Merge branch 'master' into explore/queries-import-changes
@ -1,6 +1,7 @@
|
||||
# 6.0.0-beta1 (unreleased)
|
||||
|
||||
### New Features
|
||||
|
||||
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
|
||||
* **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
|
||||
* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl)
|
||||
@ -9,7 +10,7 @@
|
||||
|
||||
### Minor
|
||||
|
||||
* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
|
||||
* **Alerting**: Use separate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
|
||||
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
|
||||
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
|
||||
* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
|
||||
@ -27,18 +28,21 @@
|
||||
* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
|
||||
* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
|
||||
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
|
||||
* **Templating**: Add percentencode formatting to variable interpolation to be used mainly for url escaping [#12764](https://github.com/grafana/grafana/issues/12764), thx [@cxcv](https://github.com/cxcv)
|
||||
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
|
||||
* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
|
||||
* **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484)
|
||||
* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
|
||||
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
|
||||
* **Dataproxy**: Add global datasource proxy timeout setting [#5699](https://github.com/grafana/grafana/issues/5699), thx [@RangerRick](https://github.com/RangerRick)
|
||||
* **Database**: Support specifying database host using IPV6 for backend database and sql datasources [#13711](https://github.com/grafana/grafana/issues/13711), thx [@ellisvlad](https://github.com/ellisvlad)
|
||||
* **Database**: Support defining additonal database connection string args when using `url` property in database settings [#14709](https://github.com/grafana/grafana/pull/14709), thx [@tpetr](https://github.com/tpetr)
|
||||
* **Stackdriver**: crossSeriesAggregation not being sent with the query [#15129](https://github.com/grafana/grafana/issues/15129), thx [@Legogris](https://github.com/Legogris)
|
||||
|
||||
### Bug fixes
|
||||
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
|
||||
* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
|
||||
* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy)
|
||||
* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
|
||||
|
||||
### Breaking changes
|
||||
* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
|
||||
|
@ -69,6 +69,7 @@
|
||||
"load-grunt-tasks": "3.5.2",
|
||||
"mini-css-extract-plugin": "^0.4.0",
|
||||
"mocha": "^4.0.1",
|
||||
"monaco-editor": "^0.15.6",
|
||||
"ng-annotate-loader": "^0.6.1",
|
||||
"ng-annotate-webpack-plugin": "^0.3.0",
|
||||
"ngtemplate-loader": "^2.0.1",
|
||||
@ -82,6 +83,7 @@
|
||||
"prettier": "1.9.2",
|
||||
"react-hot-loader": "^4.3.6",
|
||||
"react-test-renderer": "^16.5.0",
|
||||
"regexp-replace-loader": "^1.0.1",
|
||||
"sass-lint": "^1.10.2",
|
||||
"sass-loader": "^7.0.1",
|
||||
"sinon": "1.17.6",
|
||||
|
@ -10,6 +10,7 @@
|
||||
font-size: 1.1rem;
|
||||
background: $panel-options-group-header-bg;
|
||||
position: relative;
|
||||
border-radius: $border-radius $border-radius 0 0;
|
||||
|
||||
.btn {
|
||||
position: absolute;
|
||||
|
@ -35,7 +35,7 @@ func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) {
|
||||
|
||||
for _, plugin := range plugins.Apps {
|
||||
for _, route := range plugin.Routes {
|
||||
url := util.JoinUrlFragments("/api/plugin-proxy/"+plugin.Id, route.Path)
|
||||
url := util.JoinURLFragments("/api/plugin-proxy/"+plugin.Id, route.Path)
|
||||
handlers := make([]macaron.Handler, 0)
|
||||
handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{
|
||||
ReqSignedIn: true,
|
||||
|
@ -30,7 +30,7 @@ func ReverseProxyGnetReq(proxyPath string) *httputil.ReverseProxy {
|
||||
req.URL.Host = url.Host
|
||||
req.Host = url.Host
|
||||
|
||||
req.URL.Path = util.JoinUrlFragments(url.Path+"/api", proxyPath)
|
||||
req.URL.Path = util.JoinURLFragments(url.Path+"/api", proxyPath)
|
||||
|
||||
// clear cookie headers
|
||||
req.Header.Del("Cookie")
|
||||
|
@ -39,7 +39,7 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route
|
||||
req.URL.Scheme = routeURL.Scheme
|
||||
req.URL.Host = routeURL.Host
|
||||
req.Host = routeURL.Host
|
||||
req.URL.Path = util.JoinUrlFragments(routeURL.Path, proxyPath)
|
||||
req.URL.Path = util.JoinURLFragments(routeURL.Path, proxyPath)
|
||||
|
||||
if err := addHeaders(&req.Header, route, data); err != nil {
|
||||
logger.Error("Failed to render plugin headers", "error", err)
|
||||
|
@ -139,19 +139,19 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
|
||||
reqQueryVals := req.URL.Query()
|
||||
|
||||
if proxy.ds.Type == m.DS_INFLUXDB_08 {
|
||||
req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, "db/"+proxy.ds.Database+"/"+proxy.proxyPath)
|
||||
req.URL.Path = util.JoinURLFragments(proxy.targetUrl.Path, "db/"+proxy.ds.Database+"/"+proxy.proxyPath)
|
||||
reqQueryVals.Add("u", proxy.ds.User)
|
||||
reqQueryVals.Add("p", proxy.ds.Password)
|
||||
req.URL.RawQuery = reqQueryVals.Encode()
|
||||
} else if proxy.ds.Type == m.DS_INFLUXDB {
|
||||
req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath)
|
||||
req.URL.Path = util.JoinURLFragments(proxy.targetUrl.Path, proxy.proxyPath)
|
||||
req.URL.RawQuery = reqQueryVals.Encode()
|
||||
if !proxy.ds.BasicAuth {
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.User, proxy.ds.Password))
|
||||
}
|
||||
} else {
|
||||
req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath)
|
||||
req.URL.Path = util.JoinURLFragments(proxy.targetUrl.Path, proxy.proxyPath)
|
||||
}
|
||||
if proxy.ds.BasicAuth {
|
||||
req.Header.Del("Authorization")
|
||||
|
@ -46,7 +46,7 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
|
||||
req.URL.Host = targetURL.Host
|
||||
req.Host = targetURL.Host
|
||||
|
||||
req.URL.Path = util.JoinUrlFragments(targetURL.Path, proxyPath)
|
||||
req.URL.Path = util.JoinURLFragments(targetURL.Path, proxyPath)
|
||||
|
||||
// clear cookie headers
|
||||
req.Header.Del("Cookie")
|
||||
|
@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func (hs *HTTPServer) RenderToPng(c *m.ReqContext) {
|
||||
queryReader, err := util.NewUrlQueryReader(c.Req.URL)
|
||||
queryReader, err := util.NewURLQueryReader(c.Req.URL)
|
||||
if err != nil {
|
||||
c.Handle(400, "Render parameters error", err)
|
||||
return
|
||||
|
@ -20,7 +20,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
|
||||
fakeDash.Id = 1
|
||||
fakeDash.FolderId = 1
|
||||
fakeDash.HasAcl = false
|
||||
fakeDash.Uid = util.GenerateShortUid()
|
||||
fakeDash.Uid = util.GenerateShortUID()
|
||||
|
||||
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
|
||||
query.Result = fakeDash
|
||||
|
@ -23,6 +23,7 @@ const (
|
||||
DS_ACCESS_DIRECT = "direct"
|
||||
DS_ACCESS_PROXY = "proxy"
|
||||
DS_STACKDRIVER = "stackdriver"
|
||||
DS_AZURE_MONITOR = "azure-monitor"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -73,6 +74,7 @@ var knownDatasourcePlugins = map[string]bool{
|
||||
DS_MYSQL: true,
|
||||
DS_MSSQL: true,
|
||||
DS_STACKDRIVER: true,
|
||||
DS_AZURE_MONITOR: true,
|
||||
"opennms": true,
|
||||
"abhisant-druid-datasource": true,
|
||||
"dalmatinerdb-datasource": true,
|
||||
|
@ -45,9 +45,9 @@ func (fp *FrontendPluginBase) setPathsBasedOnApp(app *AppPlugin) {
|
||||
fp.BaseUrl = app.BaseUrl
|
||||
|
||||
if isExternalPlugin(app.PluginDir) {
|
||||
fp.Module = util.JoinUrlFragments("plugins/"+app.Id, appSubPath) + "/module"
|
||||
fp.Module = util.JoinURLFragments("plugins/"+app.Id, appSubPath) + "/module"
|
||||
} else {
|
||||
fp.Module = util.JoinUrlFragments("app/plugins/app/"+app.Id, appSubPath) + "/module"
|
||||
fp.Module = util.JoinURLFragments("app/plugins/app/"+app.Id, appSubPath) + "/module"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -169,7 +169,7 @@ func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err erro
|
||||
}
|
||||
|
||||
if f.Name() == "node_modules" {
|
||||
return util.WalkSkipDir
|
||||
return util.ErrWalkSkipDir
|
||||
}
|
||||
|
||||
if f.IsDir() {
|
||||
|
@ -80,7 +80,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
|
||||
return nil, models.ErrDashboardFolderNameExists
|
||||
}
|
||||
|
||||
if !util.IsValidShortUid(dash.Uid) {
|
||||
if !util.IsValidShortUID(dash.Uid) {
|
||||
return nil, models.ErrDashboardInvalidUid
|
||||
} else if len(dash.Uid) > 40 {
|
||||
return nil, models.ErrDashboardUidToLong
|
||||
|
@ -85,7 +85,7 @@ func GetAlertNotificationsWithUidToSend(query *m.GetAlertNotificationsWithUidToS
|
||||
var sql bytes.Buffer
|
||||
params := make([]interface{}, 0)
|
||||
|
||||
sql.WriteString(`SELECT
|
||||
sql.WriteString(`SELECT
|
||||
alert_notification.id,
|
||||
alert_notification.uid,
|
||||
alert_notification.org_id,
|
||||
@ -276,7 +276,7 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
|
||||
|
||||
func generateNewAlertNotificationUid(sess *DBSession, orgId int64) (string, error) {
|
||||
for i := 0; i < 3; i++ {
|
||||
uid := util.GenerateShortUid()
|
||||
uid := util.GenerateShortUID()
|
||||
exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.AlertNotification{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
@ -27,7 +27,7 @@ func init() {
|
||||
bus.AddHandler("sql", HasEditPermissionInFolders)
|
||||
}
|
||||
|
||||
var generateNewUid func() string = util.GenerateShortUid
|
||||
var generateNewUid func() string = util.GenerateShortUID
|
||||
|
||||
func SaveDashboard(cmd *m.SaveDashboardCommand) error {
|
||||
return inTransaction(func(sess *DBSession) error {
|
||||
|
@ -106,7 +106,7 @@ func TestDashboardDataAccess(t *testing.T) {
|
||||
if timesCalled <= 2 {
|
||||
return savedDash.Uid
|
||||
}
|
||||
return util.GenerateShortUid()
|
||||
return util.GenerateShortUID()
|
||||
}
|
||||
cmd := m.SaveDashboardCommand{
|
||||
OrgId: 1,
|
||||
@ -119,7 +119,7 @@ func TestDashboardDataAccess(t *testing.T) {
|
||||
err := SaveDashboard(&cmd)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
generateNewUid = util.GenerateShortUid
|
||||
generateNewUid = util.GenerateShortUID
|
||||
})
|
||||
|
||||
Convey("Should be able to create dashboard", func() {
|
||||
|
@ -196,6 +196,23 @@ func (ss *SqlStore) ensureAdminUser() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (ss *SqlStore) buildExtraConnectionString(sep rune) string {
|
||||
if ss.dbCfg.UrlQueryParams == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for key, values := range ss.dbCfg.UrlQueryParams {
|
||||
for _, value := range values {
|
||||
sb.WriteRune(sep)
|
||||
sb.WriteString(key)
|
||||
sb.WriteRune('=')
|
||||
sb.WriteString(value)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (ss *SqlStore) buildConnectionString() (string, error) {
|
||||
cnnstr := ss.dbCfg.ConnectionString
|
||||
|
||||
@ -222,8 +239,10 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
|
||||
mysql.RegisterTLSConfig("custom", tlsCert)
|
||||
cnnstr += "&tls=custom"
|
||||
}
|
||||
|
||||
cnnstr += ss.buildExtraConnectionString('&')
|
||||
case migrator.POSTGRES:
|
||||
host, port, err := util.SplitIpPort(ss.dbCfg.Host, "5432")
|
||||
host, port, err := util.SplitIPPort(ss.dbCfg.Host, "5432")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -234,6 +253,8 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
|
||||
ss.dbCfg.User = "''"
|
||||
}
|
||||
cnnstr = fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s", ss.dbCfg.User, ss.dbCfg.Pwd, host, port, ss.dbCfg.Name, ss.dbCfg.SslMode, ss.dbCfg.ClientCertPath, ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath)
|
||||
|
||||
cnnstr += ss.buildExtraConnectionString(' ')
|
||||
case migrator.SQLITE:
|
||||
// special case for tests
|
||||
if !filepath.IsAbs(ss.dbCfg.Path) {
|
||||
@ -241,6 +262,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
|
||||
}
|
||||
os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
|
||||
cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", ss.dbCfg.Path, ss.dbCfg.CacheMode)
|
||||
cnnstr += ss.buildExtraConnectionString('&')
|
||||
default:
|
||||
return "", fmt.Errorf("Unknown database type: %s", ss.dbCfg.Type)
|
||||
}
|
||||
@ -297,6 +319,8 @@ func (ss *SqlStore) readConfig() {
|
||||
ss.dbCfg.User = userInfo.Username()
|
||||
ss.dbCfg.Pwd, _ = userInfo.Password()
|
||||
}
|
||||
|
||||
ss.dbCfg.UrlQueryParams = dbURL.Query()
|
||||
} else {
|
||||
ss.dbCfg.Type = sec.Key("type").String()
|
||||
ss.dbCfg.Host = sec.Key("host").String()
|
||||
@ -406,4 +430,5 @@ type DatabaseConfig struct {
|
||||
MaxIdleConn int
|
||||
ConnMaxLifetime int
|
||||
CacheMode string
|
||||
UrlQueryParams map[string][]string
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ func generateConnectionString(datasource *models.DataSource) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
server, port, err := util.SplitIpPort(datasource.Url, "1433")
|
||||
server, port, err := util.SplitIPPort(datasource.Url, "1433")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
@ -233,12 +233,12 @@ func buildFilterString(metricType string, filterParts []interface{}) string {
|
||||
}
|
||||
|
||||
func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) {
|
||||
primaryAggregation := query.Model.Get("primaryAggregation").MustString()
|
||||
crossSeriesReducer := query.Model.Get("crossSeriesReducer").MustString()
|
||||
perSeriesAligner := query.Model.Get("perSeriesAligner").MustString()
|
||||
alignmentPeriod := query.Model.Get("alignmentPeriod").MustString()
|
||||
|
||||
if primaryAggregation == "" {
|
||||
primaryAggregation = "REDUCE_NONE"
|
||||
if crossSeriesReducer == "" {
|
||||
crossSeriesReducer = "REDUCE_NONE"
|
||||
}
|
||||
|
||||
if perSeriesAligner == "" {
|
||||
@ -267,7 +267,7 @@ func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) {
|
||||
alignmentPeriod = "+3600s"
|
||||
}
|
||||
|
||||
params.Add("aggregation.crossSeriesReducer", primaryAggregation)
|
||||
params.Add("aggregation.crossSeriesReducer", crossSeriesReducer)
|
||||
params.Add("aggregation.perSeriesAligner", perSeriesAligner)
|
||||
params.Add("aggregation.alignmentPeriod", alignmentPeriod)
|
||||
|
||||
|
@ -173,7 +173,7 @@ func TestStackdriver(t *testing.T) {
|
||||
Convey("and query has aggregation mean set", func() {
|
||||
tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
|
||||
"metricType": "a/metric/type",
|
||||
"primaryAggregation": "REDUCE_MEAN",
|
||||
"crossSeriesReducer": "REDUCE_SUM",
|
||||
"view": "FULL",
|
||||
})
|
||||
|
||||
@ -182,11 +182,11 @@ func TestStackdriver(t *testing.T) {
|
||||
|
||||
So(len(queries), ShouldEqual, 1)
|
||||
So(queries[0].RefID, ShouldEqual, "A")
|
||||
So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_MEAN&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
|
||||
So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_SUM&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
|
||||
So(len(queries[0].Params), ShouldEqual, 7)
|
||||
So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
|
||||
So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
|
||||
So(queries[0].Params["aggregation.crossSeriesReducer"][0], ShouldEqual, "REDUCE_MEAN")
|
||||
So(queries[0].Params["aggregation.crossSeriesReducer"][0], ShouldEqual, "REDUCE_SUM")
|
||||
So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
|
||||
So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, "+60s")
|
||||
So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
|
||||
@ -196,7 +196,7 @@ func TestStackdriver(t *testing.T) {
|
||||
Convey("and query has group bys", func() {
|
||||
tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
|
||||
"metricType": "a/metric/type",
|
||||
"primaryAggregation": "REDUCE_NONE",
|
||||
"crossSeriesReducer": "REDUCE_NONE",
|
||||
"groupBys": []interface{}{"metric.label.group1", "metric.label.group2"},
|
||||
"view": "FULL",
|
||||
})
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetRandomString generate random string by specify chars.
|
||||
// source: https://github.com/gogits/gogs/blob/9ee80e3e5426821f03a4e99fad34418f5c736413/modules/base/tool.go#L58
|
||||
func GetRandomString(n int, alphabets ...byte) string {
|
||||
const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
@ -27,18 +28,21 @@ func GetRandomString(n int, alphabets ...byte) string {
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
// EncodePassword encodes a password using PBKDF2.
|
||||
func EncodePassword(password string, salt string) string {
|
||||
newPasswd := PBKDF2([]byte(password), []byte(salt), 10000, 50, sha256.New)
|
||||
return hex.EncodeToString(newPasswd)
|
||||
}
|
||||
|
||||
// Encode string to md5 hex value.
|
||||
// EncodeMd5 encodes a string to md5 hex value.
|
||||
func EncodeMd5(str string) string {
|
||||
m := md5.New()
|
||||
m.Write([]byte(str))
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
// PBKDF2 implements Password-Based Key Derivation Function 2), aimed to reduce
|
||||
// the vulnerability of encrypted keys to brute force attacks.
|
||||
// http://code.google.com/p/go/source/browse/pbkdf2/pbkdf2.go?repo=crypto
|
||||
func PBKDF2(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte {
|
||||
prf := hmac.New(h, password)
|
||||
@ -77,11 +81,13 @@ func PBKDF2(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte
|
||||
return dk[:keyLen]
|
||||
}
|
||||
|
||||
// GetBasicAuthHeader returns a base64 encoded string from user and password.
|
||||
func GetBasicAuthHeader(user string, password string) string {
|
||||
var userAndPass = user + ":" + password
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userAndPass))
|
||||
}
|
||||
|
||||
// DecodeBasicAuthHeader decodes user and password from a basic auth header.
|
||||
func DecodeBasicAuthHeader(header string) (string, string, error) {
|
||||
var code string
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
@ -102,6 +108,7 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
|
||||
return userAndPass[0], userAndPass[1], nil
|
||||
}
|
||||
|
||||
// RandomHex returns a random string from a n seed.
|
||||
func RandomHex(n int) (string, error) {
|
||||
bytes := make([]byte, n)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
|
||||
const saltLength = 8
|
||||
|
||||
// Decrypt decrypts a payload with a given secret.
|
||||
func Decrypt(payload []byte, secret string) ([]byte, error) {
|
||||
salt := payload[:saltLength]
|
||||
key := encryptionKeyToBytes(secret, string(salt))
|
||||
@ -36,6 +37,7 @@ func Decrypt(payload []byte, secret string) ([]byte, error) {
|
||||
return payloadDst, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts a payload with a given secret.
|
||||
func Encrypt(payload []byte, secret string) ([]byte, error) {
|
||||
salt := GetRandomString(saltLength)
|
||||
|
||||
|
@ -8,8 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
//WalkSkipDir is the Error returned when we want to skip descending into a directory
|
||||
var WalkSkipDir = errors.New("skip this directory")
|
||||
//ErrWalkSkipDir is the Error returned when we want to skip descending into a directory
|
||||
var ErrWalkSkipDir = errors.New("skip this directory")
|
||||
|
||||
//WalkFunc is a callback function called for each path as a directory is walked
|
||||
//If resolvedPath != "", then we are following symbolic links.
|
||||
@ -50,7 +50,7 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow
|
||||
}
|
||||
err := walkFn(resolvedPath, info, nil)
|
||||
if err != nil {
|
||||
if info.IsDir() && err == WalkSkipDir {
|
||||
if info.IsDir() && err == ErrWalkSkipDir {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
|
@ -4,7 +4,8 @@ import (
|
||||
"net"
|
||||
)
|
||||
|
||||
func SplitIpPort(ipStr string, portDefault string) (ip string, port string, err error) {
|
||||
// SplitIPPort splits the ip string and port.
|
||||
func SplitIPPort(ipStr string, portDefault string) (ip string, port string, err error) {
|
||||
ipAddr := net.ParseIP(ipStr)
|
||||
|
||||
if ipAddr == nil {
|
||||
|
@ -6,10 +6,10 @@ import (
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestSplitIpPort(t *testing.T) {
|
||||
func TestSplitIPPort(t *testing.T) {
|
||||
|
||||
Convey("When parsing an IPv4 without explicit port", t, func() {
|
||||
ip, port, err := SplitIpPort("1.2.3.4", "5678")
|
||||
ip, port, err := SplitIPPort("1.2.3.4", "5678")
|
||||
|
||||
So(err, ShouldEqual, nil)
|
||||
So(ip, ShouldEqual, "1.2.3.4")
|
||||
@ -17,7 +17,7 @@ func TestSplitIpPort(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("When parsing an IPv6 without explicit port", t, func() {
|
||||
ip, port, err := SplitIpPort("::1", "5678")
|
||||
ip, port, err := SplitIPPort("::1", "5678")
|
||||
|
||||
So(err, ShouldEqual, nil)
|
||||
So(ip, ShouldEqual, "::1")
|
||||
@ -25,7 +25,7 @@ func TestSplitIpPort(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("When parsing an IPv4 with explicit port", t, func() {
|
||||
ip, port, err := SplitIpPort("1.2.3.4:56", "78")
|
||||
ip, port, err := SplitIPPort("1.2.3.4:56", "78")
|
||||
|
||||
So(err, ShouldEqual, nil)
|
||||
So(ip, ShouldEqual, "1.2.3.4")
|
||||
@ -33,7 +33,7 @@ func TestSplitIpPort(t *testing.T) {
|
||||
})
|
||||
|
||||
Convey("When parsing an IPv6 with explicit port", t, func() {
|
||||
ip, port, err := SplitIpPort("[::1]:56", "78")
|
||||
ip, port, err := SplitIPPort("[::1]:56", "78")
|
||||
|
||||
So(err, ShouldEqual, nil)
|
||||
So(ip, ShouldEqual, "::1")
|
||||
|
@ -1,3 +1,4 @@
|
||||
package util
|
||||
|
||||
// DynMap defines a dynamic map interface.
|
||||
type DynMap map[string]interface{}
|
||||
|
@ -19,7 +19,7 @@ func Md5Sum(reader io.Reader) (string, error) {
|
||||
return returnMD5String, nil
|
||||
}
|
||||
|
||||
// Md5Sum calculates the md5sum of a string
|
||||
// Md5SumString calculates the md5sum of a string
|
||||
func Md5SumString(input string) (string, error) {
|
||||
buffer := strings.NewReader(input)
|
||||
return Md5Sum(buffer)
|
||||
|
@ -8,19 +8,19 @@ import (
|
||||
|
||||
var allowedChars = shortid.DefaultABC
|
||||
|
||||
var validUidPattern = regexp.MustCompile(`^[a-zA-Z0-9\-\_]*$`).MatchString
|
||||
var validUIDPattern = regexp.MustCompile(`^[a-zA-Z0-9\-\_]*$`).MatchString
|
||||
|
||||
func init() {
|
||||
gen, _ := shortid.New(1, allowedChars, 1)
|
||||
shortid.SetDefault(gen)
|
||||
}
|
||||
|
||||
// IsValidShortUid checks if short unique identifier contains valid characters
|
||||
func IsValidShortUid(uid string) bool {
|
||||
return validUidPattern(uid)
|
||||
// IsValidShortUID checks if short unique identifier contains valid characters
|
||||
func IsValidShortUID(uid string) bool {
|
||||
return validUIDPattern(uid)
|
||||
}
|
||||
|
||||
// GenerateShortUid generates a short unique identifier.
|
||||
func GenerateShortUid() string {
|
||||
// GenerateShortUID generates a short unique identifier.
|
||||
func GenerateShortUID() string {
|
||||
return shortid.MustGenerate()
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import "testing"
|
||||
|
||||
func TestAllowedCharMatchesUidPattern(t *testing.T) {
|
||||
for _, c := range allowedChars {
|
||||
if !IsValidShortUid(string(c)) {
|
||||
if !IsValidShortUID(string(c)) {
|
||||
t.Fatalf("charset for creating new shortids contains chars not present in uid pattern")
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// StringsFallback2 returns the first of two not empty strings.
|
||||
func StringsFallback2(val1 string, val2 string) string {
|
||||
return stringsFallback(val1, val2)
|
||||
}
|
||||
|
||||
// StringsFallback3 returns the first of three not empty strings.
|
||||
func StringsFallback3(val1 string, val2 string, val3 string) string {
|
||||
return stringsFallback(val1, val2, val3)
|
||||
}
|
||||
@ -24,6 +26,7 @@ func stringsFallback(vals ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// SplitString splits a string by commas or empty spaces.
|
||||
func SplitString(str string) []string {
|
||||
if len(str) == 0 {
|
||||
return []string{}
|
||||
@ -32,6 +35,7 @@ func SplitString(str string) []string {
|
||||
return regexp.MustCompile("[, ]+").Split(str, -1)
|
||||
}
|
||||
|
||||
// GetAgeString returns a string representing certain time from years to minutes.
|
||||
func GetAgeString(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "?"
|
||||
|
@ -5,22 +5,26 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UrlQueryReader struct {
|
||||
// URLQueryReader is a URL query type.
|
||||
type URLQueryReader struct {
|
||||
values url.Values
|
||||
}
|
||||
|
||||
func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) {
|
||||
// NewURLQueryReader parses a raw query and returns it as a URLQueryReader type.
|
||||
func NewURLQueryReader(urlInfo *url.URL) (*URLQueryReader, error) {
|
||||
u, err := url.ParseQuery(urlInfo.RawQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UrlQueryReader{
|
||||
return &URLQueryReader{
|
||||
values: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *UrlQueryReader) Get(name string, def string) string {
|
||||
// Get parse parameters from an URL. If the parameter does not exist, it returns
|
||||
// the default value.
|
||||
func (r *URLQueryReader) Get(name string, def string) string {
|
||||
val := r.values[name]
|
||||
if len(val) == 0 {
|
||||
return def
|
||||
@ -29,7 +33,8 @@ func (r *UrlQueryReader) Get(name string, def string) string {
|
||||
return val[0]
|
||||
}
|
||||
|
||||
func JoinUrlFragments(a, b string) string {
|
||||
// JoinURLFragments joins two URL fragments into only one URL string.
|
||||
func JoinURLFragments(a, b string) string {
|
||||
aslash := strings.HasSuffix(a, "/")
|
||||
bslash := strings.HasPrefix(b, "/")
|
||||
|
||||
|
@ -1,60 +1,60 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func TestUrl(t *testing.T) {
|
||||
|
||||
Convey("When joining two urls where right hand side is empty", t, func() {
|
||||
result := JoinUrlFragments("http://localhost:8080", "")
|
||||
result := JoinURLFragments("http://localhost:8080", "")
|
||||
|
||||
So(result, ShouldEqual, "http://localhost:8080")
|
||||
})
|
||||
|
||||
Convey("When joining two urls where right hand side is empty and lefthand side has a trailing slash", t, func() {
|
||||
result := JoinUrlFragments("http://localhost:8080/", "")
|
||||
result := JoinURLFragments("http://localhost:8080/", "")
|
||||
|
||||
So(result, ShouldEqual, "http://localhost:8080/")
|
||||
})
|
||||
|
||||
Convey("When joining two urls where neither has a trailing slash", t, func() {
|
||||
result := JoinUrlFragments("http://localhost:8080", "api")
|
||||
result := JoinURLFragments("http://localhost:8080", "api")
|
||||
|
||||
So(result, ShouldEqual, "http://localhost:8080/api")
|
||||
})
|
||||
|
||||
Convey("When joining two urls where lefthand side has a trailing slash", t, func() {
|
||||
result := JoinUrlFragments("http://localhost:8080/", "api")
|
||||
result := JoinURLFragments("http://localhost:8080/", "api")
|
||||
|
||||
So(result, ShouldEqual, "http://localhost:8080/api")
|
||||
})
|
||||
|
||||
Convey("When joining two urls where righthand side has preceding slash", t, func() {
|
||||
result := JoinUrlFragments("http://localhost:8080", "/api")
|
||||
result := JoinURLFragments("http://localhost:8080", "/api")
|
||||
|
||||
So(result, ShouldEqual, "http://localhost:8080/api")
|
||||
})
|
||||
|
||||
Convey("When joining two urls where righthand side has trailing slash", t, func() {
|
||||
result := JoinUrlFragments("http://localhost:8080", "api/")
|
||||
result := JoinURLFragments("http://localhost:8080", "api/")
|
||||
|
||||
So(result, ShouldEqual, "http://localhost:8080/api/")
|
||||
})
|
||||
|
||||
Convey("When joining two urls where lefthand side has a trailing slash and righthand side has preceding slash", t, func() {
|
||||
result := JoinUrlFragments("http://localhost:8080/", "/api/")
|
||||
result := JoinURLFragments("http://localhost:8080/", "/api/")
|
||||
|
||||
So(result, ShouldEqual, "http://localhost:8080/api/")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewUrlQueryReader(t *testing.T) {
|
||||
func TestNewURLQueryReader(t *testing.T) {
|
||||
u, _ := url.Parse("http://www.abc.com/foo?bar=baz&bar2=baz2")
|
||||
uqr, _ := NewUrlQueryReader(u)
|
||||
uqr, _ := NewURLQueryReader(u)
|
||||
|
||||
Convey("when trying to retrieve the first query value", t, func() {
|
||||
result := uqr.Get("bar", "foodef")
|
||||
|
@ -13,6 +13,7 @@ var (
|
||||
regexEmail = regexp.MustCompile(emailRegexPattern)
|
||||
)
|
||||
|
||||
// IsEmail checks if a string is a valid email address.
|
||||
func IsEmail(str string) bool {
|
||||
return regexEmail.MatchString(strings.ToLower(str))
|
||||
}
|
||||
|
@ -9,9 +9,9 @@ import PageHeader from '../PageHeader/PageHeader';
|
||||
import Footer from '../Footer/Footer';
|
||||
import PageContents from './PageContents';
|
||||
import { CustomScrollbar } from '@grafana/ui';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
children: JSX.Element[] | JSX.Element;
|
||||
navModel: NavModel;
|
||||
}
|
||||
@ -28,7 +28,7 @@ class Page extends Component<Props> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.title !== this.props.title) {
|
||||
if (!isEqual(prevProps.navModel, this.props.navModel)) {
|
||||
this.updateTitle();
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,16 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { RawTimeRange, TimeRange } from '@grafana/ui';
|
||||
import { TimeRange, RawTimeRange } from '@grafana/ui';
|
||||
|
||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
import { toggleGraph } from './state/actions';
|
||||
import { toggleGraph, changeTime } from './state/actions';
|
||||
import Graph from './Graph';
|
||||
import Panel from './Panel';
|
||||
|
||||
interface GraphContainerProps {
|
||||
onChangeTime: (range: TimeRange) => void;
|
||||
exploreId: ExploreId;
|
||||
graphResult?: any[];
|
||||
loading: boolean;
|
||||
@ -20,6 +19,7 @@ interface GraphContainerProps {
|
||||
showingTable: boolean;
|
||||
split: boolean;
|
||||
toggleGraph: typeof toggleGraph;
|
||||
changeTime: typeof changeTime;
|
||||
}
|
||||
|
||||
export class GraphContainer extends PureComponent<GraphContainerProps> {
|
||||
@ -27,8 +27,12 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
|
||||
this.props.toggleGraph(this.props.exploreId);
|
||||
};
|
||||
|
||||
onChangeTime = (timeRange: TimeRange) => {
|
||||
this.props.changeTime(this.props.exploreId, timeRange);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { exploreId, graphResult, loading, onChangeTime, showingGraph, showingTable, range, split } = this.props;
|
||||
const { exploreId, graphResult, loading, showingGraph, showingTable, range, split } = this.props;
|
||||
const graphHeight = showingGraph && showingTable ? '200px' : '400px';
|
||||
|
||||
if (!graphResult) {
|
||||
@ -41,7 +45,7 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
|
||||
data={graphResult}
|
||||
height={graphHeight}
|
||||
id={`explore-graph-${exploreId}`}
|
||||
onChangeTime={onChangeTime}
|
||||
onChangeTime={this.onChangeTime}
|
||||
range={range}
|
||||
split={split}
|
||||
/>
|
||||
@ -61,6 +65,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
|
||||
|
||||
const mapDispatchToProps = {
|
||||
toggleGraph,
|
||||
changeTime,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(GraphContainer));
|
||||
|
@ -128,17 +128,21 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
||||
}
|
||||
|
||||
onChange = ({ value }) => {
|
||||
const textChanged = value.document !== this.state.value.document;
|
||||
const documentChanged = value.document !== this.state.value.document;
|
||||
const prevValue = this.state.value;
|
||||
|
||||
// Control editor loop, then pass text change up to parent
|
||||
this.setState({ value }, () => {
|
||||
if (textChanged) {
|
||||
this.handleChangeValue();
|
||||
if (documentChanged) {
|
||||
const textChanged = Plain.serialize(prevValue) !== Plain.serialize(value);
|
||||
if (textChanged) {
|
||||
this.handleChangeValue();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show suggest menu on text input
|
||||
if (textChanged && value.selection.isCollapsed) {
|
||||
if (documentChanged && value.selection.isCollapsed) {
|
||||
// Need one paint to allow DOM-based typeahead rules to work
|
||||
window.requestAnimationFrame(this.handleTypeahead);
|
||||
} else if (!this.resetTimer) {
|
||||
|
@ -131,16 +131,22 @@ export class QueryRow extends PureComponent<QueryRowProps> {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="query-row-tools">
|
||||
<button className="btn navbar-button navbar-button--tight" onClick={this.onClickClearButton}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
<button className="btn navbar-button navbar-button--tight" onClick={this.onClickAddButton}>
|
||||
<i className="fa fa-plus" />
|
||||
</button>
|
||||
<button className="btn navbar-button navbar-button--tight" onClick={this.onClickRemoveButton}>
|
||||
<i className="fa fa-minus" />
|
||||
</button>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<button className="gf-form-label gf-form-label--btn" onClick={this.onClickClearButton}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<button className="gf-form-label gf-form-label--btn" onClick={this.onClickAddButton}>
|
||||
<i className="fa fa-plus" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<button className="gf-form-label gf-form-label--btn" onClick={this.onClickRemoveButton}>
|
||||
<i className="fa fa-minus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ import * as prometheusPlugin from 'app/plugins/datasource/prometheus/module';
|
||||
import * as mssqlPlugin from 'app/plugins/datasource/mssql/module';
|
||||
import * as testDataDSPlugin from 'app/plugins/datasource/testdata/module';
|
||||
import * as stackdriverPlugin from 'app/plugins/datasource/stackdriver/module';
|
||||
import * as azureMonitorPlugin from 'app/plugins/datasource/grafana-azure-monitor-datasource/module';
|
||||
|
||||
import * as textPanel from 'app/plugins/panel/text/module';
|
||||
import * as text2Panel from 'app/plugins/panel/text2/module';
|
||||
@ -41,6 +42,7 @@ const builtInPlugins = {
|
||||
'app/plugins/datasource/prometheus/module': prometheusPlugin,
|
||||
'app/plugins/datasource/testdata/module': testDataDSPlugin,
|
||||
'app/plugins/datasource/stackdriver/module': stackdriverPlugin,
|
||||
'app/plugins/datasource/grafana-azure-monitor-datasource/module': azureMonitorPlugin,
|
||||
|
||||
'app/plugins/panel/text/module': textPanel,
|
||||
'app/plugins/panel/text2/module': text2Panel,
|
||||
|
@ -0,0 +1,16 @@
|
||||
export class QueryCtrl {
|
||||
target: any;
|
||||
datasource: any;
|
||||
panelCtrl: any;
|
||||
panel: any;
|
||||
hasRawMode: boolean;
|
||||
error: string;
|
||||
|
||||
constructor(public $scope, _$injector) {
|
||||
this.panelCtrl = this.panelCtrl || { panel: {} };
|
||||
this.target = this.target || { target: '' };
|
||||
this.panel = this.panelCtrl.panel;
|
||||
}
|
||||
|
||||
refresh() {}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
import { QueryCtrl } from './query_ctrl';
|
||||
|
||||
export { QueryCtrl };
|
@ -0,0 +1,32 @@
|
||||
export class AzureMonitorAnnotationsQueryCtrl {
|
||||
static templateUrl = 'partials/annotations.editor.html';
|
||||
datasource: any;
|
||||
annotation: any;
|
||||
workspaces: any[];
|
||||
|
||||
defaultQuery = '<your table>\n| where $__timeFilter() \n| project TimeGenerated, Text=YourTitleColumn, Tags="tag1,tag2"';
|
||||
|
||||
/** @ngInject */
|
||||
constructor() {
|
||||
this.annotation.queryType = this.annotation.queryType || 'Azure Log Analytics';
|
||||
this.annotation.rawQuery = this.annotation.rawQuery || this.defaultQuery;
|
||||
this.getWorkspaces();
|
||||
}
|
||||
|
||||
getWorkspaces() {
|
||||
if (this.workspaces && this.workspaces.length > 0) {
|
||||
return this.workspaces;
|
||||
}
|
||||
|
||||
return this.datasource
|
||||
.getAzureLogAnalyticsWorkspaces()
|
||||
.then(list => {
|
||||
this.workspaces = list;
|
||||
if (list.length > 0 && !this.annotation.workspace) {
|
||||
this.annotation.workspace = list[0].value;
|
||||
}
|
||||
return this.workspaces;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
@ -0,0 +1,441 @@
|
||||
import AzureMonitorDatasource from '../datasource';
|
||||
import Q from 'q';
|
||||
import moment from 'moment';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
describe('AppInsightsDatasource', () => {
|
||||
const ctx: any = {
|
||||
backendSrv: {},
|
||||
templateSrv: new TemplateSrv(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.$q = Q;
|
||||
ctx.instanceSettings = {
|
||||
jsonData: { appInsightsAppId: '3ad4400f-ea7d-465d-a8fb-43fb20555d85' },
|
||||
url: 'http://appinsightsapi',
|
||||
};
|
||||
|
||||
ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.templateSrv, ctx.$q);
|
||||
});
|
||||
|
||||
describe('When performing testDatasource', () => {
|
||||
describe('and a list of metrics is returned', () => {
|
||||
const response = {
|
||||
metrics: {
|
||||
'requests/count': {
|
||||
displayName: 'Server requests',
|
||||
defaultAggregation: 'sum',
|
||||
},
|
||||
'requests/duration': {
|
||||
displayName: 'Server requests',
|
||||
defaultAggregation: 'sum',
|
||||
},
|
||||
},
|
||||
dimensions: {
|
||||
'request/source': {
|
||||
displayName: 'Request source',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = () => {
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return success status', () => {
|
||||
return ctx.ds.testDatasource().then(results => {
|
||||
expect(results.status).toEqual('success');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and a PathNotFoundError error is returned', () => {
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
code: 'PathNotFoundError',
|
||||
message: `An error message.`,
|
||||
},
|
||||
},
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
return ctx.$q.reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return error status and a detailed error message', () => {
|
||||
return ctx.ds.testDatasource().then(results => {
|
||||
expect(results.status).toEqual('error');
|
||||
expect(results.message).toEqual(
|
||||
'1. Application Insights: Not Found: Invalid Application Id for Application Insights service. '
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and an error is returned', () => {
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
code: 'SomeOtherError',
|
||||
message: `An error message.`,
|
||||
},
|
||||
},
|
||||
status: 500,
|
||||
statusText: 'Error',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
return ctx.$q.reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return error status and a detailed error message', () => {
|
||||
return ctx.ds.testDatasource().then(results => {
|
||||
expect(results.status).toEqual('error');
|
||||
expect(results.message).toEqual('1. Application Insights: Error: SomeOtherError. An error message. ');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing query', () => {
|
||||
const options = {
|
||||
range: {
|
||||
from: moment.utc('2017-08-22T20:00:00Z'),
|
||||
to: moment.utc('2017-08-22T23:59:00Z'),
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
apiVersion: '2016-09-01',
|
||||
refId: 'A',
|
||||
queryType: 'Application Insights',
|
||||
appInsights: {
|
||||
metricName: 'exceptions/server',
|
||||
groupBy: '',
|
||||
timeGrainType: 'none',
|
||||
timeGrain: '',
|
||||
timeGrainUnit: '',
|
||||
alias: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('and with a single value', () => {
|
||||
const response = {
|
||||
value: {
|
||||
start: '2017-08-30T15:53:58.845Z',
|
||||
end: '2017-09-06T15:53:58.845Z',
|
||||
'exceptions/server': {
|
||||
sum: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/exceptions/server');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a single datapoint', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(1);
|
||||
expect(results.data[0].datapoints.length).toBe(1);
|
||||
expect(results.data[0].target).toEqual('exceptions/server');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1504713238845);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and with an interval group and without a segment group by', () => {
|
||||
const response = {
|
||||
value: {
|
||||
start: '2017-08-30T15:53:58.845Z',
|
||||
end: '2017-09-06T15:53:58.845Z',
|
||||
interval: 'PT1H',
|
||||
segments: [
|
||||
{
|
||||
start: '2017-08-30T15:53:58.845Z',
|
||||
end: '2017-08-30T16:00:00.000Z',
|
||||
'exceptions/server': {
|
||||
sum: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
start: '2017-08-30T16:00:00.000Z',
|
||||
end: '2017-08-30T17:00:00.000Z',
|
||||
'exceptions/server': {
|
||||
sum: 66,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
options.targets[0].appInsights.timeGrainType = 'specific';
|
||||
options.targets[0].appInsights.timeGrain = '30';
|
||||
options.targets[0].appInsights.timeGrainUnit = 'minute';
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/exceptions/server');
|
||||
expect(options.url).toContain('interval=PT30M');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(1);
|
||||
expect(results.data[0].datapoints.length).toBe(2);
|
||||
expect(results.data[0].target).toEqual('exceptions/server');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1504108800000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(3);
|
||||
expect(results.data[0].datapoints[1][1]).toEqual(1504112400000);
|
||||
expect(results.data[0].datapoints[1][0]).toEqual(66);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and with a group by', () => {
|
||||
const response = {
|
||||
value: {
|
||||
start: '2017-08-30T15:53:58.845Z',
|
||||
end: '2017-09-06T15:53:58.845Z',
|
||||
interval: 'PT1H',
|
||||
segments: [
|
||||
{
|
||||
start: '2017-08-30T15:53:58.845Z',
|
||||
end: '2017-08-30T16:00:00.000Z',
|
||||
segments: [
|
||||
{
|
||||
'exceptions/server': {
|
||||
sum: 10,
|
||||
},
|
||||
'client/city': 'Miami',
|
||||
},
|
||||
{
|
||||
'exceptions/server': {
|
||||
sum: 1,
|
||||
},
|
||||
'client/city': 'San Jose',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
start: '2017-08-30T16:00:00.000Z',
|
||||
end: '2017-08-30T17:00:00.000Z',
|
||||
segments: [
|
||||
{
|
||||
'exceptions/server': {
|
||||
sum: 20,
|
||||
},
|
||||
'client/city': 'Miami',
|
||||
},
|
||||
{
|
||||
'exceptions/server': {
|
||||
sum: 2,
|
||||
},
|
||||
'client/city': 'San Antonio',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('and with no alias specified', () => {
|
||||
beforeEach(() => {
|
||||
options.targets[0].appInsights.groupBy = 'client/city';
|
||||
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/exceptions/server');
|
||||
expect(options.url).toContain('segment=client/city');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(3);
|
||||
expect(results.data[0].datapoints.length).toBe(2);
|
||||
expect(results.data[0].target).toEqual('exceptions/server{client/city="Miami"}');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1504108800000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(10);
|
||||
expect(results.data[0].datapoints[1][1]).toEqual(1504112400000);
|
||||
expect(results.data[0].datapoints[1][0]).toEqual(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and with an alias specified', () => {
|
||||
beforeEach(() => {
|
||||
options.targets[0].appInsights.groupBy = 'client/city';
|
||||
options.targets[0].appInsights.alias = '{{metric}} + {{groupbyname}} + {{groupbyvalue}}';
|
||||
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/exceptions/server');
|
||||
expect(options.url).toContain('segment=client/city');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(3);
|
||||
expect(results.data[0].datapoints.length).toBe(2);
|
||||
expect(results.data[0].target).toEqual('exceptions/server + client/city + Miami');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1504108800000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(10);
|
||||
expect(results.data[0].datapoints[1][1]).toEqual(1504112400000);
|
||||
expect(results.data[0].datapoints[1][0]).toEqual(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery', () => {
|
||||
describe('with a metric names query', () => {
|
||||
const response = {
|
||||
metrics: {
|
||||
'exceptions/server': {},
|
||||
'requests/count': {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/metadata');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of metric names', () => {
|
||||
return ctx.ds.metricFindQuery('appInsightsMetricNames()').then(results => {
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].text).toBe('exceptions/server');
|
||||
expect(results[0].value).toBe('exceptions/server');
|
||||
expect(results[1].text).toBe('requests/count');
|
||||
expect(results[1].value).toBe('requests/count');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with metadata group by query', () => {
|
||||
const response = {
|
||||
metrics: {
|
||||
'exceptions/server': {
|
||||
supportedAggregations: ['sum'],
|
||||
supportedGroupBy: {
|
||||
all: ['client/os', 'client/city', 'client/browser'],
|
||||
},
|
||||
defaultAggregation: 'sum',
|
||||
},
|
||||
'requests/count': {
|
||||
supportedAggregations: ['avg', 'sum', 'total'],
|
||||
supportedGroupBy: {
|
||||
all: ['client/os', 'client/city', 'client/browser'],
|
||||
},
|
||||
defaultAggregation: 'avg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/metadata');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of group bys', () => {
|
||||
return ctx.ds.metricFindQuery('appInsightsGroupBys(requests/count)').then(results => {
|
||||
expect(results[0].text).toContain('client/os');
|
||||
expect(results[0].value).toContain('client/os');
|
||||
expect(results[1].text).toContain('client/city');
|
||||
expect(results[1].value).toContain('client/city');
|
||||
expect(results[2].text).toContain('client/browser');
|
||||
expect(results[2].value).toContain('client/browser');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When getting Metric Names', () => {
|
||||
const response = {
|
||||
metrics: {
|
||||
'exceptions/server': {},
|
||||
'requests/count': {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/metadata');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of metric names', () => {
|
||||
return ctx.ds.getAppInsightsMetricNames().then(results => {
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].text).toBe('exceptions/server');
|
||||
expect(results[0].value).toBe('exceptions/server');
|
||||
expect(results[1].text).toBe('requests/count');
|
||||
expect(results[1].value).toBe('requests/count');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When getting Metric Metadata', () => {
|
||||
const response = {
|
||||
metrics: {
|
||||
'exceptions/server': {
|
||||
supportedAggregations: ['sum'],
|
||||
supportedGroupBy: {
|
||||
all: ['client/os', 'client/city', 'client/browser'],
|
||||
},
|
||||
defaultAggregation: 'sum',
|
||||
},
|
||||
'requests/count': {
|
||||
supportedAggregations: ['avg', 'sum', 'total'],
|
||||
supportedGroupBy: {
|
||||
all: ['client/os', 'client/city', 'client/browser'],
|
||||
},
|
||||
defaultAggregation: 'avg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('/metrics/metadata');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of group bys', () => {
|
||||
return ctx.ds.getAppInsightsMetricMetadata('requests/count').then(results => {
|
||||
expect(results.primaryAggType).toEqual('avg');
|
||||
expect(results.supportedAggTypes).toContain('avg');
|
||||
expect(results.supportedAggTypes).toContain('sum');
|
||||
expect(results.supportedAggTypes).toContain('total');
|
||||
expect(results.supportedGroupBy).toContain('client/os');
|
||||
expect(results.supportedGroupBy).toContain('client/city');
|
||||
expect(results.supportedGroupBy).toContain('client/browser');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,227 @@
|
||||
import _ from 'lodash';
|
||||
import AppInsightsQuerystringBuilder from './app_insights_querystring_builder';
|
||||
import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder';
|
||||
import ResponseParser from './response_parser';
|
||||
|
||||
export interface LogAnalyticsColumn {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
export default class AppInsightsDatasource {
|
||||
id: number;
|
||||
url: string;
|
||||
baseUrl: string;
|
||||
version = 'beta';
|
||||
applicationId: string;
|
||||
logAnalyticsColumns: { [key: string]: LogAnalyticsColumn[] } = {};
|
||||
|
||||
/** @ngInject */
|
||||
constructor(instanceSettings, private backendSrv, private templateSrv, private $q) {
|
||||
this.id = instanceSettings.id;
|
||||
this.applicationId = instanceSettings.jsonData.appInsightsAppId;
|
||||
this.baseUrl = `/appinsights/${this.version}/apps/${this.applicationId}`;
|
||||
this.url = instanceSettings.url;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.applicationId && this.applicationId.length > 0;
|
||||
}
|
||||
|
||||
query(options) {
|
||||
const queries = _.filter(options.targets, item => {
|
||||
return item.hide !== true;
|
||||
}).map(target => {
|
||||
const item = target.appInsights;
|
||||
if (item.rawQuery) {
|
||||
const querystringBuilder = new LogAnalyticsQuerystringBuilder(
|
||||
this.templateSrv.replace(item.rawQueryString, options.scopedVars),
|
||||
options,
|
||||
'timestamp'
|
||||
);
|
||||
const generated = querystringBuilder.generate();
|
||||
|
||||
const url = `${this.baseUrl}/query?${generated.uriString}`;
|
||||
|
||||
return {
|
||||
refId: target.refId,
|
||||
intervalMs: options.intervalMs,
|
||||
maxDataPoints: options.maxDataPoints,
|
||||
datasourceId: this.id,
|
||||
url: url,
|
||||
format: options.format,
|
||||
alias: item.alias,
|
||||
query: generated.rawQuery,
|
||||
xaxis: item.xaxis,
|
||||
yaxis: item.yaxis,
|
||||
spliton: item.spliton,
|
||||
raw: true,
|
||||
};
|
||||
} else {
|
||||
const querystringBuilder = new AppInsightsQuerystringBuilder(
|
||||
options.range.from,
|
||||
options.range.to,
|
||||
options.interval
|
||||
);
|
||||
|
||||
if (item.groupBy !== 'none') {
|
||||
querystringBuilder.setGroupBy(this.templateSrv.replace(item.groupBy, options.scopedVars));
|
||||
}
|
||||
querystringBuilder.setAggregation(item.aggregation);
|
||||
querystringBuilder.setInterval(
|
||||
item.timeGrainType,
|
||||
this.templateSrv.replace(item.timeGrain, options.scopedVars),
|
||||
item.timeGrainUnit
|
||||
);
|
||||
|
||||
querystringBuilder.setFilter(this.templateSrv.replace(item.filter || ''));
|
||||
|
||||
const url = `${this.baseUrl}/metrics/${this.templateSrv.replace(
|
||||
encodeURI(item.metricName),
|
||||
options.scopedVars
|
||||
)}?${querystringBuilder.generate()}`;
|
||||
|
||||
return {
|
||||
refId: target.refId,
|
||||
intervalMs: options.intervalMs,
|
||||
maxDataPoints: options.maxDataPoints,
|
||||
datasourceId: this.id,
|
||||
url: url,
|
||||
format: options.format,
|
||||
alias: item.alias,
|
||||
xaxis: '',
|
||||
yaxis: '',
|
||||
spliton: '',
|
||||
raw: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (!queries || queries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = this.doQueries(queries);
|
||||
|
||||
return this.$q
|
||||
.all(promises)
|
||||
.then(results => {
|
||||
return new ResponseParser(results).parseQueryResult();
|
||||
})
|
||||
.then(results => {
|
||||
const flattened: any[] = [];
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i].columnsForDropdown) {
|
||||
this.logAnalyticsColumns[results[i].refId] = results[i].columnsForDropdown;
|
||||
}
|
||||
flattened.push(results[i]);
|
||||
}
|
||||
|
||||
return flattened;
|
||||
});
|
||||
}
|
||||
|
||||
doQueries(queries) {
|
||||
return _.map(queries, query => {
|
||||
return this.doRequest(query.url)
|
||||
.then(result => {
|
||||
return {
|
||||
result: result,
|
||||
query: query,
|
||||
};
|
||||
})
|
||||
.catch(err => {
|
||||
throw {
|
||||
error: err,
|
||||
query: query,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
annotationQuery(options) {}
|
||||
|
||||
metricFindQuery(query: string) {
|
||||
const appInsightsMetricNameQuery = query.match(/^AppInsightsMetricNames\(\)/i);
|
||||
if (appInsightsMetricNameQuery) {
|
||||
return this.getMetricNames();
|
||||
}
|
||||
|
||||
const appInsightsGroupByQuery = query.match(/^AppInsightsGroupBys\(([^\)]+?)(,\s?([^,]+?))?\)/i);
|
||||
if (appInsightsGroupByQuery) {
|
||||
const metricName = appInsightsGroupByQuery[1];
|
||||
return this.getGroupBys(this.templateSrv.replace(metricName));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
testDatasource() {
|
||||
const url = `${this.baseUrl}/metrics/metadata`;
|
||||
return this.doRequest(url)
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Successfully queried the Application Insights service.',
|
||||
title: 'Success',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Returned http status code ' + response.status,
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
let message = 'Application Insights: ';
|
||||
message += error.statusText ? error.statusText + ': ' : '';
|
||||
|
||||
if (error.data && error.data.error && error.data.error.code === 'PathNotFoundError') {
|
||||
message += 'Invalid Application Id for Application Insights service.';
|
||||
} else if (error.data && error.data.error) {
|
||||
message += error.data.error.code + '. ' + error.data.error.message;
|
||||
} else {
|
||||
message += 'Cannot connect to Application Insights REST API.';
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
doRequest(url, maxRetries = 1) {
|
||||
return this.backendSrv
|
||||
.datasourceRequest({
|
||||
url: this.url + url,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch(error => {
|
||||
if (maxRetries > 0) {
|
||||
return this.doRequest(url, maxRetries - 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
getMetricNames() {
|
||||
const url = `${this.baseUrl}/metrics/metadata`;
|
||||
return this.doRequest(url).then(ResponseParser.parseMetricNames);
|
||||
}
|
||||
|
||||
getMetricMetadata(metricName: string) {
|
||||
const url = `${this.baseUrl}/metrics/metadata`;
|
||||
return this.doRequest(url).then(result => {
|
||||
return new ResponseParser(result).parseMetadata(metricName);
|
||||
});
|
||||
}
|
||||
|
||||
getGroupBys(metricName: string) {
|
||||
return this.getMetricMetadata(metricName).then(result => {
|
||||
return new ResponseParser(result).parseGroupBys();
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
import AppInsightsQuerystringBuilder from './app_insights_querystring_builder';
|
||||
import moment from 'moment';
|
||||
|
||||
describe('AppInsightsQuerystringBuilder', () => {
|
||||
let builder: AppInsightsQuerystringBuilder;
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new AppInsightsQuerystringBuilder(moment.utc('2017-08-22 06:00'), moment.utc('2017-08-22 07:00'), '1h');
|
||||
});
|
||||
|
||||
describe('with only from/to date range', () => {
|
||||
it('should always add datetime filtering to the querystring', () => {
|
||||
const querystring = `timespan=2017-08-22T06:00:00Z/2017-08-22T07:00:00Z`;
|
||||
expect(builder.generate()).toEqual(querystring);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with from/to date range and aggregation type', () => {
|
||||
beforeEach(() => {
|
||||
builder.setAggregation('avg');
|
||||
});
|
||||
|
||||
it('should add datetime filtering and aggregation to the querystring', () => {
|
||||
const querystring = `timespan=2017-08-22T06:00:00Z/2017-08-22T07:00:00Z&aggregation=avg`;
|
||||
expect(builder.generate()).toEqual(querystring);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with from/to date range and group by segment', () => {
|
||||
beforeEach(() => {
|
||||
builder.setGroupBy('client/city');
|
||||
});
|
||||
|
||||
it('should add datetime filtering and segment to the querystring', () => {
|
||||
const querystring = `timespan=2017-08-22T06:00:00Z/2017-08-22T07:00:00Z&segment=client/city`;
|
||||
expect(builder.generate()).toEqual(querystring);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with from/to date range and specific group by interval', () => {
|
||||
beforeEach(() => {
|
||||
builder.setInterval('specific', 1, 'hour');
|
||||
});
|
||||
|
||||
it('should add datetime filtering and interval to the querystring', () => {
|
||||
const querystring = `timespan=2017-08-22T06:00:00Z/2017-08-22T07:00:00Z&interval=PT1H`;
|
||||
expect(builder.generate()).toEqual(querystring);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with from/to date range and auto group by interval', () => {
|
||||
beforeEach(() => {
|
||||
builder.setInterval('auto', '', '');
|
||||
});
|
||||
|
||||
it('should add datetime filtering and interval to the querystring', () => {
|
||||
const querystring = `timespan=2017-08-22T06:00:00Z/2017-08-22T07:00:00Z&interval=PT1H`;
|
||||
expect(builder.generate()).toEqual(querystring);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with filter', () => {
|
||||
beforeEach(() => {
|
||||
builder.setFilter(`client/city eq 'Boydton'`);
|
||||
});
|
||||
|
||||
it('should add datetime filtering and interval to the querystring', () => {
|
||||
const querystring = `timespan=2017-08-22T06:00:00Z/2017-08-22T07:00:00Z&filter=client/city eq 'Boydton'`;
|
||||
expect(builder.generate()).toEqual(querystring);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,56 @@
|
||||
import TimeGrainConverter from '../time_grain_converter';
|
||||
|
||||
export default class AppInsightsQuerystringBuilder {
|
||||
aggregation = '';
|
||||
groupBy = '';
|
||||
timeGrainType = '';
|
||||
timeGrain = '';
|
||||
timeGrainUnit = '';
|
||||
filter = '';
|
||||
|
||||
constructor(private from, private to, public grafanaInterval) {}
|
||||
|
||||
setAggregation(aggregation) {
|
||||
this.aggregation = aggregation;
|
||||
}
|
||||
|
||||
setGroupBy(groupBy) {
|
||||
this.groupBy = groupBy;
|
||||
}
|
||||
|
||||
setInterval(timeGrainType, timeGrain, timeGrainUnit) {
|
||||
this.timeGrainType = timeGrainType;
|
||||
this.timeGrain = timeGrain;
|
||||
this.timeGrainUnit = timeGrainUnit;
|
||||
}
|
||||
|
||||
setFilter(filter: string) {
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
generate() {
|
||||
let querystring = `timespan=${this.from.utc().format()}/${this.to.utc().format()}`;
|
||||
|
||||
if (this.aggregation && this.aggregation.length > 0) {
|
||||
querystring += `&aggregation=${this.aggregation}`;
|
||||
}
|
||||
|
||||
if (this.groupBy && this.groupBy.length > 0) {
|
||||
querystring += `&segment=${this.groupBy}`;
|
||||
}
|
||||
|
||||
if (this.timeGrainType === 'specific' && this.timeGrain && this.timeGrainUnit) {
|
||||
querystring += `&interval=${TimeGrainConverter.createISO8601Duration(this.timeGrain, this.timeGrainUnit)}`;
|
||||
}
|
||||
|
||||
if (this.timeGrainType === 'auto') {
|
||||
querystring += `&interval=${TimeGrainConverter.createISO8601DurationFromInterval(this.grafanaInterval)}`;
|
||||
}
|
||||
|
||||
if (this.filter) {
|
||||
querystring += `&filter=${this.filter}`;
|
||||
}
|
||||
|
||||
return querystring;
|
||||
}
|
||||
}
|
@ -0,0 +1,212 @@
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
|
||||
export default class ResponseParser {
|
||||
constructor(private results) {}
|
||||
|
||||
parseQueryResult() {
|
||||
let data: any = [];
|
||||
let columns: any = [];
|
||||
for (let i = 0; i < this.results.length; i++) {
|
||||
if (this.results[i].query.raw) {
|
||||
const xaxis = this.results[i].query.xaxis;
|
||||
const yaxises = this.results[i].query.yaxis;
|
||||
const spliton = this.results[i].query.spliton;
|
||||
columns = this.results[i].result.data.Tables[0].Columns;
|
||||
const rows = this.results[i].result.data.Tables[0].Rows;
|
||||
data = _.concat(
|
||||
data,
|
||||
this.parseRawQueryResultRow(this.results[i].query, columns, rows, xaxis, yaxises, spliton)
|
||||
);
|
||||
} else {
|
||||
const value = this.results[i].result.data.value;
|
||||
const alias = this.results[i].query.alias;
|
||||
data = _.concat(data, this.parseQueryResultRow(this.results[i].query, value, alias));
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
parseRawQueryResultRow(query: any, columns, rows, xaxis: string, yaxises: string, spliton: string) {
|
||||
const data: any[] = [];
|
||||
const columnsForDropdown = _.map(columns, column => ({ text: column.ColumnName, value: column.ColumnName }));
|
||||
|
||||
const xaxisColumn = columns.findIndex(column => column.ColumnName === xaxis);
|
||||
const yaxisesSplit = yaxises.split(',');
|
||||
const yaxisColumns = {};
|
||||
_.forEach(yaxisesSplit, yaxis => {
|
||||
yaxisColumns[yaxis] = columns.findIndex(column => column.ColumnName === yaxis);
|
||||
});
|
||||
const splitonColumn = columns.findIndex(column => column.ColumnName === spliton);
|
||||
const convertTimestamp = xaxis === 'timestamp';
|
||||
|
||||
_.forEach(rows, row => {
|
||||
_.forEach(yaxisColumns, (yaxisColumn, yaxisName) => {
|
||||
const bucket =
|
||||
splitonColumn === -1
|
||||
? ResponseParser.findOrCreateBucket(data, yaxisName)
|
||||
: ResponseParser.findOrCreateBucket(data, row[splitonColumn]);
|
||||
const epoch = convertTimestamp ? ResponseParser.dateTimeToEpoch(row[xaxisColumn]) : row[xaxisColumn];
|
||||
bucket.datapoints.push([row[yaxisColumn], epoch]);
|
||||
bucket.refId = query.refId;
|
||||
bucket.query = query.query;
|
||||
bucket.columnsForDropdown = columnsForDropdown;
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
parseQueryResultRow(query: any, value, alias: string) {
|
||||
const data: any[] = [];
|
||||
|
||||
if (ResponseParser.isSingleValue(value)) {
|
||||
const metricName = ResponseParser.getMetricFieldKey(value);
|
||||
const aggField = ResponseParser.getKeyForAggregationField(value[metricName]);
|
||||
const epoch = ResponseParser.dateTimeToEpoch(value.end);
|
||||
data.push({
|
||||
target: metricName,
|
||||
datapoints: [[value[metricName][aggField], epoch]],
|
||||
refId: query.refId,
|
||||
query: query.query,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
const groupedBy = ResponseParser.hasSegmentsField(value.segments[0]);
|
||||
if (!groupedBy) {
|
||||
const metricName = ResponseParser.getMetricFieldKey(value.segments[0]);
|
||||
const dataTarget = ResponseParser.findOrCreateBucket(data, metricName);
|
||||
|
||||
for (let i = 0; i < value.segments.length; i++) {
|
||||
const epoch = ResponseParser.dateTimeToEpoch(value.segments[i].end);
|
||||
const aggField: string = ResponseParser.getKeyForAggregationField(value.segments[i][metricName]);
|
||||
|
||||
dataTarget.datapoints.push([value.segments[i][metricName][aggField], epoch]);
|
||||
}
|
||||
dataTarget.refId = query.refId;
|
||||
dataTarget.query = query.query;
|
||||
} else {
|
||||
for (let i = 0; i < value.segments.length; i++) {
|
||||
const epoch = ResponseParser.dateTimeToEpoch(value.segments[i].end);
|
||||
|
||||
for (let j = 0; j < value.segments[i].segments.length; j++) {
|
||||
const metricName = ResponseParser.getMetricFieldKey(value.segments[i].segments[j]);
|
||||
const aggField = ResponseParser.getKeyForAggregationField(value.segments[i].segments[j][metricName]);
|
||||
const target = this.getTargetName(value.segments[i].segments[j], alias);
|
||||
|
||||
const bucket = ResponseParser.findOrCreateBucket(data, target);
|
||||
bucket.datapoints.push([value.segments[i].segments[j][metricName][aggField], epoch]);
|
||||
bucket.refId = query.refId;
|
||||
bucket.query = query.query;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
getTargetName(segment, alias: string) {
|
||||
let metric = '';
|
||||
let segmentName = '';
|
||||
let segmentValue = '';
|
||||
for (const prop in segment) {
|
||||
if (_.isObject(segment[prop])) {
|
||||
metric = prop;
|
||||
} else {
|
||||
segmentName = prop;
|
||||
segmentValue = segment[prop];
|
||||
}
|
||||
}
|
||||
|
||||
if (alias) {
|
||||
const regex = /\{\{([\s\S]+?)\}\}/g;
|
||||
return alias.replace(regex, (match, g1, g2) => {
|
||||
const group = g1 || g2;
|
||||
|
||||
if (group === 'metric') {
|
||||
return metric;
|
||||
} else if (group === 'groupbyname') {
|
||||
return segmentName;
|
||||
} else if (group === 'groupbyvalue') {
|
||||
return segmentValue;
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
return metric + `{${segmentName}="${segmentValue}"}`;
|
||||
}
|
||||
|
||||
static isSingleValue(value) {
|
||||
return !ResponseParser.hasSegmentsField(value);
|
||||
}
|
||||
|
||||
static findOrCreateBucket(data, target) {
|
||||
let dataTarget = _.find(data, ['target', target]);
|
||||
if (!dataTarget) {
|
||||
dataTarget = { target: target, datapoints: [] };
|
||||
data.push(dataTarget);
|
||||
}
|
||||
|
||||
return dataTarget;
|
||||
}
|
||||
|
||||
static hasSegmentsField(obj) {
|
||||
const keys = _.keys(obj);
|
||||
return _.indexOf(keys, 'segments') > -1;
|
||||
}
|
||||
|
||||
static getMetricFieldKey(segment) {
|
||||
const keys = _.keys(segment);
|
||||
|
||||
return _.filter(_.without(keys, 'start', 'end'), key => {
|
||||
return _.isObject(segment[key]);
|
||||
})[0];
|
||||
}
|
||||
|
||||
static getKeyForAggregationField(dataObj): string {
|
||||
const keys = _.keys(dataObj);
|
||||
return _.intersection(keys, ['sum', 'avg', 'min', 'max', 'count', 'unique'])[0];
|
||||
}
|
||||
|
||||
static dateTimeToEpoch(dateTime) {
|
||||
return moment(dateTime).valueOf();
|
||||
}
|
||||
|
||||
static parseMetricNames(result) {
|
||||
const keys = _.keys(result.data.metrics);
|
||||
|
||||
return ResponseParser.toTextValueList(keys);
|
||||
}
|
||||
|
||||
parseMetadata(metricName: string) {
|
||||
const metric = this.results.data.metrics[metricName];
|
||||
|
||||
if (!metric) {
|
||||
throw Error('No data found for metric: ' + metricName);
|
||||
}
|
||||
|
||||
return {
|
||||
primaryAggType: metric.defaultAggregation,
|
||||
supportedAggTypes: metric.supportedAggregations,
|
||||
supportedGroupBy: metric.supportedGroupBy.all,
|
||||
};
|
||||
}
|
||||
|
||||
parseGroupBys() {
|
||||
return ResponseParser.toTextValueList(this.results.supportedGroupBy);
|
||||
}
|
||||
|
||||
static toTextValueList(values) {
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
list.push({
|
||||
text: values[i],
|
||||
value: values[i],
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
@ -0,0 +1,305 @@
|
||||
export default class FakeSchemaData {
|
||||
static getLogAnalyticsFakeSchema() {
|
||||
return {
|
||||
Tables: [
|
||||
{
|
||||
TableName: 'Table_0',
|
||||
Columns: [
|
||||
{
|
||||
ColumnName: 'TableName',
|
||||
DataType: 'String',
|
||||
},
|
||||
{
|
||||
ColumnName: 'ColumnName',
|
||||
DataType: 'String',
|
||||
},
|
||||
{
|
||||
ColumnName: 'ColumnType',
|
||||
DataType: 'String',
|
||||
},
|
||||
],
|
||||
Rows: [
|
||||
['AzureNetworkAnalytics_CL', 'SourceSystem', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'ManagementGroupName', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'TimeGenerated', 'System.DateTime'],
|
||||
['AzureNetworkAnalytics_CL', 'Computer', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'FASchemaVersion_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'FlowType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SrcIP_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'DestIP_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VMIP_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'L4Protocol_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'L7Protocol_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'FlowDirection_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'NSGList_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'NSGRules_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'HopNSGList_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'HopNSGRules_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Region1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Region2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'NIC_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'NIC1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'NIC2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VM_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VM1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VM2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Subnet_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'ConnectionName_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'S2SConnection_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'S2SConnectionType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Country_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'AzureRegion_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Subscription1_g', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Subscription2_g', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'FlowStartTime_t', 'System.DateTime'],
|
||||
['AzureNetworkAnalytics_CL', 'FlowEndTime_t', 'System.DateTime'],
|
||||
['AzureNetworkAnalytics_CL', 'DestPort_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'AllowedInFlows_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'DeniedInFlows_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'AllowedOutFlows_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'DeniedOutFlows_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'DeniedInFlowsAtHops_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'DeniedOutFlowsAtHops_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'FlowCount_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'NextHopIP_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'IsVirtualAppliance_b', 'System.Boolean'],
|
||||
['AzureNetworkAnalytics_CL', 'AddressPrefix_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'NextHopType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'RouteTable_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Subnet1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Subnet2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SubnetRegion1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SubnetRegion2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualAppliances_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'AllowForwardedTraffic_b', 'System.Boolean'],
|
||||
['AzureNetworkAnalytics_CL', 'AllowGatewayTransit_b', 'System.Boolean'],
|
||||
['AzureNetworkAnalytics_CL', 'AllowVirtualNetworkAccess_b', 'System.Boolean'],
|
||||
['AzureNetworkAnalytics_CL', 'UseRemoteGateways_b', 'System.Boolean'],
|
||||
['AzureNetworkAnalytics_CL', 'NSG_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'PrivateIPAddresses_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'PublicIPAddresses_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Subnetwork_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualMachine_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'MACAddress_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'AddressPrefixes_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'ConnectingVirtualNetwork_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'RemoteVirtualNetworkGateway_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'IsFlowEnabled_b', 'System.Boolean'],
|
||||
['AzureNetworkAnalytics_CL', 'GatewayType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SKU_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VIPAddress_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualSubnetwork_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'BGPEnabled_b', 'System.Boolean'],
|
||||
['AzureNetworkAnalytics_CL', 'ConnectionStatus_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'ConnectionType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'GatewayConnectionType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'LocalNetworkGateway_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualNetwork1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualNetwork2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualNetworkGateway1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualNetworkGateway2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualNetworkRegion1_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'VirtualNetworkRegion2_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'EgressBytesTransferred_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'IngressBytesTransferred_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'RoutingWeight_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'FrontendSubnet_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'LoadBalancerType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Access_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Description_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'DestinationAddressPrefix_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'DestinationPortRange_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Direction_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Protocol_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'RuleType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SourceAddressPrefix_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SourcePortRange_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Priority_d', 'System.Double'],
|
||||
['AzureNetworkAnalytics_CL', 'IPAddress', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SubnetPrefixes_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SchemaVersion_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Name_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Region_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'AppGatewayType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'BackendSubnets_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'FrontendIPs_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'GatewaySubnet_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'ComponentType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'DiscoveryRegion_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'ResourceType', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Status_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'SubType_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'TopologyVersion_s', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'Subscription_g', 'System.String'],
|
||||
['AzureNetworkAnalytics_CL', 'TimeProcessed_t', 'System.DateTime'],
|
||||
['AzureNetworkAnalytics_CL', 'Type', 'System.String'],
|
||||
],
|
||||
KqlPrimaryTimestampColumnName: 'TimeGenerated',
|
||||
},
|
||||
{
|
||||
TableName: 'Table_1',
|
||||
Columns: [
|
||||
{
|
||||
ColumnName: 'TableType',
|
||||
DataType: 'String',
|
||||
},
|
||||
{
|
||||
ColumnName: 'TableName',
|
||||
DataType: 'String',
|
||||
},
|
||||
{
|
||||
ColumnName: 'PrimaryTimestampColumnName',
|
||||
DataType: 'String',
|
||||
},
|
||||
{
|
||||
ColumnName: 'Solutions',
|
||||
DataType: 'String',
|
||||
},
|
||||
],
|
||||
Rows: [['oms', 'AzureNetworkAnalytics_CL', 'TimeGenerated', 'LogManagement']],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static getlogAnalyticsFakeMetadata() {
|
||||
return {
|
||||
tables: [
|
||||
{
|
||||
id: 't/Alert',
|
||||
name: 'Alert',
|
||||
timespanColumn: 'TimeGenerated',
|
||||
columns: [
|
||||
{ name: 'TimeGenerated', type: 'datetime' },
|
||||
{ name: 'AlertSeverity', type: 'string' },
|
||||
{ name: 'SourceDisplayName', type: 'string' },
|
||||
{ name: 'AlertName', type: 'string' },
|
||||
{ name: 'AlertDescription', type: 'string' },
|
||||
{ name: 'SourceSystem', type: 'string' },
|
||||
{ name: 'QueryExecutionStartTime', type: 'datetime' },
|
||||
{ name: 'QueryExecutionEndTime', type: 'datetime' },
|
||||
{ name: 'Query', type: 'string' },
|
||||
{ name: 'RemediationJobId', type: 'string' },
|
||||
{ name: 'RemediationRunbookName', type: 'string' },
|
||||
{ name: 'AlertRuleId', type: 'string' },
|
||||
{ name: 'AlertRuleInstanceId', type: 'string' },
|
||||
{ name: 'ThresholdOperator', type: 'string' },
|
||||
{ name: 'ThresholdValue', type: 'int' },
|
||||
{ name: 'LinkToSearchResults', type: 'string' },
|
||||
{ name: 'ServiceDeskConnectionName', type: 'string' },
|
||||
{ name: 'ServiceDeskId', type: 'string' },
|
||||
{ name: 'ServiceDeskWorkItemLink', type: 'string' },
|
||||
{ name: 'ServiceDeskWorkItemType', type: 'string' },
|
||||
{ name: 'ResourceId', type: 'string' },
|
||||
{ name: 'ResourceType', type: 'string' },
|
||||
{ name: 'ResourceValue', type: 'string' },
|
||||
{ name: 'RootObjectName', type: 'string' },
|
||||
{ name: 'ObjectDisplayName', type: 'string' },
|
||||
{ name: 'Computer', type: 'string' },
|
||||
{ name: 'AlertPriority', type: 'string' },
|
||||
{ name: 'SourceFullName', type: 'string' },
|
||||
{ name: 'AlertId', type: 'string' },
|
||||
{ name: 'RepeatCount', type: 'int' },
|
||||
{ name: 'AlertState', type: 'string' },
|
||||
{ name: 'ResolvedBy', type: 'string' },
|
||||
{ name: 'LastModifiedBy', type: 'string' },
|
||||
{ name: 'TimeRaised', type: 'datetime' },
|
||||
{ name: 'TimeResolved', type: 'datetime' },
|
||||
{ name: 'TimeLastModified', type: 'datetime' },
|
||||
{ name: 'AlertContext', type: 'string' },
|
||||
{ name: 'TicketId', type: 'string' },
|
||||
{ name: 'Custom1', type: 'string' },
|
||||
{ name: 'Custom2', type: 'string' },
|
||||
{ name: 'Custom3', type: 'string' },
|
||||
{ name: 'Custom4', type: 'string' },
|
||||
{ name: 'Custom5', type: 'string' },
|
||||
{ name: 'Custom6', type: 'string' },
|
||||
{ name: 'Custom7', type: 'string' },
|
||||
{ name: 'Custom8', type: 'string' },
|
||||
{ name: 'Custom9', type: 'string' },
|
||||
{ name: 'Custom10', type: 'string' },
|
||||
{ name: 'ManagementGroupName', type: 'string' },
|
||||
{ name: 'PriorityNumber', type: 'int' },
|
||||
{ name: 'HostName', type: 'string' },
|
||||
{ name: 'StateType', type: 'string' },
|
||||
{ name: 'AlertTypeDescription', type: 'string' },
|
||||
{ name: 'AlertTypeNumber', type: 'int' },
|
||||
{ name: 'AlertError', type: 'string' },
|
||||
{ name: 'StatusDescription', type: 'string' },
|
||||
{ name: 'AlertStatus', type: 'int' },
|
||||
{ name: 'TriggerId', type: 'string' },
|
||||
{ name: 'Url', type: 'string' },
|
||||
{ name: 'ValueDescription', type: 'string' },
|
||||
{ name: 'AlertValue', type: 'int' },
|
||||
{ name: 'Comments', type: 'string' },
|
||||
{ name: 'TemplateId', type: 'string' },
|
||||
{ name: 'FlagsDescription', type: 'string' },
|
||||
{ name: 'Flags', type: 'int' },
|
||||
{ name: 'ValueFlagsDescription', type: 'string' },
|
||||
{ name: 'ValueFlags', type: 'int' },
|
||||
{ name: 'Expression', type: 'string' },
|
||||
{ name: 'Type', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't/AzureActivity',
|
||||
name: 'AzureActivity',
|
||||
timespanColumn: 'TimeGenerated',
|
||||
columns: [
|
||||
{ name: 'OperationName', type: 'string' },
|
||||
{ name: 'Level', type: 'string' },
|
||||
{ name: 'ActivityStatus', type: 'string' },
|
||||
{ name: 'ActivitySubstatus', type: 'string' },
|
||||
{ name: 'ResourceGroup', type: 'string' },
|
||||
{ name: 'SubscriptionId', type: 'string' },
|
||||
{ name: 'CorrelationId', type: 'string' },
|
||||
{ name: 'Caller', type: 'string' },
|
||||
{ name: 'CallerIpAddress', type: 'string' },
|
||||
{ name: 'Category', type: 'string' },
|
||||
{ name: 'HTTPRequest', type: 'string' },
|
||||
{ name: 'Properties', type: 'string' },
|
||||
{ name: 'EventSubmissionTimestamp', type: 'datetime' },
|
||||
{ name: 'Authorization', type: 'string' },
|
||||
{ name: 'ResourceId', type: 'string' },
|
||||
{ name: 'OperationId', type: 'string' },
|
||||
{ name: 'ResourceProvider', type: 'string' },
|
||||
{ name: 'Resource', type: 'string' },
|
||||
{ name: 'TimeGenerated', type: 'datetime' },
|
||||
{ name: 'SourceSystem', type: 'string' },
|
||||
{ name: 'Type', type: 'string' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tableGroups: [
|
||||
{
|
||||
id: 'oms/LogManagement',
|
||||
name: 'LogManagement',
|
||||
source: 'oms',
|
||||
tables: ['t/Alert', 't/AzureActivity'],
|
||||
},
|
||||
],
|
||||
functions: [
|
||||
{
|
||||
id: 'f/Func1',
|
||||
name: 'Func1',
|
||||
displayName: 'Func1',
|
||||
body: 'AzureActivity\n| where ActivityStatus == "" \n',
|
||||
category: 'test',
|
||||
},
|
||||
],
|
||||
applications: [],
|
||||
workspaces: [
|
||||
{
|
||||
id: 'a2c1b44e-3e57-4410-b027-999999999999',
|
||||
name: 'danieltest',
|
||||
resourceId:
|
||||
'/subscriptions/44693801-6ee6-49de-9b2d-999999999999/resourceGroups/danieltest/providers/' +
|
||||
'microsoft.operationalinsights/workspaces/danieltest',
|
||||
tables: [],
|
||||
tableGroups: ['oms/LogManagement'],
|
||||
functions: ['f/Func1'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,401 @@
|
||||
import AzureMonitorDatasource from '../datasource';
|
||||
import FakeSchemaData from './__mocks__/schema';
|
||||
import Q from 'q';
|
||||
import moment from 'moment';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
describe('AzureLogAnalyticsDatasource', () => {
|
||||
const ctx: any = {
|
||||
backendSrv: {},
|
||||
templateSrv: new TemplateSrv(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.$q = Q;
|
||||
ctx.instanceSettings = {
|
||||
jsonData: { logAnalyticsSubscriptionId: 'xxx' },
|
||||
url: 'http://azureloganalyticsapi',
|
||||
};
|
||||
|
||||
ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.templateSrv, ctx.$q);
|
||||
});
|
||||
|
||||
describe('When the config option "Same as Azure Monitor" has been chosen', () => {
|
||||
const tableResponseWithOneColumn = {
|
||||
tables: [
|
||||
{
|
||||
name: 'PrimaryResult',
|
||||
columns: [
|
||||
{
|
||||
name: 'Category',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
rows: [['Administrative'], ['Policy']],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const workspaceResponse = {
|
||||
value: [
|
||||
{
|
||||
name: 'aworkspace',
|
||||
properties: {
|
||||
source: 'Azure',
|
||||
customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let workspacesUrl;
|
||||
let azureLogAnalyticsUrl;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx.instanceSettings.jsonData.subscriptionId = 'xxx';
|
||||
ctx.instanceSettings.jsonData.tenantId = 'xxx';
|
||||
ctx.instanceSettings.jsonData.clientId = 'xxx';
|
||||
ctx.instanceSettings.jsonData.azureLogAnalyticsSameAs = true;
|
||||
ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.templateSrv, ctx.$q);
|
||||
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) {
|
||||
workspacesUrl = options.url;
|
||||
return ctx.$q.when({ data: workspaceResponse, status: 200 });
|
||||
} else {
|
||||
azureLogAnalyticsUrl = options.url;
|
||||
return ctx.$q.when({ data: tableResponseWithOneColumn, status: 200 });
|
||||
}
|
||||
};
|
||||
|
||||
await ctx.ds.metricFindQuery('workspace("aworkspace").AzureActivity | distinct Category');
|
||||
});
|
||||
|
||||
it('should use the sameasloganalyticsazure plugin route', () => {
|
||||
expect(workspacesUrl).toContain('azuremonitor');
|
||||
expect(azureLogAnalyticsUrl).toContain('sameasloganalyticsazure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing testDatasource', () => {
|
||||
describe('and an error is returned', () => {
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
code: 'InvalidApiVersionParameter',
|
||||
message: `An error message.`,
|
||||
},
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.instanceSettings.jsonData.logAnalyticsSubscriptionId = 'xxx';
|
||||
ctx.instanceSettings.jsonData.logAnalyticsTenantId = 'xxx';
|
||||
ctx.instanceSettings.jsonData.logAnalyticsClientId = 'xxx';
|
||||
ctx.backendSrv.datasourceRequest = () => {
|
||||
return ctx.$q.reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return error status and a detailed error message', () => {
|
||||
return ctx.ds.testDatasource().then(results => {
|
||||
expect(results.status).toEqual('error');
|
||||
expect(results.message).toEqual(
|
||||
'1. Azure Log Analytics: Bad Request: InvalidApiVersionParameter. An error message. '
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing query', () => {
|
||||
const options = {
|
||||
range: {
|
||||
from: moment.utc('2017-08-22T20:00:00Z'),
|
||||
to: moment.utc('2017-08-22T23:59:00Z'),
|
||||
},
|
||||
rangeRaw: {
|
||||
from: 'now-4h',
|
||||
to: 'now',
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
apiVersion: '2016-09-01',
|
||||
refId: 'A',
|
||||
queryType: 'Azure Log Analytics',
|
||||
azureLogAnalytics: {
|
||||
resultFormat: 'time_series',
|
||||
query:
|
||||
'AzureActivity | where TimeGenerated > ago(2h) ' +
|
||||
'| summarize count() by Category, bin(TimeGenerated, 5min) ' +
|
||||
'| project TimeGenerated, Category, count_ | order by TimeGenerated asc',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = {
|
||||
tables: [
|
||||
{
|
||||
name: 'PrimaryResult',
|
||||
columns: [
|
||||
{
|
||||
name: 'TimeGenerated',
|
||||
type: 'datetime',
|
||||
},
|
||||
{
|
||||
name: 'Category',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'count_',
|
||||
type: 'long',
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
['2018-06-02T20:20:00Z', 'Administrative', 2],
|
||||
['2018-06-02T20:25:00Z', 'Administrative', 22],
|
||||
['2018-06-02T20:30:00Z', 'Policy', 20],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('in time series format', () => {
|
||||
describe('and the data is valid (has time, metric and value columns)', () => {
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('query=AzureActivity');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(2);
|
||||
expect(results.data[0].datapoints.length).toBe(2);
|
||||
expect(results.data[0].target).toEqual('Administrative');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1527970800000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(2);
|
||||
expect(results.data[0].datapoints[1][1]).toEqual(1527971100000);
|
||||
expect(results.data[0].datapoints[1][0]).toEqual(22);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and the data has no time column)', () => {
|
||||
beforeEach(() => {
|
||||
const invalidResponse = {
|
||||
tables: [
|
||||
{
|
||||
name: 'PrimaryResult',
|
||||
columns: [
|
||||
{
|
||||
name: 'Category',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'count_',
|
||||
type: 'long',
|
||||
},
|
||||
],
|
||||
rows: [['Administrative', 2]],
|
||||
},
|
||||
],
|
||||
};
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('query=AzureActivity');
|
||||
return ctx.$q.when({ data: invalidResponse, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should throw an exception', () => {
|
||||
ctx.ds.query(options).catch(err => {
|
||||
expect(err.message).toContain('The Time Series format requires a time column.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('in tableformat', () => {
|
||||
beforeEach(() => {
|
||||
options.targets[0].azureLogAnalytics.resultFormat = 'table';
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('query=AzureActivity');
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of columns and rows', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data[0].type).toBe('table');
|
||||
expect(results.data[0].columns.length).toBe(3);
|
||||
expect(results.data[0].rows.length).toBe(3);
|
||||
expect(results.data[0].columns[0].text).toBe('TimeGenerated');
|
||||
expect(results.data[0].columns[0].type).toBe('datetime');
|
||||
expect(results.data[0].columns[1].text).toBe('Category');
|
||||
expect(results.data[0].columns[1].type).toBe('string');
|
||||
expect(results.data[0].columns[2].text).toBe('count_');
|
||||
expect(results.data[0].columns[2].type).toBe('long');
|
||||
expect(results.data[0].rows[0][0]).toEqual('2018-06-02T20:20:00Z');
|
||||
expect(results.data[0].rows[0][1]).toEqual('Administrative');
|
||||
expect(results.data[0].rows[0][2]).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing getSchema', () => {
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain('metadata');
|
||||
return ctx.$q.when({ data: FakeSchemaData.getlogAnalyticsFakeMetadata(), status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a schema with a table and rows', () => {
|
||||
return ctx.ds.azureLogAnalyticsDatasource.getSchema('myWorkspace').then(result => {
|
||||
expect(Object.keys(result.Databases.Default.Tables).length).toBe(2);
|
||||
expect(result.Databases.Default.Tables.Alert.Name).toBe('Alert');
|
||||
expect(result.Databases.Default.Tables.AzureActivity.Name).toBe('AzureActivity');
|
||||
expect(result.Databases.Default.Tables.Alert.OrderedColumns.length).toBe(69);
|
||||
expect(result.Databases.Default.Tables.AzureActivity.OrderedColumns.length).toBe(21);
|
||||
expect(result.Databases.Default.Tables.Alert.OrderedColumns[0].Name).toBe('TimeGenerated');
|
||||
expect(result.Databases.Default.Tables.Alert.OrderedColumns[0].Type).toBe('datetime');
|
||||
|
||||
expect(Object.keys(result.Databases.Default.Functions).length).toBe(1);
|
||||
expect(result.Databases.Default.Functions.Func1.Name).toBe('Func1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery', () => {
|
||||
const tableResponseWithOneColumn = {
|
||||
tables: [
|
||||
{
|
||||
name: 'PrimaryResult',
|
||||
columns: [
|
||||
{
|
||||
name: 'Category',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
rows: [['Administrative'], ['Policy']],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const workspaceResponse = {
|
||||
value: [
|
||||
{
|
||||
name: 'aworkspace',
|
||||
properties: {
|
||||
source: 'Azure',
|
||||
customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let queryResults;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) {
|
||||
return ctx.$q.when({ data: workspaceResponse, status: 200 });
|
||||
} else {
|
||||
return ctx.$q.when({ data: tableResponseWithOneColumn, status: 200 });
|
||||
}
|
||||
};
|
||||
|
||||
queryResults = await ctx.ds.metricFindQuery('workspace("aworkspace").AzureActivity | distinct Category');
|
||||
});
|
||||
|
||||
it('should return a list of categories in the correct format', () => {
|
||||
expect(queryResults.length).toBe(2);
|
||||
expect(queryResults[0].text).toBe('Administrative');
|
||||
expect(queryResults[0].value).toBe('Administrative');
|
||||
expect(queryResults[1].text).toBe('Policy');
|
||||
expect(queryResults[1].value).toBe('Policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing annotationQuery', () => {
|
||||
const tableResponse = {
|
||||
tables: [
|
||||
{
|
||||
name: 'PrimaryResult',
|
||||
columns: [
|
||||
{
|
||||
name: 'TimeGenerated',
|
||||
type: 'datetime',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'Tags',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
rows: [['2018-06-02T20:20:00Z', 'Computer1', 'tag1,tag2'], ['2018-06-02T20:28:00Z', 'Computer2', 'tag2']],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const workspaceResponse = {
|
||||
value: [
|
||||
{
|
||||
name: 'aworkspace',
|
||||
properties: {
|
||||
source: 'Azure',
|
||||
customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let annotationResults;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) {
|
||||
return ctx.$q.when({ data: workspaceResponse, status: 200 });
|
||||
} else {
|
||||
return ctx.$q.when({ data: tableResponse, status: 200 });
|
||||
}
|
||||
};
|
||||
|
||||
annotationResults = await ctx.ds.annotationQuery({
|
||||
annotation: {
|
||||
rawQuery: 'Heartbeat | where $__timeFilter()| project TimeGenerated, Text=Computer, tags="test"',
|
||||
workspace: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67',
|
||||
},
|
||||
range: {
|
||||
from: moment.utc('2017-08-22T20:00:00Z'),
|
||||
to: moment.utc('2017-08-22T23:59:00Z'),
|
||||
},
|
||||
rangeRaw: {
|
||||
from: 'now-4h',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a list of categories in the correct format', () => {
|
||||
expect(annotationResults.length).toBe(2);
|
||||
|
||||
expect(annotationResults[0].time).toBe(1527970800000);
|
||||
expect(annotationResults[0].text).toBe('Computer1');
|
||||
expect(annotationResults[0].tags[0]).toBe('tag1');
|
||||
expect(annotationResults[0].tags[1]).toBe('tag2');
|
||||
|
||||
expect(annotationResults[1].time).toBe(1527971280000);
|
||||
expect(annotationResults[1].text).toBe('Computer2');
|
||||
expect(annotationResults[1].tags[0]).toBe('tag2');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,321 @@
|
||||
import _ from 'lodash';
|
||||
import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder';
|
||||
import ResponseParser from './response_parser';
|
||||
|
||||
export default class AzureLogAnalyticsDatasource {
|
||||
id: number;
|
||||
url: string;
|
||||
baseUrl: string;
|
||||
applicationId: string;
|
||||
azureMonitorUrl: string;
|
||||
defaultOrFirstWorkspace: string;
|
||||
subscriptionId: string;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private instanceSettings, private backendSrv, private templateSrv, private $q) {
|
||||
this.id = instanceSettings.id;
|
||||
this.baseUrl = this.instanceSettings.jsonData.azureLogAnalyticsSameAs
|
||||
? '/sameasloganalyticsazure'
|
||||
: `/loganalyticsazure`;
|
||||
this.url = instanceSettings.url;
|
||||
this.defaultOrFirstWorkspace = this.instanceSettings.jsonData.logAnalyticsDefaultWorkspace;
|
||||
|
||||
this.setWorkspaceUrl();
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return (
|
||||
(!!this.instanceSettings.jsonData.logAnalyticsSubscriptionId &&
|
||||
this.instanceSettings.jsonData.logAnalyticsSubscriptionId.length > 0) ||
|
||||
!!this.instanceSettings.jsonData.azureLogAnalyticsSameAs
|
||||
);
|
||||
}
|
||||
|
||||
setWorkspaceUrl() {
|
||||
if (!!this.instanceSettings.jsonData.subscriptionId || !!this.instanceSettings.jsonData.azureLogAnalyticsSameAs) {
|
||||
this.subscriptionId = this.instanceSettings.jsonData.subscriptionId;
|
||||
const azureCloud = this.instanceSettings.jsonData.cloudName || 'azuremonitor';
|
||||
this.azureMonitorUrl = `/${azureCloud}/subscriptions/${this.subscriptionId}`;
|
||||
} else {
|
||||
this.subscriptionId = this.instanceSettings.jsonData.logAnalyticsSubscriptionId;
|
||||
this.azureMonitorUrl = `/workspacesloganalytics/subscriptions/${this.subscriptionId}`;
|
||||
}
|
||||
}
|
||||
|
||||
getWorkspaces() {
|
||||
const workspaceListUrl =
|
||||
this.azureMonitorUrl + '/providers/Microsoft.OperationalInsights/workspaces?api-version=2017-04-26-preview';
|
||||
return this.doRequest(workspaceListUrl).then(response => {
|
||||
return (
|
||||
_.map(response.data.value, val => {
|
||||
return { text: val.name, value: val.properties.customerId };
|
||||
}) || []
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getSchema(workspace) {
|
||||
if (!workspace) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const url = `${this.baseUrl}/${workspace}/metadata`;
|
||||
|
||||
return this.doRequest(url).then(response => {
|
||||
return new ResponseParser(response.data).parseSchemaResult();
|
||||
});
|
||||
}
|
||||
|
||||
query(options) {
|
||||
const queries = _.filter(options.targets, item => {
|
||||
return item.hide !== true;
|
||||
}).map(target => {
|
||||
const item = target.azureLogAnalytics;
|
||||
|
||||
const querystringBuilder = new LogAnalyticsQuerystringBuilder(
|
||||
this.templateSrv.replace(item.query, options.scopedVars, this.interpolateVariable),
|
||||
options,
|
||||
'TimeGenerated'
|
||||
);
|
||||
const generated = querystringBuilder.generate();
|
||||
|
||||
const url = `${this.baseUrl}/${item.workspace}/query?${generated.uriString}`;
|
||||
|
||||
return {
|
||||
refId: target.refId,
|
||||
intervalMs: options.intervalMs,
|
||||
maxDataPoints: options.maxDataPoints,
|
||||
datasourceId: this.id,
|
||||
url: url,
|
||||
query: generated.rawQuery,
|
||||
format: options.format,
|
||||
resultFormat: item.resultFormat,
|
||||
};
|
||||
});
|
||||
|
||||
if (!queries || queries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = this.doQueries(queries);
|
||||
|
||||
return this.$q.all(promises).then(results => {
|
||||
return new ResponseParser(results).parseQueryResult();
|
||||
});
|
||||
}
|
||||
|
||||
metricFindQuery(query: string) {
|
||||
return this.getDefaultOrFirstWorkspace().then(workspace => {
|
||||
const queries: any[] = this.buildQuery(query, null, workspace);
|
||||
|
||||
const promises = this.doQueries(queries);
|
||||
|
||||
return this.$q
|
||||
.all(promises)
|
||||
.then(results => {
|
||||
return new ResponseParser(results).parseToVariables();
|
||||
})
|
||||
.catch(err => {
|
||||
if (
|
||||
err.error &&
|
||||
err.error.data &&
|
||||
err.error.data.error &&
|
||||
err.error.data.error.innererror &&
|
||||
err.error.data.error.innererror.innererror
|
||||
) {
|
||||
throw { message: err.error.data.error.innererror.innererror.message };
|
||||
} else if (err.error && err.error.data && err.error.data.error) {
|
||||
throw { message: err.error.data.error.message };
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private buildQuery(query: string, options: any, workspace: any) {
|
||||
const querystringBuilder = new LogAnalyticsQuerystringBuilder(
|
||||
this.templateSrv.replace(query, {}, this.interpolateVariable),
|
||||
options,
|
||||
'TimeGenerated'
|
||||
);
|
||||
const querystring = querystringBuilder.generate().uriString;
|
||||
const url = `${this.baseUrl}/${workspace}/query?${querystring}`;
|
||||
const queries: any[] = [];
|
||||
queries.push({
|
||||
datasourceId: this.id,
|
||||
url: url,
|
||||
resultFormat: 'table',
|
||||
});
|
||||
return queries;
|
||||
}
|
||||
|
||||
interpolateVariable(value, variable) {
|
||||
if (typeof value === 'string') {
|
||||
if (variable.multi || variable.includeAll) {
|
||||
return "'" + value + "'";
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const quotedValues = _.map(value, val => {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "'" + val + "'";
|
||||
});
|
||||
return quotedValues.join(',');
|
||||
}
|
||||
|
||||
getDefaultOrFirstWorkspace() {
|
||||
if (this.defaultOrFirstWorkspace) {
|
||||
return Promise.resolve(this.defaultOrFirstWorkspace);
|
||||
}
|
||||
|
||||
return this.getWorkspaces().then(workspaces => {
|
||||
this.defaultOrFirstWorkspace = workspaces[0].value;
|
||||
return this.defaultOrFirstWorkspace;
|
||||
});
|
||||
}
|
||||
|
||||
annotationQuery(options) {
|
||||
if (!options.annotation.rawQuery) {
|
||||
return this.$q.reject({
|
||||
message: 'Query missing in annotation definition',
|
||||
});
|
||||
}
|
||||
|
||||
const queries: any[] = this.buildQuery(options.annotation.rawQuery, options, options.annotation.workspace);
|
||||
|
||||
const promises = this.doQueries(queries);
|
||||
|
||||
return this.$q.all(promises).then(results => {
|
||||
const annotations = new ResponseParser(results).transformToAnnotations(options);
|
||||
return annotations;
|
||||
});
|
||||
}
|
||||
|
||||
doQueries(queries) {
|
||||
return _.map(queries, query => {
|
||||
return this.doRequest(query.url)
|
||||
.then(result => {
|
||||
return {
|
||||
result: result,
|
||||
query: query,
|
||||
};
|
||||
})
|
||||
.catch(err => {
|
||||
throw {
|
||||
error: err,
|
||||
query: query,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
doRequest(url, maxRetries = 1) {
|
||||
return this.backendSrv
|
||||
.datasourceRequest({
|
||||
url: this.url + url,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch(error => {
|
||||
if (maxRetries > 0) {
|
||||
return this.doRequest(url, maxRetries - 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
testDatasource() {
|
||||
const validationError = this.isValidConfig();
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
return this.getDefaultOrFirstWorkspace()
|
||||
.then(ws => {
|
||||
const url = `${this.baseUrl}/${ws}/metadata`;
|
||||
|
||||
return this.doRequest(url);
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Successfully queried the Azure Log Analytics service.',
|
||||
title: 'Success',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Returned http status code ' + response.status,
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
let message = 'Azure Log Analytics: ';
|
||||
if (error.config && error.config.url && error.config.url.indexOf('workspacesloganalytics') > -1) {
|
||||
message = 'Azure Log Analytics requires access to Azure Monitor but had the following error: ';
|
||||
}
|
||||
|
||||
message = this.getErrorMessage(message, error);
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getErrorMessage(message: string, error: any) {
|
||||
message += error.statusText ? error.statusText + ': ' : '';
|
||||
if (error.data && error.data.error && error.data.error.code) {
|
||||
message += error.data.error.code + '. ' + error.data.error.message;
|
||||
} else if (error.data && error.data.error) {
|
||||
message += error.data.error;
|
||||
} else if (error.data) {
|
||||
message += error.data;
|
||||
} else {
|
||||
message += 'Cannot connect to Azure Log Analytics REST API.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
isValidConfig() {
|
||||
if (this.instanceSettings.jsonData.azureLogAnalyticsSameAs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsSubscriptionId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'The Subscription Id field is required.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsTenantId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'The Tenant Id field is required.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsClientId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'The Client Id field is required.',
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
isValidConfigField(field: string) {
|
||||
return field && field.length > 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,269 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
export interface DataTarget {
|
||||
target: string;
|
||||
datapoints: any[];
|
||||
refId: string;
|
||||
query: any;
|
||||
}
|
||||
export interface TableResult {
|
||||
columns: TableColumn[];
|
||||
rows: any[];
|
||||
type: string;
|
||||
refId: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface TableColumn {
|
||||
text: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface KustoSchema {
|
||||
Databases: { [key: string]: KustoDatabase };
|
||||
Plugins: any[];
|
||||
}
|
||||
export interface KustoDatabase {
|
||||
Name: string;
|
||||
Tables: { [key: string]: KustoTable };
|
||||
Functions: { [key: string]: KustoFunction };
|
||||
}
|
||||
|
||||
export interface KustoTable {
|
||||
Name: string;
|
||||
OrderedColumns: KustoColumn[];
|
||||
}
|
||||
|
||||
export interface KustoColumn {
|
||||
Name: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
export interface KustoFunction {
|
||||
Name: string;
|
||||
DocString: string;
|
||||
Body: string;
|
||||
Folder: string;
|
||||
FunctionKind: string;
|
||||
InputParameters: any[];
|
||||
OutputColumns: any[];
|
||||
}
|
||||
|
||||
export interface Variable {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AnnotationItem {
|
||||
annotation: any;
|
||||
time: number;
|
||||
text: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default class ResponseParser {
|
||||
columns: string[];
|
||||
constructor(private results) {}
|
||||
|
||||
parseQueryResult(): any {
|
||||
let data: any[] = [];
|
||||
let columns: any[] = [];
|
||||
for (let i = 0; i < this.results.length; i++) {
|
||||
if (this.results[i].result.data.tables.length === 0) {
|
||||
continue;
|
||||
}
|
||||
columns = this.results[i].result.data.tables[0].columns;
|
||||
const rows = this.results[i].result.data.tables[0].rows;
|
||||
|
||||
if (this.results[i].query.resultFormat === 'time_series') {
|
||||
data = _.concat(data, this.parseTimeSeriesResult(this.results[i].query, columns, rows));
|
||||
} else {
|
||||
data = _.concat(data, this.parseTableResult(this.results[i].query, columns, rows));
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
parseTimeSeriesResult(query, columns, rows): DataTarget[] {
|
||||
const data: DataTarget[] = [];
|
||||
let timeIndex = -1;
|
||||
let metricIndex = -1;
|
||||
let valueIndex = -1;
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
if (timeIndex === -1 && columns[i].type === 'datetime') {
|
||||
timeIndex = i;
|
||||
}
|
||||
|
||||
if (metricIndex === -1 && columns[i].type === 'string') {
|
||||
metricIndex = i;
|
||||
}
|
||||
|
||||
if (valueIndex === -1 && ['int', 'long', 'real', 'double'].indexOf(columns[i].type) > -1) {
|
||||
valueIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (timeIndex === -1) {
|
||||
throw new Error('No datetime column found in the result. The Time Series format requires a time column.');
|
||||
}
|
||||
|
||||
_.forEach(rows, row => {
|
||||
const epoch = ResponseParser.dateTimeToEpoch(row[timeIndex]);
|
||||
const metricName = metricIndex > -1 ? row[metricIndex] : columns[valueIndex].name;
|
||||
const bucket = ResponseParser.findOrCreateBucket(data, metricName);
|
||||
bucket.datapoints.push([row[valueIndex], epoch]);
|
||||
bucket.refId = query.refId;
|
||||
bucket.query = query.query;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
parseTableResult(query, columns, rows): TableResult {
|
||||
const tableResult: TableResult = {
|
||||
type: 'table',
|
||||
columns: _.map(columns, col => {
|
||||
return { text: col.name, type: col.type };
|
||||
}),
|
||||
rows: rows,
|
||||
refId: query.refId,
|
||||
query: query.query,
|
||||
};
|
||||
|
||||
return tableResult;
|
||||
}
|
||||
|
||||
parseToVariables(): Variable[] {
|
||||
const queryResult = this.parseQueryResult();
|
||||
|
||||
const variables: Variable[] = [];
|
||||
_.forEach(queryResult, result => {
|
||||
_.forEach(_.flattenDeep(result.rows), row => {
|
||||
variables.push({
|
||||
text: row,
|
||||
value: row,
|
||||
} as Variable);
|
||||
});
|
||||
});
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
transformToAnnotations(options: any) {
|
||||
const queryResult = this.parseQueryResult();
|
||||
|
||||
const list: AnnotationItem[] = [];
|
||||
|
||||
_.forEach(queryResult, result => {
|
||||
let timeIndex = -1;
|
||||
let textIndex = -1;
|
||||
let tagsIndex = -1;
|
||||
|
||||
for (let i = 0; i < result.columns.length; i++) {
|
||||
if (timeIndex === -1 && result.columns[i].type === 'datetime') {
|
||||
timeIndex = i;
|
||||
}
|
||||
|
||||
if (textIndex === -1 && result.columns[i].text.toLowerCase() === 'text') {
|
||||
textIndex = i;
|
||||
}
|
||||
|
||||
if (tagsIndex === -1 && result.columns[i].text.toLowerCase() === 'tags') {
|
||||
tagsIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
_.forEach(result.rows, row => {
|
||||
list.push({
|
||||
annotation: options.annotation,
|
||||
time: Math.floor(ResponseParser.dateTimeToEpoch(row[timeIndex])),
|
||||
text: row[textIndex] ? row[textIndex].toString() : '',
|
||||
tags: row[tagsIndex] ? row[tagsIndex].trim().split(/\s*,\s*/) : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
parseSchemaResult(): KustoSchema {
|
||||
return {
|
||||
Plugins: [
|
||||
{
|
||||
Name: 'pivot',
|
||||
},
|
||||
],
|
||||
Databases: this.createSchemaDatabaseWithTables(),
|
||||
};
|
||||
}
|
||||
|
||||
createSchemaDatabaseWithTables(): { [key: string]: KustoDatabase } {
|
||||
const databases = {
|
||||
Default: {
|
||||
Name: 'Default',
|
||||
Tables: this.createSchemaTables(),
|
||||
Functions: this.createSchemaFunctions(),
|
||||
},
|
||||
};
|
||||
|
||||
return databases;
|
||||
}
|
||||
|
||||
createSchemaTables(): { [key: string]: KustoTable } {
|
||||
const tables: { [key: string]: KustoTable } = {};
|
||||
|
||||
for (const table of this.results.tables) {
|
||||
tables[table.name] = {
|
||||
Name: table.name,
|
||||
OrderedColumns: [],
|
||||
};
|
||||
for (const col of table.columns) {
|
||||
tables[table.name].OrderedColumns.push(this.convertToKustoColumn(col));
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
convertToKustoColumn(col: any): KustoColumn {
|
||||
return {
|
||||
Name: col.name,
|
||||
Type: col.type,
|
||||
};
|
||||
}
|
||||
|
||||
createSchemaFunctions(): { [key: string]: KustoFunction } {
|
||||
const functions: { [key: string]: KustoFunction } = {};
|
||||
|
||||
for (const func of this.results.functions) {
|
||||
functions[func.name] = {
|
||||
Name: func.name,
|
||||
Body: func.body,
|
||||
DocString: func.displayName,
|
||||
Folder: func.category,
|
||||
FunctionKind: 'Unknown',
|
||||
InputParameters: [],
|
||||
OutputColumns: [],
|
||||
};
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
static findOrCreateBucket(data, target): DataTarget {
|
||||
let dataTarget = _.find(data, ['target', target]);
|
||||
if (!dataTarget) {
|
||||
dataTarget = { target: target, datapoints: [], refId: '', query: '' };
|
||||
data.push(dataTarget);
|
||||
}
|
||||
|
||||
return dataTarget;
|
||||
}
|
||||
|
||||
static dateTimeToEpoch(dateTime) {
|
||||
return moment(dateTime).valueOf();
|
||||
}
|
||||
}
|
@ -0,0 +1,819 @@
|
||||
import AzureMonitorDatasource from '../datasource';
|
||||
import Q from 'q';
|
||||
import moment from 'moment';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
describe('AzureMonitorDatasource', () => {
|
||||
const ctx: any = {
|
||||
backendSrv: {},
|
||||
templateSrv: new TemplateSrv(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.$q = Q;
|
||||
ctx.instanceSettings = {
|
||||
url: 'http://azuremonitor.com',
|
||||
jsonData: { subscriptionId: '9935389e-9122-4ef9-95f9-1513dd24753f' },
|
||||
cloudName: 'azuremonitor',
|
||||
};
|
||||
|
||||
ctx.ds = new AzureMonitorDatasource(ctx.instanceSettings, ctx.backendSrv, ctx.templateSrv, ctx.$q);
|
||||
});
|
||||
|
||||
describe('When performing testDatasource', () => {
|
||||
describe('and an error is returned', () => {
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
code: 'InvalidApiVersionParameter',
|
||||
message: `An error message.`,
|
||||
},
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.instanceSettings.jsonData.tenantId = 'xxx';
|
||||
ctx.instanceSettings.jsonData.clientId = 'xxx';
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
return ctx.$q.reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return error status and a detailed error message', () => {
|
||||
return ctx.ds.testDatasource().then(results => {
|
||||
expect(results.status).toEqual('error');
|
||||
expect(results.message).toEqual(
|
||||
'1. Azure Monitor: Bad Request: InvalidApiVersionParameter. An error message. '
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and a list of resource groups is returned', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [{ name: 'grp1' }, { name: 'grp2' }],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.instanceSettings.jsonData.tenantId = 'xxx';
|
||||
ctx.instanceSettings.jsonData.clientId = 'xxx';
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return success status', () => {
|
||||
return ctx.ds.testDatasource().then(results => {
|
||||
expect(results.status).toEqual('success');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing query', () => {
|
||||
const options = {
|
||||
range: {
|
||||
from: moment.utc('2017-08-22T20:00:00Z'),
|
||||
to: moment.utc('2017-08-22T23:59:00Z'),
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
apiVersion: '2018-01-01',
|
||||
refId: 'A',
|
||||
queryType: 'Azure Monitor',
|
||||
azureMonitor: {
|
||||
resourceGroup: 'testRG',
|
||||
resourceName: 'testRN',
|
||||
metricDefinition: 'Microsoft.Compute/virtualMachines',
|
||||
metricName: 'Percentage CPU',
|
||||
timeGrain: 'PT1H',
|
||||
alias: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('and data field is average', () => {
|
||||
const response = {
|
||||
value: [
|
||||
{
|
||||
timeseries: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
timeStamp: '2017-08-22T21:00:00Z',
|
||||
average: 1.0503333333333331,
|
||||
},
|
||||
{
|
||||
timeStamp: '2017-08-22T22:00:00Z',
|
||||
average: 1.045083333333333,
|
||||
},
|
||||
{
|
||||
timeStamp: '2017-08-22T23:00:00Z',
|
||||
average: 1.0457499999999995,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
id:
|
||||
'/subscriptions/xxx/resourceGroups/testRG/providers/Microsoft.Compute/virtualMachines' +
|
||||
'/testRN/providers/Microsoft.Insights/metrics/Percentage CPU',
|
||||
name: {
|
||||
value: 'Percentage CPU',
|
||||
localizedValue: 'Percentage CPU',
|
||||
},
|
||||
type: 'Microsoft.Insights/metrics',
|
||||
unit: 'Percent',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain(
|
||||
'/testRG/providers/Microsoft.Compute/virtualMachines/testRN/providers/microsoft.insights/metrics'
|
||||
);
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(1);
|
||||
expect(results.data[0].target).toEqual('testRN.Percentage CPU');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1503435600000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(1.0503333333333331);
|
||||
expect(results.data[0].datapoints[2][1]).toEqual(1503442800000);
|
||||
expect(results.data[0].datapoints[2][0]).toEqual(1.0457499999999995);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and data field is total', () => {
|
||||
const response = {
|
||||
value: [
|
||||
{
|
||||
timeseries: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
timeStamp: '2017-08-22T21:00:00Z',
|
||||
total: 1.0503333333333331,
|
||||
},
|
||||
{
|
||||
timeStamp: '2017-08-22T22:00:00Z',
|
||||
total: 1.045083333333333,
|
||||
},
|
||||
{
|
||||
timeStamp: '2017-08-22T23:00:00Z',
|
||||
total: 1.0457499999999995,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
id:
|
||||
'/subscriptions/xxx/resourceGroups/testRG/providers/Microsoft.Compute/virtualMachines' +
|
||||
'/testRN/providers/Microsoft.Insights/metrics/Percentage CPU',
|
||||
name: {
|
||||
value: 'Percentage CPU',
|
||||
localizedValue: 'Percentage CPU',
|
||||
},
|
||||
type: 'Microsoft.Insights/metrics',
|
||||
unit: 'Percent',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
expect(options.url).toContain(
|
||||
'/testRG/providers/Microsoft.Compute/virtualMachines/testRN/providers/microsoft.insights/metrics'
|
||||
);
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(1);
|
||||
expect(results.data[0].target).toEqual('testRN.Percentage CPU');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1503435600000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(1.0503333333333331);
|
||||
expect(results.data[0].datapoints[2][1]).toEqual(1503442800000);
|
||||
expect(results.data[0].datapoints[2][0]).toEqual(1.0457499999999995);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and data has a dimension filter', () => {
|
||||
const response = {
|
||||
value: [
|
||||
{
|
||||
timeseries: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
timeStamp: '2017-08-22T21:00:00Z',
|
||||
total: 1.0503333333333331,
|
||||
},
|
||||
{
|
||||
timeStamp: '2017-08-22T22:00:00Z',
|
||||
total: 1.045083333333333,
|
||||
},
|
||||
{
|
||||
timeStamp: '2017-08-22T23:00:00Z',
|
||||
total: 1.0457499999999995,
|
||||
},
|
||||
],
|
||||
metadatavalues: [
|
||||
{
|
||||
name: {
|
||||
value: 'blobtype',
|
||||
localizedValue: 'blobtype',
|
||||
},
|
||||
value: 'BlockBlob',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
id:
|
||||
'/subscriptions/xxx/resourceGroups/testRG/providers/Microsoft.Compute/virtualMachines' +
|
||||
'/testRN/providers/Microsoft.Insights/metrics/Percentage CPU',
|
||||
name: {
|
||||
value: 'Percentage CPU',
|
||||
localizedValue: 'Percentage CPU',
|
||||
},
|
||||
type: 'Microsoft.Insights/metrics',
|
||||
unit: 'Percent',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('and with no alias specified', () => {
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const expected =
|
||||
'/testRG/providers/Microsoft.Compute/virtualMachines/testRN/providers/microsoft.insights/metrics';
|
||||
expect(options.url).toContain(expected);
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(1);
|
||||
expect(results.data[0].target).toEqual('testRN{blobtype=BlockBlob}.Percentage CPU');
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1503435600000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(1.0503333333333331);
|
||||
expect(results.data[0].datapoints[2][1]).toEqual(1503442800000);
|
||||
expect(results.data[0].datapoints[2][0]).toEqual(1.0457499999999995);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and with an alias specified', () => {
|
||||
beforeEach(() => {
|
||||
options.targets[0].azureMonitor.alias =
|
||||
'{{resourcegroup}} + {{namespace}} + {{resourcename}} + ' +
|
||||
'{{metric}} + {{dimensionname}} + {{dimensionvalue}}';
|
||||
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const expected =
|
||||
'/testRG/providers/Microsoft.Compute/virtualMachines/testRN/providers/microsoft.insights/metrics';
|
||||
expect(options.url).toContain(expected);
|
||||
return ctx.$q.when({ data: response, status: 200 });
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of datapoints', () => {
|
||||
return ctx.ds.query(options).then(results => {
|
||||
expect(results.data.length).toBe(1);
|
||||
const expected =
|
||||
'testRG + Microsoft.Compute/virtualMachines + testRN + Percentage CPU + blobtype + BlockBlob';
|
||||
expect(results.data[0].target).toEqual(expected);
|
||||
expect(results.data[0].datapoints[0][1]).toEqual(1503435600000);
|
||||
expect(results.data[0].datapoints[0][0]).toEqual(1.0503333333333331);
|
||||
expect(results.data[0].datapoints[2][1]).toEqual(1503442800000);
|
||||
expect(results.data[0].datapoints[2][0]).toEqual(1.0457499999999995);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery', () => {
|
||||
describe('with a metric names query', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [{ name: 'grp1' }, { name: 'grp2' }],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of metric names', () => {
|
||||
return ctx.ds.metricFindQuery('ResourceGroups()').then(results => {
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].text).toBe('grp1');
|
||||
expect(results[0].value).toBe('grp1');
|
||||
expect(results[1].text).toBe('grp2');
|
||||
expect(results[1].value).toBe('grp2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with metric definitions query', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'Microsoft.Network/networkInterfaces',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
|
||||
expect(options.url).toBe(baseUrl + '/nodesapp/resources?api-version=2018-01-01');
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of metric definitions', () => {
|
||||
return ctx.ds.metricFindQuery('Namespaces(nodesapp)').then(results => {
|
||||
expect(results.length).toEqual(1);
|
||||
expect(results[0].text).toEqual('Microsoft.Network/networkInterfaces');
|
||||
expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with resource names query', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: 'Failure Anomalies - nodeapp',
|
||||
type: 'microsoft.insights/alertrules',
|
||||
},
|
||||
{
|
||||
name: 'nodeapp',
|
||||
type: 'microsoft.insights/components',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
|
||||
expect(options.url).toBe(baseUrl + '/nodeapp/resources?api-version=2018-01-01');
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of resource names', () => {
|
||||
return ctx.ds.metricFindQuery('resourceNames(nodeapp, microsoft.insights/components )').then(results => {
|
||||
expect(results.length).toEqual(1);
|
||||
expect(results[0].text).toEqual('nodeapp');
|
||||
expect(results[0].value).toEqual('nodeapp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with metric names query', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: {
|
||||
value: 'Percentage CPU',
|
||||
localizedValue: 'Percentage CPU',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: {
|
||||
value: 'UsedCapacity',
|
||||
localizedValue: 'Used capacity',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
|
||||
expect(options.url).toBe(
|
||||
baseUrl +
|
||||
'/nodeapp/providers/microsoft.insights/components/rn/providers/microsoft.insights/' +
|
||||
'metricdefinitions?api-version=2018-01-01'
|
||||
);
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of metric names', () => {
|
||||
return ctx.ds.metricFindQuery('Metricnames(nodeapp, microsoft.insights/components, rn)').then(results => {
|
||||
expect(results.length).toEqual(2);
|
||||
expect(results[0].text).toEqual('Percentage CPU');
|
||||
expect(results[0].value).toEqual('Percentage CPU');
|
||||
|
||||
expect(results[1].text).toEqual('Used capacity');
|
||||
expect(results[1].value).toEqual('UsedCapacity');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing getResourceGroups', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [{ name: 'grp1' }, { name: 'grp2' }],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return list of Resource Groups', () => {
|
||||
return ctx.ds.getResourceGroups().then(results => {
|
||||
expect(results.length).toEqual(2);
|
||||
expect(results[0].text).toEqual('grp1');
|
||||
expect(results[0].value).toEqual('grp1');
|
||||
expect(results[1].text).toEqual('grp2');
|
||||
expect(results[1].value).toEqual('grp2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing getMetricDefinitions', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: 'test',
|
||||
type: 'Microsoft.Network/networkInterfaces',
|
||||
},
|
||||
{
|
||||
location: 'northeurope',
|
||||
name: 'northeur',
|
||||
type: 'Microsoft.Compute/virtualMachines',
|
||||
},
|
||||
{
|
||||
location: 'westcentralus',
|
||||
name: 'us',
|
||||
type: 'Microsoft.Compute/virtualMachines',
|
||||
},
|
||||
{
|
||||
name: 'IHaveNoMetrics',
|
||||
type: 'IShouldBeFilteredOut',
|
||||
},
|
||||
{
|
||||
name: 'storageTest',
|
||||
type: 'Microsoft.Storage/storageAccounts',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
|
||||
expect(options.url).toBe(baseUrl + '/nodesapp/resources?api-version=2018-01-01');
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return list of Metric Definitions with no duplicates and no unsupported namespaces', () => {
|
||||
return ctx.ds.getMetricDefinitions('nodesapp').then(results => {
|
||||
expect(results.length).toEqual(7);
|
||||
expect(results[0].text).toEqual('Microsoft.Network/networkInterfaces');
|
||||
expect(results[0].value).toEqual('Microsoft.Network/networkInterfaces');
|
||||
expect(results[1].text).toEqual('Microsoft.Compute/virtualMachines');
|
||||
expect(results[1].value).toEqual('Microsoft.Compute/virtualMachines');
|
||||
expect(results[2].text).toEqual('Microsoft.Storage/storageAccounts');
|
||||
expect(results[2].value).toEqual('Microsoft.Storage/storageAccounts');
|
||||
expect(results[3].text).toEqual('Microsoft.Storage/storageAccounts/blobServices');
|
||||
expect(results[3].value).toEqual('Microsoft.Storage/storageAccounts/blobServices');
|
||||
expect(results[4].text).toEqual('Microsoft.Storage/storageAccounts/fileServices');
|
||||
expect(results[4].value).toEqual('Microsoft.Storage/storageAccounts/fileServices');
|
||||
expect(results[5].text).toEqual('Microsoft.Storage/storageAccounts/tableServices');
|
||||
expect(results[5].value).toEqual('Microsoft.Storage/storageAccounts/tableServices');
|
||||
expect(results[6].text).toEqual('Microsoft.Storage/storageAccounts/queueServices');
|
||||
expect(results[6].value).toEqual('Microsoft.Storage/storageAccounts/queueServices');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing getResourceNames', () => {
|
||||
describe('and there are no special cases', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: 'Failure Anomalies - nodeapp',
|
||||
type: 'microsoft.insights/alertrules',
|
||||
},
|
||||
{
|
||||
name: 'nodeapp',
|
||||
type: 'microsoft.insights/components',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
|
||||
expect(options.url).toBe(baseUrl + '/nodeapp/resources?api-version=2018-01-01');
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return list of Resource Names', () => {
|
||||
return ctx.ds.getResourceNames('nodeapp', 'microsoft.insights/components').then(results => {
|
||||
expect(results.length).toEqual(1);
|
||||
expect(results[0].text).toEqual('nodeapp');
|
||||
expect(results[0].value).toEqual('nodeapp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and the metric definition is blobServices', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: 'Failure Anomalies - nodeapp',
|
||||
type: 'microsoft.insights/alertrules',
|
||||
},
|
||||
{
|
||||
name: 'storagetest',
|
||||
type: 'Microsoft.Storage/storageAccounts',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups';
|
||||
expect(options.url).toBe(baseUrl + '/nodeapp/resources?api-version=2018-01-01');
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return list of Resource Names', () => {
|
||||
return ctx.ds.getResourceNames('nodeapp', 'Microsoft.Storage/storageAccounts/blobServices').then(results => {
|
||||
expect(results.length).toEqual(1);
|
||||
expect(results[0].text).toEqual('storagetest/default');
|
||||
expect(results[0].value).toEqual('storagetest/default');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing getMetricNames', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: {
|
||||
value: 'UsedCapacity',
|
||||
localizedValue: 'Used capacity',
|
||||
},
|
||||
unit: 'CountPerSecond',
|
||||
primaryAggregationType: 'Total',
|
||||
supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
|
||||
metricAvailabilities: [
|
||||
{ timeGrain: 'PT1H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT6H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT12H', retention: 'P93D' },
|
||||
{ timeGrain: 'P1D', retention: 'P93D' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: {
|
||||
value: 'FreeCapacity',
|
||||
localizedValue: 'Free capacity',
|
||||
},
|
||||
unit: 'CountPerSecond',
|
||||
primaryAggregationType: 'Average',
|
||||
supportedAggregationTypes: ['None', 'Average'],
|
||||
metricAvailabilities: [
|
||||
{ timeGrain: 'PT1H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT6H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT12H', retention: 'P93D' },
|
||||
{ timeGrain: 'P1D', retention: 'P93D' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
|
||||
const expected =
|
||||
baseUrl +
|
||||
'/providers/microsoft.insights/components/resource1' +
|
||||
'/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01';
|
||||
expect(options.url).toBe(expected);
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return list of Metric Definitions', () => {
|
||||
return ctx.ds.getMetricNames('nodeapp', 'microsoft.insights/components', 'resource1').then(results => {
|
||||
expect(results.length).toEqual(2);
|
||||
expect(results[0].text).toEqual('Used capacity');
|
||||
expect(results[0].value).toEqual('UsedCapacity');
|
||||
expect(results[1].text).toEqual('Free capacity');
|
||||
expect(results[1].value).toEqual('FreeCapacity');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing getMetricMetadata', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: {
|
||||
value: 'UsedCapacity',
|
||||
localizedValue: 'Used capacity',
|
||||
},
|
||||
unit: 'CountPerSecond',
|
||||
primaryAggregationType: 'Total',
|
||||
supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
|
||||
metricAvailabilities: [
|
||||
{ timeGrain: 'PT1H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT6H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT12H', retention: 'P93D' },
|
||||
{ timeGrain: 'P1D', retention: 'P93D' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: {
|
||||
value: 'FreeCapacity',
|
||||
localizedValue: 'Free capacity',
|
||||
},
|
||||
unit: 'CountPerSecond',
|
||||
primaryAggregationType: 'Average',
|
||||
supportedAggregationTypes: ['None', 'Average'],
|
||||
metricAvailabilities: [
|
||||
{ timeGrain: 'PT1H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT6H', retention: 'P93D' },
|
||||
{ timeGrain: 'PT12H', retention: 'P93D' },
|
||||
{ timeGrain: 'P1D', retention: 'P93D' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
|
||||
const expected =
|
||||
baseUrl +
|
||||
'/providers/microsoft.insights/components/resource1' +
|
||||
'/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01';
|
||||
expect(options.url).toBe(expected);
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return Aggregation metadata for a Metric', () => {
|
||||
return ctx.ds
|
||||
.getMetricMetadata('nodeapp', 'microsoft.insights/components', 'resource1', 'UsedCapacity')
|
||||
.then(results => {
|
||||
expect(results.primaryAggType).toEqual('Total');
|
||||
expect(results.supportedAggTypes.length).toEqual(6);
|
||||
expect(results.supportedTimeGrains.length).toEqual(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing getMetricMetadata on metrics with dimensions', () => {
|
||||
const response = {
|
||||
data: {
|
||||
value: [
|
||||
{
|
||||
name: {
|
||||
value: 'Transactions',
|
||||
localizedValue: 'Transactions',
|
||||
},
|
||||
unit: 'Count',
|
||||
primaryAggregationType: 'Total',
|
||||
supportedAggregationTypes: ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'],
|
||||
isDimensionRequired: false,
|
||||
dimensions: [
|
||||
{
|
||||
value: 'ResponseType',
|
||||
localizedValue: 'Response type',
|
||||
},
|
||||
{
|
||||
value: 'GeoType',
|
||||
localizedValue: 'Geo type',
|
||||
},
|
||||
{
|
||||
value: 'ApiName',
|
||||
localizedValue: 'API name',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: {
|
||||
value: 'FreeCapacity',
|
||||
localizedValue: 'Free capacity',
|
||||
},
|
||||
unit: 'CountPerSecond',
|
||||
primaryAggregationType: 'Average',
|
||||
supportedAggregationTypes: ['None', 'Average'],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = options => {
|
||||
const baseUrl =
|
||||
'http://azuremonitor.com/azuremonitor/subscriptions/9935389e-9122-4ef9-95f9-1513dd24753f/resourceGroups/nodeapp';
|
||||
const expected =
|
||||
baseUrl +
|
||||
'/providers/microsoft.insights/components/resource1' +
|
||||
'/providers/microsoft.insights/metricdefinitions?api-version=2018-01-01';
|
||||
expect(options.url).toBe(expected);
|
||||
return ctx.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return dimensions for a Metric that has dimensions', () => {
|
||||
return ctx.ds
|
||||
.getMetricMetadata('nodeapp', 'microsoft.insights/components', 'resource1', 'Transactions')
|
||||
.then(results => {
|
||||
expect(results.dimensions.length).toEqual(4);
|
||||
expect(results.dimensions[0].text).toEqual('None');
|
||||
expect(results.dimensions[0].value).toEqual('None');
|
||||
expect(results.dimensions[1].text).toEqual('Response type');
|
||||
expect(results.dimensions[1].value).toEqual('ResponseType');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty array for a Metric that does not have dimensions', () => {
|
||||
return ctx.ds
|
||||
.getMetricMetadata('nodeapp', 'microsoft.insights/components', 'resource1', 'FreeCapacity')
|
||||
.then(results => {
|
||||
expect(results.dimensions.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,339 @@
|
||||
import _ from 'lodash';
|
||||
import AzureMonitorFilterBuilder from './azure_monitor_filter_builder';
|
||||
import UrlBuilder from './url_builder';
|
||||
import ResponseParser from './response_parser';
|
||||
import SupportedNamespaces from './supported_namespaces';
|
||||
import TimegrainConverter from '../time_grain_converter';
|
||||
|
||||
export default class AzureMonitorDatasource {
|
||||
apiVersion = '2018-01-01';
|
||||
id: number;
|
||||
subscriptionId: string;
|
||||
baseUrl: string;
|
||||
resourceGroup: string;
|
||||
resourceName: string;
|
||||
url: string;
|
||||
defaultDropdownValue = 'select';
|
||||
cloudName: string;
|
||||
supportedMetricNamespaces: any[] = [];
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private instanceSettings, private backendSrv, private templateSrv, private $q) {
|
||||
this.id = instanceSettings.id;
|
||||
this.subscriptionId = instanceSettings.jsonData.subscriptionId;
|
||||
this.cloudName = instanceSettings.jsonData.cloudName || 'azuremonitor';
|
||||
this.baseUrl = `/${this.cloudName}/subscriptions/${this.subscriptionId}/resourceGroups`;
|
||||
this.url = instanceSettings.url;
|
||||
|
||||
this.supportedMetricNamespaces = new SupportedNamespaces(this.cloudName).get();
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.subscriptionId && this.subscriptionId.length > 0;
|
||||
}
|
||||
|
||||
query(options) {
|
||||
const queries = _.filter(options.targets, item => {
|
||||
return (
|
||||
item.hide !== true &&
|
||||
item.azureMonitor.resourceGroup &&
|
||||
item.azureMonitor.resourceGroup !== this.defaultDropdownValue &&
|
||||
item.azureMonitor.resourceName &&
|
||||
item.azureMonitor.resourceName !== this.defaultDropdownValue &&
|
||||
item.azureMonitor.metricDefinition &&
|
||||
item.azureMonitor.metricDefinition !== this.defaultDropdownValue &&
|
||||
item.azureMonitor.metricName &&
|
||||
item.azureMonitor.metricName !== this.defaultDropdownValue
|
||||
);
|
||||
}).map(target => {
|
||||
const item = target.azureMonitor;
|
||||
|
||||
if (item.timeGrainUnit && item.timeGrain !== 'auto') {
|
||||
item.timeGrain = TimegrainConverter.createISO8601Duration(item.timeGrain, item.timeGrainUnit);
|
||||
}
|
||||
|
||||
const resourceGroup = this.templateSrv.replace(item.resourceGroup, options.scopedVars);
|
||||
const resourceName = this.templateSrv.replace(item.resourceName, options.scopedVars);
|
||||
const metricDefinition = this.templateSrv.replace(item.metricDefinition, options.scopedVars);
|
||||
const timeGrain = this.templateSrv.replace((item.timeGrain || '').toString(), options.scopedVars);
|
||||
|
||||
const filterBuilder = new AzureMonitorFilterBuilder(
|
||||
item.metricName,
|
||||
options.range.from,
|
||||
options.range.to,
|
||||
timeGrain,
|
||||
options.interval
|
||||
);
|
||||
|
||||
if (item.timeGrains) {
|
||||
filterBuilder.setAllowedTimeGrains(item.timeGrains);
|
||||
}
|
||||
|
||||
if (item.aggregation) {
|
||||
filterBuilder.setAggregation(item.aggregation);
|
||||
}
|
||||
|
||||
if (item.dimension && item.dimension !== 'None') {
|
||||
filterBuilder.setDimensionFilter(item.dimension, item.dimensionFilter);
|
||||
}
|
||||
|
||||
const filter = this.templateSrv.replace(filterBuilder.generateFilter(), options.scopedVars);
|
||||
|
||||
const url = UrlBuilder.buildAzureMonitorQueryUrl(
|
||||
this.baseUrl,
|
||||
resourceGroup,
|
||||
metricDefinition,
|
||||
resourceName,
|
||||
this.apiVersion,
|
||||
filter
|
||||
);
|
||||
|
||||
return {
|
||||
refId: target.refId,
|
||||
intervalMs: options.intervalMs,
|
||||
maxDataPoints: options.maxDataPoints,
|
||||
datasourceId: this.id,
|
||||
url: url,
|
||||
format: options.format,
|
||||
alias: item.alias,
|
||||
raw: false,
|
||||
};
|
||||
});
|
||||
|
||||
if (!queries || queries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = this.doQueries(queries);
|
||||
|
||||
return this.$q.all(promises).then(results => {
|
||||
return new ResponseParser(results).parseQueryResult();
|
||||
});
|
||||
}
|
||||
|
||||
doQueries(queries) {
|
||||
return _.map(queries, query => {
|
||||
return this.doRequest(query.url)
|
||||
.then(result => {
|
||||
return {
|
||||
result: result,
|
||||
query: query,
|
||||
};
|
||||
})
|
||||
.catch(err => {
|
||||
throw {
|
||||
error: err,
|
||||
query: query,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
annotationQuery(options) {}
|
||||
|
||||
metricFindQuery(query: string) {
|
||||
const resourceGroupsQuery = query.match(/^ResourceGroups\(\)/i);
|
||||
if (resourceGroupsQuery) {
|
||||
return this.getResourceGroups();
|
||||
}
|
||||
|
||||
const metricDefinitionsQuery = query.match(/^Namespaces\(([^\)]+?)(,\s?([^,]+?))?\)/i);
|
||||
if (metricDefinitionsQuery) {
|
||||
return this.getMetricDefinitions(this.toVariable(metricDefinitionsQuery[1]));
|
||||
}
|
||||
|
||||
const resourceNamesQuery = query.match(/^ResourceNames\(([^,]+?),\s?([^,]+?)\)/i);
|
||||
if (resourceNamesQuery) {
|
||||
const resourceGroup = this.toVariable(resourceNamesQuery[1]);
|
||||
const metricDefinition = this.toVariable(resourceNamesQuery[2]);
|
||||
return this.getResourceNames(resourceGroup, metricDefinition);
|
||||
}
|
||||
|
||||
const metricNamesQuery = query.match(/^MetricNames\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/i);
|
||||
|
||||
if (metricNamesQuery) {
|
||||
const resourceGroup = this.toVariable(metricNamesQuery[1]);
|
||||
const metricDefinition = this.toVariable(metricNamesQuery[2]);
|
||||
const resourceName = this.toVariable(metricNamesQuery[3]);
|
||||
return this.getMetricNames(resourceGroup, metricDefinition, resourceName);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toVariable(metric: string) {
|
||||
return this.templateSrv.replace((metric || '').trim());
|
||||
}
|
||||
|
||||
getResourceGroups() {
|
||||
const url = `${this.baseUrl}?api-version=${this.apiVersion}`;
|
||||
return this.doRequest(url).then(result => {
|
||||
return ResponseParser.parseResponseValues(result, 'name', 'name');
|
||||
});
|
||||
}
|
||||
|
||||
getMetricDefinitions(resourceGroup: string) {
|
||||
const url = `${this.baseUrl}/${resourceGroup}/resources?api-version=${this.apiVersion}`;
|
||||
return this.doRequest(url)
|
||||
.then(result => {
|
||||
return ResponseParser.parseResponseValues(result, 'type', 'type');
|
||||
})
|
||||
.then(result => {
|
||||
return _.filter(result, t => {
|
||||
for (let i = 0; i < this.supportedMetricNamespaces.length; i++) {
|
||||
if (t.value.toLowerCase() === this.supportedMetricNamespaces[i].toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
})
|
||||
.then(result => {
|
||||
let shouldHardcodeBlobStorage = false;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i].value === 'Microsoft.Storage/storageAccounts') {
|
||||
shouldHardcodeBlobStorage = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldHardcodeBlobStorage) {
|
||||
result.push({
|
||||
text: 'Microsoft.Storage/storageAccounts/blobServices',
|
||||
value: 'Microsoft.Storage/storageAccounts/blobServices',
|
||||
});
|
||||
result.push({
|
||||
text: 'Microsoft.Storage/storageAccounts/fileServices',
|
||||
value: 'Microsoft.Storage/storageAccounts/fileServices',
|
||||
});
|
||||
result.push({
|
||||
text: 'Microsoft.Storage/storageAccounts/tableServices',
|
||||
value: 'Microsoft.Storage/storageAccounts/tableServices',
|
||||
});
|
||||
result.push({
|
||||
text: 'Microsoft.Storage/storageAccounts/queueServices',
|
||||
value: 'Microsoft.Storage/storageAccounts/queueServices',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
getResourceNames(resourceGroup: string, metricDefinition: string) {
|
||||
const url = `${this.baseUrl}/${resourceGroup}/resources?api-version=${this.apiVersion}`;
|
||||
|
||||
return this.doRequest(url).then(result => {
|
||||
if (!_.startsWith(metricDefinition, 'Microsoft.Storage/storageAccounts/')) {
|
||||
return ResponseParser.parseResourceNames(result, metricDefinition);
|
||||
}
|
||||
|
||||
const list = ResponseParser.parseResourceNames(result, 'Microsoft.Storage/storageAccounts');
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
list[i].text += '/default';
|
||||
list[i].value += '/default';
|
||||
}
|
||||
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
getMetricNames(resourceGroup: string, metricDefinition: string, resourceName: string) {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
this.baseUrl,
|
||||
resourceGroup,
|
||||
metricDefinition,
|
||||
resourceName,
|
||||
this.apiVersion
|
||||
);
|
||||
|
||||
return this.doRequest(url).then(result => {
|
||||
return ResponseParser.parseResponseValues(result, 'name.localizedValue', 'name.value');
|
||||
});
|
||||
}
|
||||
|
||||
getMetricMetadata(resourceGroup: string, metricDefinition: string, resourceName: string, metricName: string) {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
this.baseUrl,
|
||||
resourceGroup,
|
||||
metricDefinition,
|
||||
resourceName,
|
||||
this.apiVersion
|
||||
);
|
||||
|
||||
return this.doRequest(url).then(result => {
|
||||
return ResponseParser.parseMetadata(result, metricName);
|
||||
});
|
||||
}
|
||||
|
||||
testDatasource() {
|
||||
if (!this.isValidConfigField(this.instanceSettings.jsonData.tenantId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'The Tenant Id field is required.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isValidConfigField(this.instanceSettings.jsonData.clientId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'The Client Id field is required.',
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}?api-version=${this.apiVersion}`;
|
||||
return this.doRequest(url)
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Successfully queried the Azure Monitor service.',
|
||||
title: 'Success',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Returned http status code ' + response.status,
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
let message = 'Azure Monitor: ';
|
||||
message += error.statusText ? error.statusText + ': ' : '';
|
||||
|
||||
if (error.data && error.data.error && error.data.error.code) {
|
||||
message += error.data.error.code + '. ' + error.data.error.message;
|
||||
} else if (error.data && error.data.error) {
|
||||
message += error.data.error;
|
||||
} else if (error.data) {
|
||||
message += error.data;
|
||||
} else {
|
||||
message += 'Cannot connect to Azure Monitor REST API.';
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
message: message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
isValidConfigField(field: string) {
|
||||
return field && field.length > 0;
|
||||
}
|
||||
|
||||
doRequest(url, maxRetries = 1) {
|
||||
return this.backendSrv
|
||||
.datasourceRequest({
|
||||
url: this.url + url,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch(error => {
|
||||
if (maxRetries > 0) {
|
||||
return this.doRequest(url, maxRetries - 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
jest.mock('app/core/utils/kbn', () => {
|
||||
return {
|
||||
interval_to_ms: interval => {
|
||||
if (interval.substring(interval.length - 1) === 's') {
|
||||
return interval.substring(0, interval.length - 1) * 1000;
|
||||
}
|
||||
|
||||
if (interval.substring(interval.length - 1) === 'm') {
|
||||
return interval.substring(0, interval.length - 1) * 1000 * 60;
|
||||
}
|
||||
|
||||
if (interval.substring(interval.length - 1) === 'd') {
|
||||
return interval.substring(0, interval.length - 1) * 1000 * 60 * 24;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import AzureMonitorFilterBuilder from './azure_monitor_filter_builder';
|
||||
import moment from 'moment';
|
||||
|
||||
describe('AzureMonitorFilterBuilder', () => {
|
||||
let builder: AzureMonitorFilterBuilder;
|
||||
|
||||
const timefilter = 'timespan=2017-08-22T06:00:00Z/2017-08-22T07:00:00Z';
|
||||
const metricFilter = 'metricnames=Percentage CPU';
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new AzureMonitorFilterBuilder(
|
||||
'Percentage CPU',
|
||||
moment.utc('2017-08-22 06:00'),
|
||||
moment.utc('2017-08-22 07:00'),
|
||||
'PT1H',
|
||||
'3m'
|
||||
);
|
||||
});
|
||||
|
||||
describe('with a metric name and auto time grain of 3 minutes', () => {
|
||||
beforeEach(() => {
|
||||
builder.timeGrain = 'auto';
|
||||
});
|
||||
|
||||
it('should always add datetime filtering and a time grain rounded to the closest allowed value to the filter', () => {
|
||||
const filter = timefilter + '&interval=PT5M&' + metricFilter;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a metric name and auto time grain of 30 seconds', () => {
|
||||
beforeEach(() => {
|
||||
builder.timeGrain = 'auto';
|
||||
builder.grafanaInterval = '30s';
|
||||
});
|
||||
|
||||
it('should always add datetime filtering and a time grain in ISO_8601 format to the filter', () => {
|
||||
const filter = timefilter + '&interval=PT1M&' + metricFilter;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a metric name and auto time grain of 10 minutes', () => {
|
||||
beforeEach(() => {
|
||||
builder.timeGrain = 'auto';
|
||||
builder.grafanaInterval = '10m';
|
||||
});
|
||||
|
||||
it('should always add datetime filtering and a time grain rounded to the closest allowed value to the filter', () => {
|
||||
const filter = timefilter + '&interval=PT15M&' + metricFilter;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a metric name and auto time grain of 2 day', () => {
|
||||
beforeEach(() => {
|
||||
builder.timeGrain = 'auto';
|
||||
builder.grafanaInterval = '2d';
|
||||
});
|
||||
|
||||
it('should always add datetime filtering and a time grain rounded to the closest allowed value to the filter', () => {
|
||||
const filter = timefilter + '&interval=P1D&' + metricFilter;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a metric name and 1 hour time grain', () => {
|
||||
it('should always add datetime filtering and a time grain in ISO_8601 format to the filter', () => {
|
||||
const filter = timefilter + '&interval=PT1H&' + metricFilter;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a metric name and 1 minute time grain', () => {
|
||||
beforeEach(() => {
|
||||
builder.timeGrain = 'PT1M';
|
||||
});
|
||||
|
||||
it('should always add datetime filtering and a time grain in ISO_8601 format to the filter', () => {
|
||||
const filter = timefilter + '&interval=PT1M&' + metricFilter;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a metric name and 1 day time grain and an aggregation', () => {
|
||||
beforeEach(() => {
|
||||
builder.timeGrain = 'P1D';
|
||||
builder.setAggregation('Maximum');
|
||||
});
|
||||
|
||||
it('should add time grain to the filter in ISO_8601 format', () => {
|
||||
const filter = timefilter + '&interval=P1D&aggregation=Maximum&' + metricFilter;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a metric name and 1 day time grain and an aggregation and a dimension', () => {
|
||||
beforeEach(() => {
|
||||
builder.setDimensionFilter('aDimension', 'aFilterValue');
|
||||
});
|
||||
|
||||
it('should add dimension to the filter', () => {
|
||||
const filter = timefilter + '&interval=PT1H&' + metricFilter + `&$filter=aDimension eq 'aFilterValue'`;
|
||||
expect(builder.generateFilter()).toEqual(filter);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,72 @@
|
||||
import TimegrainConverter from '../time_grain_converter';
|
||||
|
||||
export default class AzureMonitorFilterBuilder {
|
||||
aggregation: string;
|
||||
timeGrainInterval = '';
|
||||
dimension: string;
|
||||
dimensionFilter: string;
|
||||
allowedTimeGrains = ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'];
|
||||
|
||||
constructor(
|
||||
private metricName: string,
|
||||
private from,
|
||||
private to,
|
||||
public timeGrain: string,
|
||||
public grafanaInterval: string
|
||||
) {}
|
||||
|
||||
setAllowedTimeGrains(timeGrains) {
|
||||
this.allowedTimeGrains = [];
|
||||
timeGrains.forEach(tg => {
|
||||
if (tg.value === 'auto') {
|
||||
this.allowedTimeGrains.push(tg.value);
|
||||
} else {
|
||||
this.allowedTimeGrains.push(TimegrainConverter.createKbnUnitFromISO8601Duration(tg.value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setAggregation(agg) {
|
||||
this.aggregation = agg;
|
||||
}
|
||||
|
||||
setDimensionFilter(dimension, dimensionFilter) {
|
||||
this.dimension = dimension;
|
||||
this.dimensionFilter = dimensionFilter;
|
||||
}
|
||||
|
||||
generateFilter() {
|
||||
let filter = this.createDatetimeAndTimeGrainConditions();
|
||||
|
||||
if (this.aggregation) {
|
||||
filter += `&aggregation=${this.aggregation}`;
|
||||
}
|
||||
|
||||
if (this.metricName && this.metricName.trim().length > 0) {
|
||||
filter += `&metricnames=${this.metricName}`;
|
||||
}
|
||||
|
||||
if (this.dimension && this.dimensionFilter && this.dimensionFilter.trim().length > 0) {
|
||||
filter += `&$filter=${this.dimension} eq '${this.dimensionFilter}'`;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
createDatetimeAndTimeGrainConditions() {
|
||||
const dateTimeCondition = `timespan=${this.from.utc().format()}/${this.to.utc().format()}`;
|
||||
|
||||
if (this.timeGrain === 'auto') {
|
||||
this.timeGrain = this.calculateAutoTimeGrain();
|
||||
}
|
||||
const timeGrainCondition = `&interval=${this.timeGrain}`;
|
||||
|
||||
return dateTimeCondition + timeGrainCondition;
|
||||
}
|
||||
|
||||
calculateAutoTimeGrain() {
|
||||
const roundedInterval = TimegrainConverter.findClosestTimeGrain(this.grafanaInterval, this.allowedTimeGrains);
|
||||
|
||||
return TimegrainConverter.createISO8601DurationFromInterval(roundedInterval);
|
||||
}
|
||||
}
|
@ -0,0 +1,184 @@
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import TimeGrainConverter from '../time_grain_converter';
|
||||
|
||||
export default class ResponseParser {
|
||||
constructor(private results) {}
|
||||
|
||||
parseQueryResult() {
|
||||
const data: any[] = [];
|
||||
for (let i = 0; i < this.results.length; i++) {
|
||||
for (let j = 0; j < this.results[i].result.data.value.length; j++) {
|
||||
for (let k = 0; k < this.results[i].result.data.value[j].timeseries.length; k++) {
|
||||
const alias = this.results[i].query.alias;
|
||||
data.push({
|
||||
target: ResponseParser.createTarget(
|
||||
this.results[i].result.data.value[j],
|
||||
this.results[i].result.data.value[j].timeseries[k].metadatavalues,
|
||||
alias
|
||||
),
|
||||
datapoints: ResponseParser.convertDataToPoints(this.results[i].result.data.value[j].timeseries[k].data),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static createTarget(data, metadatavalues, alias: string) {
|
||||
const resourceGroup = ResponseParser.parseResourceGroupFromId(data.id);
|
||||
const resourceName = ResponseParser.parseResourceNameFromId(data.id);
|
||||
const namespace = ResponseParser.parseNamespaceFromId(data.id, resourceName);
|
||||
if (alias) {
|
||||
const regex = /\{\{([\s\S]+?)\}\}/g;
|
||||
return alias.replace(regex, (match, g1, g2) => {
|
||||
const group = g1 || g2;
|
||||
|
||||
if (group === 'resourcegroup') {
|
||||
return resourceGroup;
|
||||
} else if (group === 'namespace') {
|
||||
return namespace;
|
||||
} else if (group === 'resourcename') {
|
||||
return resourceName;
|
||||
} else if (group === 'metric') {
|
||||
return data.name.value;
|
||||
} else if (group === 'dimensionname') {
|
||||
return metadatavalues && metadatavalues.length > 0 ? metadatavalues[0].name.value : '';
|
||||
} else if (group === 'dimensionvalue') {
|
||||
return metadatavalues && metadatavalues.length > 0 ? metadatavalues[0].value : '';
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
if (metadatavalues && metadatavalues.length > 0) {
|
||||
return `${resourceName}{${metadatavalues[0].name.value}=${metadatavalues[0].value}}.${data.name.value}`;
|
||||
}
|
||||
|
||||
return `${resourceName}.${data.name.value}`;
|
||||
}
|
||||
|
||||
static parseResourceGroupFromId(id: string) {
|
||||
const startIndex = id.indexOf('/resourceGroups/') + 16;
|
||||
const endIndex = id.indexOf('/providers');
|
||||
|
||||
return id.substring(startIndex, endIndex);
|
||||
}
|
||||
|
||||
static parseNamespaceFromId(id: string, resourceName: string) {
|
||||
const startIndex = id.indexOf('/providers/') + 11;
|
||||
const endIndex = id.indexOf('/' + resourceName);
|
||||
|
||||
return id.substring(startIndex, endIndex);
|
||||
}
|
||||
|
||||
static parseResourceNameFromId(id: string) {
|
||||
const endIndex = id.lastIndexOf('/providers');
|
||||
const startIndex = id.slice(0, endIndex).lastIndexOf('/') + 1;
|
||||
|
||||
return id.substring(startIndex, endIndex);
|
||||
}
|
||||
|
||||
static convertDataToPoints(timeSeriesData) {
|
||||
const dataPoints: any[] = [];
|
||||
|
||||
for (let k = 0; k < timeSeriesData.length; k++) {
|
||||
const epoch = ResponseParser.dateTimeToEpoch(timeSeriesData[k].timeStamp);
|
||||
const aggKey = ResponseParser.getKeyForAggregationField(timeSeriesData[k]);
|
||||
|
||||
if (aggKey) {
|
||||
dataPoints.push([timeSeriesData[k][aggKey], epoch]);
|
||||
}
|
||||
}
|
||||
|
||||
return dataPoints;
|
||||
}
|
||||
|
||||
static dateTimeToEpoch(dateTime) {
|
||||
return moment(dateTime).valueOf();
|
||||
}
|
||||
|
||||
static getKeyForAggregationField(dataObj): string {
|
||||
const keys = _.keys(dataObj);
|
||||
if (keys.length < 2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return _.intersection(keys, ['total', 'average', 'maximum', 'minimum', 'count'])[0];
|
||||
}
|
||||
|
||||
static parseResponseValues(result: any, textFieldName: string, valueFieldName: string) {
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < result.data.value.length; i++) {
|
||||
if (!_.find(list, ['value', _.get(result.data.value[i], valueFieldName)])) {
|
||||
list.push({
|
||||
text: _.get(result.data.value[i], textFieldName),
|
||||
value: _.get(result.data.value[i], valueFieldName),
|
||||
});
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static parseResourceNames(result: any, metricDefinition: string) {
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < result.data.value.length; i++) {
|
||||
if (result.data.value[i].type === metricDefinition) {
|
||||
list.push({
|
||||
text: result.data.value[i].name,
|
||||
value: result.data.value[i].name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
static parseMetadata(result: any, metricName: string) {
|
||||
const metricData = _.find(result.data.value, o => {
|
||||
return _.get(o, 'name.value') === metricName;
|
||||
});
|
||||
|
||||
const defaultAggTypes = ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'];
|
||||
|
||||
return {
|
||||
primaryAggType: metricData.primaryAggregationType,
|
||||
supportedAggTypes: metricData.supportedAggregationTypes || defaultAggTypes,
|
||||
supportedTimeGrains: ResponseParser.parseTimeGrains(metricData.metricAvailabilities || []),
|
||||
dimensions: ResponseParser.parseDimensions(metricData),
|
||||
};
|
||||
}
|
||||
|
||||
static parseTimeGrains(metricAvailabilities) {
|
||||
const timeGrains: any[] = [];
|
||||
metricAvailabilities.forEach(avail => {
|
||||
if (avail.timeGrain) {
|
||||
timeGrains.push({
|
||||
text: TimeGrainConverter.createTimeGrainFromISO8601Duration(avail.timeGrain),
|
||||
value: avail.timeGrain,
|
||||
});
|
||||
}
|
||||
});
|
||||
return timeGrains;
|
||||
}
|
||||
|
||||
static parseDimensions(metricData: any) {
|
||||
const dimensions: any[] = [];
|
||||
if (!metricData.dimensions || metricData.dimensions.length === 0) {
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
if (!metricData.isDimensionRequired) {
|
||||
dimensions.push({ text: 'None', value: 'None' });
|
||||
}
|
||||
|
||||
for (let i = 0; i < metricData.dimensions.length; i++) {
|
||||
dimensions.push({
|
||||
text: metricData.dimensions[i].localizedValue,
|
||||
value: metricData.dimensions[i].value,
|
||||
});
|
||||
}
|
||||
return dimensions;
|
||||
}
|
||||
}
|
@ -0,0 +1,241 @@
|
||||
export default class SupportedNamespaces {
|
||||
supportedMetricNamespaces = {
|
||||
azuremonitor: [
|
||||
'Microsoft.AnalysisServices/servers',
|
||||
'Microsoft.ApiManagement/service',
|
||||
'Microsoft.Automation/automationAccounts',
|
||||
'Microsoft.Batch/batchAccounts',
|
||||
'Microsoft.Cache/redis',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.CognitiveServices/accounts',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
'Microsoft.ContainerInstance/containerGroups',
|
||||
'Microsoft.ContainerRegistry/registries',
|
||||
'Microsoft.ContainerService/managedClusters',
|
||||
'Microsoft.CustomerInsights/hubs',
|
||||
'Microsoft.DataBoxEdge/dataBoxEdgeDevices',
|
||||
'Microsoft.DataFactory/datafactories',
|
||||
'Microsoft.DataFactory/factories',
|
||||
'Microsoft.DataLakeAnalytics/accounts',
|
||||
'Microsoft.DataLakeStore/accounts',
|
||||
'Microsoft.DBforMariaDB/servers',
|
||||
'Microsoft.DBforMySQL/servers',
|
||||
'Microsoft.DBforPostgreSQL/servers',
|
||||
'Microsoft.Devices/IotHubs',
|
||||
'Microsoft.Devices/provisioningServices',
|
||||
'Microsoft.DocumentDB/databaseAccounts',
|
||||
'Microsoft.EventGrid/topics',
|
||||
'Microsoft.EventGrid/eventSubscriptions',
|
||||
'Microsoft.EventGrid/extensionTopics',
|
||||
'Microsoft.EventHub/namespaces',
|
||||
'Microsoft.EventHub/clusters',
|
||||
'Microsoft.HDInsight/clusters',
|
||||
'Microsoft.Insights/AutoscaleSettings',
|
||||
'Microsoft.Insights/components',
|
||||
'Microsoft.KeyVault/vaults',
|
||||
'Microsoft.Kusto/clusters',
|
||||
'Microsoft.LocationBasedServices/accounts',
|
||||
'Microsoft.Logic/workflows',
|
||||
'Microsoft.Logic/integrationServiceEnvironments',
|
||||
'Microsoft.NetApp/netAppAccounts/capacityPools',
|
||||
'Microsoft.NetApp/netAppAccounts/capacityPools/Volumes',
|
||||
'Microsoft.Network/networkInterfaces',
|
||||
'Microsoft.Network/loadBalancers',
|
||||
'Microsoft.Network/dnsZones',
|
||||
'Microsoft.Network/publicIPAddresses',
|
||||
'Microsoft.Network/azureFirewalls',
|
||||
'Microsoft.Network/applicationGateways',
|
||||
'Microsoft.Network/virtualNetworkGateways',
|
||||
'Microsoft.Network/expressRouteCircuits',
|
||||
'Microsoft.Network/expressRouteCircuits/Peerings',
|
||||
'Microsoft.Network/connections',
|
||||
'Microsoft.Network/trafficManagerProfiles',
|
||||
'Microsoft.Network/networkWatchers/connectionMonitors',
|
||||
'Microsoft.Network/frontdoors',
|
||||
'Microsoft.NotificationHubs/namespaces/notificationHubs',
|
||||
'Microsoft.PowerBIDedicated/capacities',
|
||||
'Microsoft.Relay/namespaces',
|
||||
'Microsoft.Search/searchServices',
|
||||
'Microsoft.ServiceBus/namespaces',
|
||||
'Microsoft.Sql/servers/databases',
|
||||
'Microsoft.Sql/servers/elasticPools',
|
||||
'Microsoft.Sql/managedInstances',
|
||||
'Microsoft.Storage/storageAccounts',
|
||||
'Microsoft.Storage/storageAccounts/blobServices',
|
||||
'Microsoft.Storage/storageAccounts/fileServices',
|
||||
'Microsoft.Storage/storageAccounts/queueServices',
|
||||
'Microsoft.Storage/storageAccounts/tableServices',
|
||||
'Microsoft.StorageSync/storageSyncServices',
|
||||
'Microsoft.StorageSync/storageSyncServices/syncGroups',
|
||||
'Microsoft.StorageSync/storageSyncServices/syncGroups/serverEndpoints',
|
||||
'Microsoft.StorageSync/storageSyncServices/registeredServers',
|
||||
'Microsoft.StreamAnalytics/streamingJobs',
|
||||
'Microsoft.Web/serverfarms',
|
||||
'Microsoft.Web/sites',
|
||||
'Microsoft.Web/sites/slots',
|
||||
'Microsoft.Web/hostingEnvironments/multiRolePools',
|
||||
'Microsoft.Web/hostingEnvironments/workerPools',
|
||||
],
|
||||
govazuremonitor: [
|
||||
'Microsoft.AnalysisServices/servers',
|
||||
'Microsoft.ApiManagement/service',
|
||||
'Microsoft.Batch/batchAccounts',
|
||||
'Microsoft.Cache/redis',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.CognitiveServices/accounts',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
'Microsoft.ContainerRegistry/registries',
|
||||
'Microsoft.DBforMySQL/servers',
|
||||
'Microsoft.DBforPostgreSQL/servers',
|
||||
'Microsoft.Devices/IotHubs',
|
||||
'Microsoft.Devices/provisioningServices',
|
||||
'Microsoft.EventGrid/topics',
|
||||
'Microsoft.EventGrid/eventSubscriptions',
|
||||
'Microsoft.EventGrid/extensionTopics',
|
||||
'Microsoft.EventHub/namespaces',
|
||||
'Microsoft.EventHub/clusters',
|
||||
'Microsoft.Insights/AutoscaleSettings',
|
||||
'Microsoft.KeyVault/vaults',
|
||||
'Microsoft.Logic/workflows',
|
||||
'Microsoft.Network/networkInterfaces',
|
||||
'Microsoft.Network/loadBalancers',
|
||||
'Microsoft.Network/dnsZones',
|
||||
'Microsoft.Network/publicIPAddresses',
|
||||
'Microsoft.Network/azureFirewalls',
|
||||
'Microsoft.Network/applicationGateways',
|
||||
'Microsoft.Network/virtualNetworkGateways',
|
||||
'Microsoft.Network/expressRouteCircuits',
|
||||
'Microsoft.Network/expressRouteCircuits/Peerings',
|
||||
'Microsoft.Network/connections',
|
||||
'Microsoft.Network/trafficManagerProfiles',
|
||||
'Microsoft.Network/networkWatchers/connectionMonitors',
|
||||
'Microsoft.Network/frontdoors',
|
||||
'Microsoft.NotificationHubs/namespaces/notificationHubs',
|
||||
'Microsoft.OperationalInsights/workspaces',
|
||||
'Microsoft.PowerBIDedicated/capacities',
|
||||
'Microsoft.Relay/namespaces',
|
||||
'Microsoft.ServiceBus/namespaces',
|
||||
'Microsoft.Sql/servers/databases',
|
||||
'Microsoft.Sql/servers/elasticPools',
|
||||
'Microsoft.Sql/managedInstances',
|
||||
'Microsoft.Storage/storageAccounts',
|
||||
'Microsoft.Storage/storageAccounts/blobServices',
|
||||
'Microsoft.Storage/storageAccounts/fileServices',
|
||||
'Microsoft.Storage/storageAccounts/queueServices',
|
||||
'Microsoft.Storage/storageAccounts/tableServices',
|
||||
'Microsoft.Web/serverfarms',
|
||||
'Microsoft.Web/sites',
|
||||
'Microsoft.Web/sites/slots',
|
||||
'Microsoft.Web/hostingEnvironments/multiRolePools',
|
||||
'Microsoft.Web/hostingEnvironments/workerPools',
|
||||
],
|
||||
germanyazuremonitor: [
|
||||
'Microsoft.AnalysisServices/servers',
|
||||
'Microsoft.Batch/batchAccounts',
|
||||
'Microsoft.Cache/redis',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
'Microsoft.DBforMySQL/servers',
|
||||
'Microsoft.DBforPostgreSQL/servers',
|
||||
'Microsoft.Devices/IotHubs',
|
||||
'Microsoft.Devices/provisioningServices',
|
||||
'Microsoft.EventHub/namespaces',
|
||||
'Microsoft.EventHub/clusters',
|
||||
'Microsoft.Insights/AutoscaleSettings',
|
||||
'Microsoft.KeyVault/vaults',
|
||||
'Microsoft.Network/networkInterfaces',
|
||||
'Microsoft.Network/loadBalancers',
|
||||
'Microsoft.Network/dnsZones',
|
||||
'Microsoft.Network/publicIPAddresses',
|
||||
'Microsoft.Network/azureFirewalls',
|
||||
'Microsoft.Network/applicationGateways',
|
||||
'Microsoft.Network/virtualNetworkGateways',
|
||||
'Microsoft.Network/expressRouteCircuits',
|
||||
'Microsoft.Network/expressRouteCircuits/Peerings',
|
||||
'Microsoft.Network/connections',
|
||||
'Microsoft.Network/trafficManagerProfiles',
|
||||
'Microsoft.Network/networkWatchers/connectionMonitors',
|
||||
'Microsoft.Network/frontdoors',
|
||||
'Microsoft.NotificationHubs/namespaces/notificationHubs',
|
||||
'Microsoft.PowerBIDedicated/capacities',
|
||||
'Microsoft.Relay/namespaces',
|
||||
'Microsoft.ServiceBus/namespaces',
|
||||
'Microsoft.Sql/servers/databases',
|
||||
'Microsoft.Sql/servers/elasticPools',
|
||||
'Microsoft.Sql/managedInstances',
|
||||
'Microsoft.Storage/storageAccounts',
|
||||
'Microsoft.Storage/storageAccounts/blobServices',
|
||||
'Microsoft.Storage/storageAccounts/fileServices',
|
||||
'Microsoft.Storage/storageAccounts/queueServices',
|
||||
'Microsoft.Storage/storageAccounts/tableServices',
|
||||
'Microsoft.StreamAnalytics/streamingJobs',
|
||||
'Microsoft.Web/serverfarms',
|
||||
'Microsoft.Web/sites',
|
||||
'Microsoft.Web/sites/slots',
|
||||
'Microsoft.Web/hostingEnvironments/multiRolePools',
|
||||
'Microsoft.Web/hostingEnvironments/workerPools',
|
||||
],
|
||||
chinaazuremonitor: [
|
||||
'Microsoft.AnalysisServices/servers',
|
||||
'Microsoft.Batch/batchAccounts',
|
||||
'Microsoft.Cache/redis',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.CognitiveServices/accounts',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
'Microsoft.ContainerRegistry/registries',
|
||||
'Microsoft.DBforMySQL/servers',
|
||||
'Microsoft.DBforPostgreSQL/servers',
|
||||
'Microsoft.Devices/IotHubs',
|
||||
'Microsoft.Devices/provisioningServices',
|
||||
'Microsoft.EventHub/namespaces',
|
||||
'Microsoft.Insights/AutoscaleSettings',
|
||||
'Microsoft.KeyVault/vaults',
|
||||
'Microsoft.Logic/workflows',
|
||||
'Microsoft.Network/networkInterfaces',
|
||||
'Microsoft.Network/loadBalancers',
|
||||
'Microsoft.Network/dnsZones',
|
||||
'Microsoft.Network/publicIPAddresses',
|
||||
'Microsoft.Network/azureFirewalls',
|
||||
'Microsoft.Network/applicationGateways',
|
||||
'Microsoft.Network/virtualNetworkGateways',
|
||||
'Microsoft.Network/expressRouteCircuits',
|
||||
'Microsoft.Network/expressRouteCircuits/Peerings',
|
||||
'Microsoft.Network/connections',
|
||||
'Microsoft.Network/trafficManagerProfiles',
|
||||
'Microsoft.Network/networkWatchers/connectionMonitors',
|
||||
'Microsoft.Network/frontdoors',
|
||||
'Microsoft.NotificationHubs/namespaces/notificationHubs',
|
||||
'Microsoft.PowerBIDedicated/capacities',
|
||||
'Microsoft.Relay/namespaces',
|
||||
'Microsoft.ServiceBus/namespaces',
|
||||
'Microsoft.Sql/servers/databases',
|
||||
'Microsoft.Sql/servers/elasticPools',
|
||||
'Microsoft.Sql/managedInstances',
|
||||
'Microsoft.Storage/storageAccounts',
|
||||
'Microsoft.Storage/storageAccounts/blobServices',
|
||||
'Microsoft.Storage/storageAccounts/fileServices',
|
||||
'Microsoft.Storage/storageAccounts/queueServices',
|
||||
'Microsoft.Storage/storageAccounts/tableServices',
|
||||
'Microsoft.StreamAnalytics/streamingJobs',
|
||||
'Microsoft.Web/serverfarms',
|
||||
'Microsoft.Web/sites',
|
||||
'Microsoft.Web/sites/slots',
|
||||
'Microsoft.Web/hostingEnvironments/multiRolePools',
|
||||
'Microsoft.Web/hostingEnvironments/workerPools',
|
||||
],
|
||||
};
|
||||
|
||||
constructor(private cloudName: string) {}
|
||||
|
||||
get() {
|
||||
return this.supportedMetricNamespaces[this.cloudName];
|
||||
}
|
||||
}
|
@ -0,0 +1,167 @@
|
||||
import UrlBuilder from './url_builder';
|
||||
|
||||
describe('AzureMonitorUrlBuilder', () => {
|
||||
describe('when metric definition is Microsoft.Sql/servers/databases', () => {
|
||||
it('should build the getMetricNames url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Sql/servers/databases',
|
||||
'rn1/rn2',
|
||||
'2017-05-01-preview'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' +
|
||||
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Sql/servers', () => {
|
||||
it('should build the getMetricNames url in the shorter format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Sql/servers',
|
||||
'rn',
|
||||
'2017-05-01-preview'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Sql/servers/rn/' +
|
||||
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices', () => {
|
||||
it('should build the getMetricNames url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/blobServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' +
|
||||
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/blobServices', () => {
|
||||
it('should build the query url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorQueryUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/blobServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview',
|
||||
'metricnames=aMetric'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' +
|
||||
'providers/microsoft.insights/metrics?api-version=2017-05-01-preview&metricnames=aMetric'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices', () => {
|
||||
it('should build the getMetricNames url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/fileServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' +
|
||||
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/fileServices', () => {
|
||||
it('should build the query url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorQueryUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/fileServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview',
|
||||
'metricnames=aMetric'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' +
|
||||
'providers/microsoft.insights/metrics?api-version=2017-05-01-preview&metricnames=aMetric'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices', () => {
|
||||
it('should build the getMetricNames url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/tableServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' +
|
||||
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/tableServices', () => {
|
||||
it('should build the query url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorQueryUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/tableServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview',
|
||||
'metricnames=aMetric'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' +
|
||||
'providers/microsoft.insights/metrics?api-version=2017-05-01-preview&metricnames=aMetric'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices', () => {
|
||||
it('should build the getMetricNames url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/queueServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' +
|
||||
'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when metric definition is Microsoft.Storage/storageAccounts/queueServices', () => {
|
||||
it('should build the query url in the longer format', () => {
|
||||
const url = UrlBuilder.buildAzureMonitorQueryUrl(
|
||||
'',
|
||||
'rg',
|
||||
'Microsoft.Storage/storageAccounts/queueServices',
|
||||
'rn1/default',
|
||||
'2017-05-01-preview',
|
||||
'metricnames=aMetric'
|
||||
);
|
||||
expect(url).toBe(
|
||||
'/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' +
|
||||
'providers/microsoft.insights/metrics?api-version=2017-05-01-preview&metricnames=aMetric'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,48 @@
|
||||
export default class UrlBuilder {
|
||||
static buildAzureMonitorQueryUrl(
|
||||
baseUrl: string,
|
||||
resourceGroup: string,
|
||||
metricDefinition: string,
|
||||
resourceName: string,
|
||||
apiVersion: string,
|
||||
filter: string
|
||||
) {
|
||||
if ((metricDefinition.match(/\//g) || []).length > 1) {
|
||||
const rn = resourceName.split('/');
|
||||
const service = metricDefinition.substring(metricDefinition.lastIndexOf('/') + 1);
|
||||
const md = metricDefinition.substring(0, metricDefinition.lastIndexOf('/'));
|
||||
return (
|
||||
`${baseUrl}/${resourceGroup}/providers/${md}/${rn[0]}/${service}/${rn[1]}` +
|
||||
`/providers/microsoft.insights/metrics?api-version=${apiVersion}&${filter}`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
`${baseUrl}/${resourceGroup}/providers/${metricDefinition}/${resourceName}` +
|
||||
`/providers/microsoft.insights/metrics?api-version=${apiVersion}&${filter}`
|
||||
);
|
||||
}
|
||||
|
||||
static buildAzureMonitorGetMetricNamesUrl(
|
||||
baseUrl: string,
|
||||
resourceGroup: string,
|
||||
metricDefinition: string,
|
||||
resourceName: string,
|
||||
apiVersion: string
|
||||
) {
|
||||
if ((metricDefinition.match(/\//g) || []).length > 1) {
|
||||
const rn = resourceName.split('/');
|
||||
const service = metricDefinition.substring(metricDefinition.lastIndexOf('/') + 1);
|
||||
const md = metricDefinition.substring(0, metricDefinition.lastIndexOf('/'));
|
||||
return (
|
||||
`${baseUrl}/${resourceGroup}/providers/${md}/${rn[0]}/${service}/${rn[1]}` +
|
||||
`/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
`${baseUrl}/${resourceGroup}/providers/${metricDefinition}/${resourceName}` +
|
||||
`/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}`
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource';
|
||||
import config from 'app/core/config';
|
||||
import { isVersionGtOrEq } from './version';
|
||||
|
||||
export class AzureMonitorConfigCtrl {
|
||||
static templateUrl = 'public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/config.html';
|
||||
current: any;
|
||||
azureLogAnalyticsDatasource: any;
|
||||
workspaces: any[];
|
||||
hasRequiredGrafanaVersion: boolean;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, backendSrv, $q) {
|
||||
this.hasRequiredGrafanaVersion = this.hasMinVersion();
|
||||
this.current.jsonData.cloudName = this.current.jsonData.cloudName || 'azuremonitor';
|
||||
this.current.jsonData.azureLogAnalyticsSameAs = this.current.jsonData.azureLogAnalyticsSameAs || false;
|
||||
|
||||
if (this.current.id) {
|
||||
this.current.url = '/api/datasources/proxy/' + this.current.id;
|
||||
this.azureLogAnalyticsDatasource = new AzureLogAnalyticsDatasource(this.current, backendSrv, null, $q);
|
||||
this.getWorkspaces();
|
||||
}
|
||||
}
|
||||
|
||||
hasMinVersion(): boolean {
|
||||
return isVersionGtOrEq(config.buildInfo.version, '5.2');
|
||||
}
|
||||
|
||||
showMinVersionWarning() {
|
||||
return !this.hasRequiredGrafanaVersion && this.current.secureJsonFields.logAnalyticsClientSecret;
|
||||
}
|
||||
|
||||
getWorkspaces() {
|
||||
if (!this.azureLogAnalyticsDatasource.isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.azureLogAnalyticsDatasource.getWorkspaces().then(workspaces => {
|
||||
this.workspaces = workspaces;
|
||||
if (this.workspaces.length > 0) {
|
||||
this.current.jsonData.logAnalyticsDefaultWorkspace =
|
||||
this.current.jsonData.logAnalyticsDefaultWorkspace || this.workspaces[0].value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
.min-width-10 {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.min-width-12 {
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.min-width-20 {
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
.gf-form-select-wrapper select.gf-form-input {
|
||||
height: 2.64rem;
|
||||
}
|
||||
|
||||
.gf-form-select-wrapper--caret-indent.gf-form-select-wrapper::after {
|
||||
right: 0.775rem
|
||||
}
|
||||
|
||||
.service-dropdown {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.aggregation-dropdown-wrapper {
|
||||
max-width: 29.1rem;
|
||||
}
|
||||
|
||||
.timegrainunit-dropdown-wrapper {
|
||||
width: 8rem;
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
import _ from 'lodash';
|
||||
import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource';
|
||||
import AppInsightsDatasource from './app_insights/app_insights_datasource';
|
||||
import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource';
|
||||
|
||||
export default class Datasource {
|
||||
id: number;
|
||||
name: string;
|
||||
azureMonitorDatasource: AzureMonitorDatasource;
|
||||
appInsightsDatasource: AppInsightsDatasource;
|
||||
azureLogAnalyticsDatasource: AzureLogAnalyticsDatasource;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(instanceSettings, private backendSrv, private templateSrv, private $q) {
|
||||
this.name = instanceSettings.name;
|
||||
this.id = instanceSettings.id;
|
||||
this.azureMonitorDatasource = new AzureMonitorDatasource(
|
||||
instanceSettings,
|
||||
this.backendSrv,
|
||||
this.templateSrv,
|
||||
this.$q
|
||||
);
|
||||
this.appInsightsDatasource = new AppInsightsDatasource(
|
||||
instanceSettings,
|
||||
this.backendSrv,
|
||||
this.templateSrv,
|
||||
this.$q
|
||||
);
|
||||
|
||||
this.azureLogAnalyticsDatasource = new AzureLogAnalyticsDatasource(
|
||||
instanceSettings,
|
||||
this.backendSrv,
|
||||
this.templateSrv,
|
||||
this.$q
|
||||
);
|
||||
}
|
||||
|
||||
query(options) {
|
||||
const promises: any[] = [];
|
||||
const azureMonitorOptions = _.cloneDeep(options);
|
||||
const appInsightsTargets = _.cloneDeep(options);
|
||||
const azureLogAnalyticsTargets = _.cloneDeep(options);
|
||||
|
||||
azureMonitorOptions.targets = _.filter(azureMonitorOptions.targets, ['queryType', 'Azure Monitor']);
|
||||
appInsightsTargets.targets = _.filter(appInsightsTargets.targets, ['queryType', 'Application Insights']);
|
||||
azureLogAnalyticsTargets.targets = _.filter(azureLogAnalyticsTargets.targets, ['queryType', 'Azure Log Analytics']);
|
||||
|
||||
if (azureMonitorOptions.targets.length > 0) {
|
||||
const amPromise = this.azureMonitorDatasource.query(azureMonitorOptions);
|
||||
if (amPromise) {
|
||||
promises.push(amPromise);
|
||||
}
|
||||
}
|
||||
|
||||
if (appInsightsTargets.targets.length > 0) {
|
||||
const aiPromise = this.appInsightsDatasource.query(appInsightsTargets);
|
||||
if (aiPromise) {
|
||||
promises.push(aiPromise);
|
||||
}
|
||||
}
|
||||
|
||||
if (azureLogAnalyticsTargets.targets.length > 0) {
|
||||
const alaPromise = this.azureLogAnalyticsDatasource.query(azureLogAnalyticsTargets);
|
||||
if (alaPromise) {
|
||||
promises.push(alaPromise);
|
||||
}
|
||||
}
|
||||
|
||||
if (promises.length === 0) {
|
||||
return this.$q.when({ data: [] });
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(results => {
|
||||
return { data: _.flatten(results) };
|
||||
});
|
||||
}
|
||||
|
||||
annotationQuery(options) {
|
||||
return this.azureLogAnalyticsDatasource.annotationQuery(options);
|
||||
}
|
||||
|
||||
metricFindQuery(query: string) {
|
||||
if (!query) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const aiResult = this.appInsightsDatasource.metricFindQuery(query);
|
||||
if (aiResult) {
|
||||
return aiResult;
|
||||
}
|
||||
|
||||
const amResult = this.azureMonitorDatasource.metricFindQuery(query);
|
||||
if (amResult) {
|
||||
return amResult;
|
||||
}
|
||||
|
||||
const alaResult = this.azureLogAnalyticsDatasource.metricFindQuery(query);
|
||||
if (alaResult) {
|
||||
return alaResult;
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
testDatasource() {
|
||||
const promises: any[] = [];
|
||||
|
||||
if (this.azureMonitorDatasource.isConfigured()) {
|
||||
promises.push(this.azureMonitorDatasource.testDatasource());
|
||||
}
|
||||
|
||||
if (this.appInsightsDatasource.isConfigured()) {
|
||||
promises.push(this.appInsightsDatasource.testDatasource());
|
||||
}
|
||||
|
||||
if (this.azureLogAnalyticsDatasource.isConfigured()) {
|
||||
promises.push(this.azureLogAnalyticsDatasource.testDatasource());
|
||||
}
|
||||
|
||||
if (promises.length === 0) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Nothing configured. At least one of the API's must be configured.`,
|
||||
title: 'Error',
|
||||
};
|
||||
}
|
||||
|
||||
return this.$q.all(promises).then(results => {
|
||||
let status = 'success';
|
||||
let message = '';
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i].status !== 'success') {
|
||||
status = results[i].status;
|
||||
}
|
||||
message += `${i + 1}. ${results[i].message} `;
|
||||
}
|
||||
|
||||
return {
|
||||
status: status,
|
||||
message: message,
|
||||
title: _.upperFirst(status),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* Azure Monitor REST API methods */
|
||||
getResourceGroups() {
|
||||
return this.azureMonitorDatasource.getResourceGroups();
|
||||
}
|
||||
|
||||
getMetricDefinitions(resourceGroup: string) {
|
||||
return this.azureMonitorDatasource.getMetricDefinitions(resourceGroup);
|
||||
}
|
||||
|
||||
getResourceNames(resourceGroup: string, metricDefinition: string) {
|
||||
return this.azureMonitorDatasource.getResourceNames(resourceGroup, metricDefinition);
|
||||
}
|
||||
|
||||
getMetricNames(resourceGroup: string, metricDefinition: string, resourceName: string) {
|
||||
return this.azureMonitorDatasource.getMetricNames(resourceGroup, metricDefinition, resourceName);
|
||||
}
|
||||
|
||||
getMetricMetadata(resourceGroup: string, metricDefinition: string, resourceName: string, metricName: string) {
|
||||
return this.azureMonitorDatasource.getMetricMetadata(resourceGroup, metricDefinition, resourceName, metricName);
|
||||
}
|
||||
|
||||
/* Application Insights API method */
|
||||
getAppInsightsMetricNames() {
|
||||
return this.appInsightsDatasource.getMetricNames();
|
||||
}
|
||||
|
||||
getAppInsightsMetricMetadata(metricName) {
|
||||
return this.appInsightsDatasource.getMetricMetadata(metricName);
|
||||
}
|
||||
|
||||
getAppInsightsColumns(refId) {
|
||||
return this.appInsightsDatasource.logAnalyticsColumns[refId];
|
||||
}
|
||||
|
||||
/*Azure Log Analytics */
|
||||
getAzureLogAnalyticsWorkspaces() {
|
||||
return this.azureLogAnalyticsDatasource.getWorkspaces();
|
||||
}
|
||||
}
|
@ -0,0 +1,312 @@
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
import QueryField from './query_field';
|
||||
// import debounce from './utils/debounce';
|
||||
// import {getNextCharacter} from './utils/dom';
|
||||
import debounce from 'app/features/explore/utils/debounce';
|
||||
import { getNextCharacter } from 'app/features/explore/utils/dom';
|
||||
|
||||
import { FUNCTIONS, KEYWORDS } from './kusto';
|
||||
// import '../sass/editor.base.scss';
|
||||
|
||||
|
||||
const TYPEAHEAD_DELAY = 500;
|
||||
|
||||
interface Suggestion {
|
||||
text: string;
|
||||
deleteBackwards?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface SuggestionGroup {
|
||||
label: string;
|
||||
items: Suggestion[];
|
||||
prefixMatch?: boolean;
|
||||
skipFilter?: boolean;
|
||||
}
|
||||
|
||||
const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
||||
const wrapText = text => ({ text });
|
||||
|
||||
export default class KustoQueryField extends QueryField {
|
||||
fields: any;
|
||||
events: any;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateMenu();
|
||||
}
|
||||
|
||||
onTypeahead = () => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.anchorNode) {
|
||||
const wrapperNode = selection.anchorNode.parentElement;
|
||||
if (wrapperNode === null) {
|
||||
return;
|
||||
}
|
||||
const editorNode = wrapperNode.closest('.slate-query-field');
|
||||
if (!editorNode || this.state.value.isBlurred) {
|
||||
// Not inside this editor
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM ranges
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = selection.anchorNode.textContent;
|
||||
if (text === null) {
|
||||
return;
|
||||
}
|
||||
const offset = range.startOffset;
|
||||
let prefix = cleanText(text.substr(0, offset));
|
||||
|
||||
// Model ranges
|
||||
const modelOffset = this.state.value.anchorOffset;
|
||||
const modelPrefix = this.state.value.anchorText.text.slice(0, modelOffset);
|
||||
|
||||
// Determine candidates by context
|
||||
let suggestionGroups: SuggestionGroup[] = [];
|
||||
const wrapperClasses = wrapperNode.classList;
|
||||
let typeaheadContext: string | null = null;
|
||||
|
||||
if (wrapperClasses.contains('function-context')) {
|
||||
typeaheadContext = 'context-function';
|
||||
if (this.fields) {
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
} else {
|
||||
this._fetchFields();
|
||||
return;
|
||||
}
|
||||
} else if (modelPrefix.match(/(facet\s$)/i)) {
|
||||
typeaheadContext = 'context-facet';
|
||||
if (this.fields) {
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
} else {
|
||||
this._fetchFields();
|
||||
return;
|
||||
}
|
||||
} else if (modelPrefix.match(/(,\s*$)/)) {
|
||||
typeaheadContext = 'context-multiple-fields';
|
||||
if (this.fields) {
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
} else {
|
||||
this._fetchFields();
|
||||
return;
|
||||
}
|
||||
} else if (modelPrefix.match(/(from\s$)/i)) {
|
||||
typeaheadContext = 'context-from';
|
||||
if (this.events) {
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
} else {
|
||||
this._fetchEvents();
|
||||
return;
|
||||
}
|
||||
} else if (modelPrefix.match(/(^select\s\w*$)/i)) {
|
||||
typeaheadContext = 'context-select';
|
||||
if (this.fields) {
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
} else {
|
||||
this._fetchFields();
|
||||
return;
|
||||
}
|
||||
} else if (modelPrefix.match(/from\s\S+\s\w*$/i)) {
|
||||
prefix = '';
|
||||
typeaheadContext = 'context-since';
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
// } else if (modelPrefix.match(/\d+\s\w*$/)) {
|
||||
// typeaheadContext = 'context-number';
|
||||
// suggestionGroups = this._getAfterNumberSuggestions();
|
||||
} else if (modelPrefix.match(/ago\b/i) || modelPrefix.match(/facet\b/i) || modelPrefix.match(/\$__timefilter\b/i)) {
|
||||
typeaheadContext = 'context-timeseries';
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
} else if (prefix && !wrapperClasses.contains('argument')) {
|
||||
typeaheadContext = 'context-builtin';
|
||||
suggestionGroups = this._getKeywordSuggestions();
|
||||
} else if (Plain.serialize(this.state.value) === '') {
|
||||
typeaheadContext = 'context-new';
|
||||
suggestionGroups = this._getInitialSuggestions();
|
||||
}
|
||||
|
||||
let results = 0;
|
||||
prefix = prefix.toLowerCase();
|
||||
const filteredSuggestions = suggestionGroups.map(group => {
|
||||
if (group.items && prefix && !group.skipFilter) {
|
||||
group.items = group.items.filter(c => c.text.length >= prefix.length);
|
||||
if (group.prefixMatch) {
|
||||
group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) === 0);
|
||||
} else {
|
||||
group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) > -1);
|
||||
}
|
||||
}
|
||||
results += group.items.length;
|
||||
return group;
|
||||
})
|
||||
.filter(group => group.items.length > 0);
|
||||
|
||||
// console.log('onTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
|
||||
|
||||
this.setState({
|
||||
typeaheadPrefix: prefix,
|
||||
typeaheadContext,
|
||||
typeaheadText: text,
|
||||
suggestions: results > 0 ? filteredSuggestions : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applyTypeahead(change, suggestion) {
|
||||
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
|
||||
let suggestionText = suggestion.text || suggestion;
|
||||
const move = 0;
|
||||
|
||||
// Modify suggestion based on context
|
||||
|
||||
const nextChar = getNextCharacter();
|
||||
if (suggestion.type === 'function') {
|
||||
if (!nextChar || nextChar !== '(') {
|
||||
suggestionText += '(';
|
||||
}
|
||||
} else if (typeaheadContext === 'context-function') {
|
||||
if (!nextChar || nextChar !== ')') {
|
||||
suggestionText += ')';
|
||||
}
|
||||
} else {
|
||||
if (!nextChar || nextChar !== ' ') {
|
||||
suggestionText += ' ';
|
||||
}
|
||||
}
|
||||
|
||||
this.resetTypeahead();
|
||||
|
||||
// Remove the current, incomplete text and replace it with the selected suggestion
|
||||
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
|
||||
const text = cleanText(typeaheadText);
|
||||
const suffixLength = text.length - typeaheadPrefix.length;
|
||||
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
||||
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
|
||||
const forward = midWord ? suffixLength + offset : 0;
|
||||
|
||||
return change
|
||||
.deleteBackward(backward)
|
||||
.deleteForward(forward)
|
||||
.insertText(suggestionText)
|
||||
.move(move)
|
||||
.focus();
|
||||
}
|
||||
|
||||
// private _getFieldsSuggestions(): SuggestionGroup[] {
|
||||
// return [
|
||||
// {
|
||||
// prefixMatch: true,
|
||||
// label: 'Fields',
|
||||
// items: this.fields.map(wrapText)
|
||||
// },
|
||||
// {
|
||||
// prefixMatch: true,
|
||||
// label: 'Variables',
|
||||
// items: this.props.templateVariables.map(wrapText)
|
||||
// }
|
||||
// ];
|
||||
// }
|
||||
|
||||
// private _getAfterFromSuggestions(): SuggestionGroup[] {
|
||||
// return [
|
||||
// {
|
||||
// skipFilter: true,
|
||||
// label: 'Events',
|
||||
// items: this.events.map(wrapText)
|
||||
// },
|
||||
// {
|
||||
// prefixMatch: true,
|
||||
// label: 'Variables',
|
||||
// items: this.props.templateVariables
|
||||
// .map(wrapText)
|
||||
// .map(suggestion => {
|
||||
// suggestion.deleteBackwards = 0;
|
||||
// return suggestion;
|
||||
// })
|
||||
// }
|
||||
// ];
|
||||
// }
|
||||
|
||||
// private _getAfterSelectSuggestions(): SuggestionGroup[] {
|
||||
// return [
|
||||
// {
|
||||
// prefixMatch: true,
|
||||
// label: 'Fields',
|
||||
// items: this.fields.map(wrapText)
|
||||
// },
|
||||
// {
|
||||
// prefixMatch: true,
|
||||
// label: 'Functions',
|
||||
// items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; })
|
||||
// },
|
||||
// {
|
||||
// prefixMatch: true,
|
||||
// label: 'Variables',
|
||||
// items: this.props.templateVariables.map(wrapText)
|
||||
// }
|
||||
// ];
|
||||
// }
|
||||
|
||||
private _getKeywordSuggestions(): SuggestionGroup[] {
|
||||
return [
|
||||
{
|
||||
prefixMatch: true,
|
||||
label: 'Keywords',
|
||||
items: KEYWORDS.map(wrapText)
|
||||
},
|
||||
{
|
||||
prefixMatch: true,
|
||||
label: 'Functions',
|
||||
items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; })
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private _getInitialSuggestions(): SuggestionGroup[] {
|
||||
// TODO: return datbase tables as an initial suggestion
|
||||
return [
|
||||
{
|
||||
prefixMatch: true,
|
||||
label: 'Keywords',
|
||||
items: KEYWORDS.map(wrapText)
|
||||
},
|
||||
{
|
||||
prefixMatch: true,
|
||||
label: 'Functions',
|
||||
items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; })
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private async _fetchEvents() {
|
||||
// const query = 'events';
|
||||
// const result = await this.request(query);
|
||||
|
||||
// if (result === undefined) {
|
||||
// this.events = [];
|
||||
// } else {
|
||||
// this.events = result;
|
||||
// }
|
||||
// setTimeout(this.onTypeahead, 0);
|
||||
|
||||
//Stub
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
private async _fetchFields() {
|
||||
// const query = 'fields';
|
||||
// const result = await this.request(query);
|
||||
|
||||
// this.fields = result || [];
|
||||
|
||||
// setTimeout(this.onTypeahead, 0);
|
||||
// Stub
|
||||
this.fields = [];
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import KustoQueryField from './KustoQueryField';
|
||||
import Kusto from './kusto';
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
class Editor extends Component<any, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
edited: false,
|
||||
query: props.query || '',
|
||||
};
|
||||
}
|
||||
|
||||
onChangeQuery = value => {
|
||||
const { index, change } = this.props;
|
||||
const { query } = this.state;
|
||||
const edited = query !== value;
|
||||
this.setState({ edited, query: value });
|
||||
if (change) {
|
||||
change(value, index);
|
||||
}
|
||||
};
|
||||
|
||||
onPressEnter = () => {
|
||||
const { execute } = this.props;
|
||||
if (execute) {
|
||||
execute();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { request, variables } = this.props;
|
||||
const { edited, query } = this.state;
|
||||
|
||||
return (
|
||||
<div className="gf-form-input" style={{ height: 'auto' }}>
|
||||
<KustoQueryField
|
||||
initialQuery={edited ? null : query}
|
||||
onPressEnter={this.onPressEnter}
|
||||
onQueryChange={this.onChangeQuery}
|
||||
prismLanguage="kusto"
|
||||
prismDefinition={Kusto}
|
||||
placeholder="Enter a query"
|
||||
request={request}
|
||||
templateVariables={variables}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.directive('kustoEditor', [
|
||||
'reactDirective',
|
||||
reactDirective => {
|
||||
return reactDirective(Editor, ['change', 'database', 'execute', 'query', 'request', 'variables']);
|
||||
},
|
||||
]);
|
@ -0,0 +1,114 @@
|
||||
export const FUNCTIONS = [
|
||||
{ text: 'countof', display: 'countof()', hint: '' },
|
||||
{ text: 'bin', display: 'bin()', hint: '' },
|
||||
{ text: 'extentid', display: 'extentid()', hint: '' },
|
||||
{ text: 'extract', display: 'extract()', hint: '' },
|
||||
{ text: 'extractjson', display: 'extractjson()', hint: '' },
|
||||
{ text: 'floor', display: 'floor()', hint: '' },
|
||||
{ text: 'iif', display: 'iif()', hint: '' },
|
||||
{ text: 'isnull', display: 'isnull()', hint: '' },
|
||||
{ text: 'isnotnull', display: 'isnotnull()', hint: '' },
|
||||
{ text: 'notnull', display: 'notnull()', hint: '' },
|
||||
{ text: 'isempty', display: 'isempty()', hint: '' },
|
||||
{ text: 'isnotempty', display: 'isnotempty()', hint: '' },
|
||||
{ text: 'notempty', display: 'notempty()', hint: '' },
|
||||
{ text: 'now', display: 'now()', hint: '' },
|
||||
{ text: 're2', display: 're2()', hint: '' },
|
||||
{ text: 'strcat', display: 'strcat()', hint: '' },
|
||||
{ text: 'strlen', display: 'strlen()', hint: '' },
|
||||
{ text: 'toupper', display: 'toupper()', hint: '' },
|
||||
{ text: 'tostring', display: 'tostring()', hint: '' },
|
||||
{ text: 'count', display: 'count()', hint: '' },
|
||||
{ text: 'cnt', display: 'cnt()', hint: '' },
|
||||
{ text: 'sum', display: 'sum()', hint: '' },
|
||||
{ text: 'min', display: 'min()', hint: '' },
|
||||
{ text: 'max', display: 'max()', hint: '' },
|
||||
{ text: 'avg', display: 'avg()', hint: '' },
|
||||
{
|
||||
text: '$__timeFilter',
|
||||
display: '$__timeFilter()',
|
||||
hint: 'Macro that uses the selected timerange in Grafana to filter the query.',
|
||||
},
|
||||
{
|
||||
text: '$__escapeMulti',
|
||||
display: '$__escapeMulti()',
|
||||
hint: 'Macro to escape multi-value template variables that contain illegal characters.',
|
||||
},
|
||||
{ text: '$__contains', display: '$__contains()', hint: 'Macro for multi-value template variables.' },
|
||||
];
|
||||
|
||||
export const KEYWORDS = [
|
||||
'by',
|
||||
'on',
|
||||
'contains',
|
||||
'notcontains',
|
||||
'containscs',
|
||||
'notcontainscs',
|
||||
'startswith',
|
||||
'has',
|
||||
'matches',
|
||||
'regex',
|
||||
'true',
|
||||
'false',
|
||||
'and',
|
||||
'or',
|
||||
'typeof',
|
||||
'int',
|
||||
'string',
|
||||
'date',
|
||||
'datetime',
|
||||
'time',
|
||||
'long',
|
||||
'real',
|
||||
'boolean',
|
||||
'bool',
|
||||
// add some more keywords
|
||||
'where',
|
||||
'order',
|
||||
];
|
||||
|
||||
// Kusto operators
|
||||
// export const OPERATORS = ['+', '-', '*', '/', '>', '<', '==', '<>', '<=', '>=', '~', '!~'];
|
||||
|
||||
export const DURATION = ['SECONDS', 'MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'YEARS'];
|
||||
|
||||
const tokenizer = {
|
||||
comment: {
|
||||
pattern: /(^|[^\\:])\/\/.*/,
|
||||
lookbehind: true,
|
||||
greedy: true,
|
||||
},
|
||||
'function-context': {
|
||||
pattern: /[a-z0-9_]+\([^)]*\)?/i,
|
||||
inside: {},
|
||||
},
|
||||
duration: {
|
||||
pattern: new RegExp(`${DURATION.join('?|')}?`, 'i'),
|
||||
alias: 'number',
|
||||
},
|
||||
builtin: new RegExp(`\\b(?:${FUNCTIONS.map(f => f.text).join('|')})(?=\\s*\\()`, 'i'),
|
||||
string: {
|
||||
pattern: /(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,
|
||||
greedy: true,
|
||||
},
|
||||
keyword: new RegExp(`\\b(?:${KEYWORDS.join('|')}|\\*)\\b`, 'i'),
|
||||
boolean: /\b(?:true|false)\b/,
|
||||
number: /\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,
|
||||
operator: /-|\+|\*|\/|>|<|==|<=?|>=?|<>|!~|~|=|\|/,
|
||||
punctuation: /[{};(),.:]/,
|
||||
variable: /(\[\[(.+?)\]\])|(\$(.+?))\b/,
|
||||
};
|
||||
|
||||
tokenizer['function-context'].inside = {
|
||||
argument: {
|
||||
pattern: /[a-z0-9_]+(?=:)/i,
|
||||
alias: 'symbol',
|
||||
},
|
||||
duration: tokenizer.duration,
|
||||
number: tokenizer.number,
|
||||
builtin: tokenizer.builtin,
|
||||
string: tokenizer.string,
|
||||
variable: tokenizer.variable,
|
||||
};
|
||||
|
||||
export default tokenizer;
|
@ -0,0 +1,335 @@
|
||||
import PluginPrism from './slate-plugins/prism';
|
||||
// import PluginPrism from 'slate-prism';
|
||||
// import Prism from 'prismjs';
|
||||
|
||||
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
|
||||
import ClearPlugin from 'app/features/explore/slate-plugins/clear';
|
||||
// Custom plugins (new line on Enter and run on Shift+Enter)
|
||||
import NewlinePlugin from './slate-plugins/newline';
|
||||
import RunnerPlugin from './slate-plugins/runner';
|
||||
|
||||
import Typeahead from './typeahead';
|
||||
|
||||
import { Block, Document, Text, Value } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
import ReactDOM from 'react-dom';
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
function flattenSuggestions(s) {
|
||||
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
||||
}
|
||||
|
||||
export const makeFragment = text => {
|
||||
const lines = text.split('\n').map(line =>
|
||||
Block.create({
|
||||
type: 'paragraph',
|
||||
nodes: [Text.create(line)],
|
||||
})
|
||||
);
|
||||
|
||||
const fragment = Document.create({
|
||||
nodes: lines,
|
||||
});
|
||||
return fragment;
|
||||
};
|
||||
|
||||
export const getInitialValue = query => Value.create({ document: makeFragment(query) });
|
||||
|
||||
class Portal extends React.Component<any, any> {
|
||||
node: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { index = 0, prefix = 'query' } = props;
|
||||
this.node = document.createElement('div');
|
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
||||
document.body.appendChild(this.node);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.body.removeChild(this.node);
|
||||
}
|
||||
|
||||
render() {
|
||||
return ReactDOM.createPortal(this.props.children, this.node);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryField extends React.Component<any, any> {
|
||||
menuEl: any;
|
||||
plugins: any;
|
||||
resetTimer: any;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const { prismDefinition = {}, prismLanguage = 'kusto' } = props;
|
||||
|
||||
this.plugins = [
|
||||
BracesPlugin(),
|
||||
ClearPlugin(),
|
||||
RunnerPlugin({ handler: props.onPressEnter }),
|
||||
NewlinePlugin(),
|
||||
PluginPrism({ definition: prismDefinition, language: prismLanguage }),
|
||||
];
|
||||
|
||||
this.state = {
|
||||
labelKeys: {},
|
||||
labelValues: {},
|
||||
suggestions: [],
|
||||
typeaheadIndex: 0,
|
||||
typeaheadPrefix: '',
|
||||
value: getInitialValue(props.initialQuery || ''),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateMenu();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.resetTimer);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateMenu();
|
||||
}
|
||||
|
||||
onChange = ({ value }) => {
|
||||
const changed = value.document !== this.state.value.document;
|
||||
this.setState({ value }, () => {
|
||||
if (changed) {
|
||||
this.onChangeQuery();
|
||||
}
|
||||
});
|
||||
|
||||
window.requestAnimationFrame(this.onTypeahead);
|
||||
};
|
||||
|
||||
request = (url?) => {
|
||||
if (this.props.request) {
|
||||
return this.props.request(url);
|
||||
}
|
||||
return fetch(url);
|
||||
};
|
||||
|
||||
onChangeQuery = () => {
|
||||
// Send text change to parent
|
||||
const { onQueryChange } = this.props;
|
||||
if (onQueryChange) {
|
||||
onQueryChange(Plain.serialize(this.state.value));
|
||||
}
|
||||
};
|
||||
|
||||
onKeyDown = (event, change) => {
|
||||
const { typeaheadIndex, suggestions } = this.state;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
if (this.menuEl) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.resetTypeahead();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ' ': {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
this.onTypeahead();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Tab':
|
||||
case 'Enter': {
|
||||
if (this.menuEl) {
|
||||
// Dont blur input
|
||||
event.preventDefault();
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the currently selected suggestion
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
const selected = Math.abs(typeaheadIndex);
|
||||
const selectedIndex = selected % flattenedSuggestions.length || 0;
|
||||
const suggestion = flattenedSuggestions[selectedIndex];
|
||||
|
||||
this.applyTypeahead(change, suggestion);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
if (this.menuEl) {
|
||||
// Select next suggestion
|
||||
event.preventDefault();
|
||||
this.setState({ typeaheadIndex: typeaheadIndex + 1 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
if (this.menuEl) {
|
||||
// Select previous suggestion
|
||||
event.preventDefault();
|
||||
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
onTypeahead = (change?, item?) => {
|
||||
return change || this.state.value.change();
|
||||
};
|
||||
|
||||
applyTypeahead(change?, suggestion?): { value: object } {
|
||||
return { value: {} };
|
||||
}
|
||||
|
||||
resetTypeahead = () => {
|
||||
this.setState({
|
||||
suggestions: [],
|
||||
typeaheadIndex: 0,
|
||||
typeaheadPrefix: '',
|
||||
typeaheadContext: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
const { onBlur } = this.props;
|
||||
// If we dont wait here, menu clicks wont work because the menu
|
||||
// will be gone.
|
||||
this.resetTimer = setTimeout(this.resetTypeahead, 100);
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
const { onFocus } = this.props;
|
||||
if (onFocus) {
|
||||
onFocus();
|
||||
}
|
||||
};
|
||||
|
||||
onClickItem = item => {
|
||||
const { suggestions } = this.state;
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the currently selected suggestion
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
const suggestion = _.find(
|
||||
flattenedSuggestions,
|
||||
suggestion => suggestion.display === item || suggestion.text === item
|
||||
);
|
||||
|
||||
// Manually triggering change
|
||||
const change = this.applyTypeahead(this.state.value.change(), suggestion);
|
||||
this.onChange(change);
|
||||
};
|
||||
|
||||
updateMenu = () => {
|
||||
const { suggestions } = this.state;
|
||||
const menu = this.menuEl;
|
||||
const selection = window.getSelection();
|
||||
const node = selection.anchorNode;
|
||||
|
||||
// No menu, nothing to do
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No suggestions or blur, remove menu
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||
if (!hasSuggesstions) {
|
||||
menu.removeAttribute('style');
|
||||
return;
|
||||
}
|
||||
|
||||
// Align menu overlay to editor node
|
||||
if (node && node.parentElement) {
|
||||
// Read from DOM
|
||||
const rect = node.parentElement.getBoundingClientRect();
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
// Write DOM
|
||||
requestAnimationFrame(() => {
|
||||
menu.style.opacity = 1;
|
||||
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
|
||||
menu.style.left = `${rect.left + scrollX - 2}px`;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
menuRef = el => {
|
||||
this.menuEl = el;
|
||||
};
|
||||
|
||||
renderMenu = () => {
|
||||
const { portalPrefix } = this.props;
|
||||
const { suggestions } = this.state;
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||
if (!hasSuggesstions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard selectedIndex to be within the length of the suggestions
|
||||
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
||||
const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
|
||||
i => (typeof i === 'object' ? i.text : i)
|
||||
);
|
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return (
|
||||
<Portal prefix={portalPrefix}>
|
||||
<Typeahead
|
||||
menuRef={this.menuRef}
|
||||
selectedItems={selectedKeys}
|
||||
onClickItem={this.onClickItem}
|
||||
groupedItems={suggestions}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="slate-query-field">
|
||||
{this.renderMenu()}
|
||||
<Editor
|
||||
autoCorrect={false}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
onFocus={this.handleFocus}
|
||||
placeholder={this.props.placeholder}
|
||||
plugins={this.plugins}
|
||||
spellCheck={false}
|
||||
value={this.state.value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default QueryField;
|
@ -0,0 +1,35 @@
|
||||
function getIndent(text) {
|
||||
let offset = text.length - text.trimLeft().length;
|
||||
if (offset) {
|
||||
let indent = text[0];
|
||||
while (--offset) {
|
||||
indent += text[0];
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function NewlinePlugin() {
|
||||
return {
|
||||
onKeyDown(event, change) {
|
||||
const { value } = change;
|
||||
if (!value.isCollapsed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const { startBlock } = value;
|
||||
const currentLineText = startBlock.text;
|
||||
const indent = getIndent(currentLineText);
|
||||
|
||||
return change
|
||||
.splitBlock()
|
||||
.insertText(indent)
|
||||
.focus();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import Prism from 'prismjs';
|
||||
|
||||
const TOKEN_MARK = 'prism-token';
|
||||
|
||||
export function setPrismTokens(language, field, values, alias = 'variable') {
|
||||
Prism.languages[language][field] = {
|
||||
alias,
|
||||
pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Code-highlighting plugin based on Prism and
|
||||
* https://github.com/ianstormtaylor/slate/blob/master/examples/code-highlighting/index.js
|
||||
*
|
||||
* (Adapted to handle nested grammar definitions.)
|
||||
*/
|
||||
|
||||
export default function PrismPlugin({ definition, language }) {
|
||||
if (definition) {
|
||||
// Don't override exising modified definitions
|
||||
Prism.languages[language] = Prism.languages[language] || definition;
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Render a Slate mark with appropiate CSS class names
|
||||
*
|
||||
* @param {Object} props
|
||||
* @return {Element}
|
||||
*/
|
||||
|
||||
renderMark(props) {
|
||||
const { children, mark } = props;
|
||||
// Only apply spans to marks identified by this plugin
|
||||
if (mark.type !== TOKEN_MARK) {
|
||||
return undefined;
|
||||
}
|
||||
const className = `token ${mark.data.get('types')}`;
|
||||
return <span className={className}>{children}</span>;
|
||||
},
|
||||
|
||||
/**
|
||||
* Decorate code blocks with Prism.js highlighting.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @return {Array}
|
||||
*/
|
||||
|
||||
decorateNode(node) {
|
||||
if (node.type !== 'paragraph') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const texts = node.getTexts().toArray();
|
||||
const tstring = texts.map(t => t.text).join('\n');
|
||||
const grammar = Prism.languages[language];
|
||||
const tokens = Prism.tokenize(tstring, grammar);
|
||||
const decorations: any[] = [];
|
||||
let startText = texts.shift();
|
||||
let endText = startText;
|
||||
let startOffset = 0;
|
||||
let endOffset = 0;
|
||||
let start = 0;
|
||||
|
||||
function processToken(token, acc?) {
|
||||
// Accumulate token types down the tree
|
||||
const types = `${acc || ''} ${token.type || ''} ${token.alias || ''}`;
|
||||
|
||||
// Add mark for token node
|
||||
if (typeof token === 'string' || typeof token.content === 'string') {
|
||||
startText = endText;
|
||||
startOffset = endOffset;
|
||||
|
||||
const content = typeof token === 'string' ? token : token.content;
|
||||
const newlines = content.split('\n').length - 1;
|
||||
const length = content.length - newlines;
|
||||
const end = start + length;
|
||||
|
||||
let available = startText.text.length - startOffset;
|
||||
let remaining = length;
|
||||
|
||||
endOffset = startOffset + remaining;
|
||||
|
||||
while (available < remaining) {
|
||||
endText = texts.shift();
|
||||
remaining = length - available;
|
||||
available = endText.text.length;
|
||||
endOffset = remaining;
|
||||
}
|
||||
|
||||
// Inject marks from up the tree (acc) as well
|
||||
if (typeof token !== 'string' || acc) {
|
||||
const range = {
|
||||
anchorKey: startText.key,
|
||||
anchorOffset: startOffset,
|
||||
focusKey: endText.key,
|
||||
focusOffset: endOffset,
|
||||
marks: [{ type: TOKEN_MARK, data: { types } }],
|
||||
};
|
||||
|
||||
decorations.push(range);
|
||||
}
|
||||
|
||||
start = end;
|
||||
} else if (token.content && token.content.length) {
|
||||
// Tokens can be nested
|
||||
for (const subToken of token.content) {
|
||||
processToken(subToken, types);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process top-level tokens
|
||||
for (const token of tokens) {
|
||||
processToken(token);
|
||||
}
|
||||
|
||||
return decorations;
|
||||
},
|
||||
};
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
export default function RunnerPlugin({ handler }) {
|
||||
return {
|
||||
onKeyDown(event) {
|
||||
// Handle enter
|
||||
if (handler && event.key === 'Enter' && event.shiftKey) {
|
||||
// Submit on Enter
|
||||
event.preventDefault();
|
||||
handler(event);
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
|
||||
function scrollIntoView(el) {
|
||||
if (!el || !el.offsetParent) {
|
||||
return;
|
||||
}
|
||||
const container = el.offsetParent;
|
||||
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
|
||||
container.scrollTop = el.offsetTop - container.offsetTop;
|
||||
}
|
||||
}
|
||||
|
||||
class TypeaheadItem extends React.PureComponent<any, any> {
|
||||
el: any;
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.isSelected && !prevProps.isSelected) {
|
||||
scrollIntoView(this.el);
|
||||
}
|
||||
}
|
||||
|
||||
getRef = el => {
|
||||
this.el = el;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hint, isSelected, label, onClickItem } = this.props;
|
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
|
||||
const onClick = () => onClickItem(label);
|
||||
return (
|
||||
<li ref={this.getRef} className={className} onClick={onClick}>
|
||||
{label}
|
||||
{hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TypeaheadGroup extends React.PureComponent<any, any> {
|
||||
render() {
|
||||
const { items, label, selected, onClickItem } = this.props;
|
||||
return (
|
||||
<li className="typeahead-group">
|
||||
<div className="typeahead-group__title">{label}</div>
|
||||
<ul className="typeahead-group__list">
|
||||
{items.map(item => {
|
||||
const text = typeof item === 'object' ? item.text : item;
|
||||
const label = typeof item === 'object' ? item.display || item.text : item;
|
||||
return (
|
||||
<TypeaheadItem
|
||||
key={text}
|
||||
onClickItem={onClickItem}
|
||||
isSelected={selected.indexOf(text) > -1}
|
||||
hint={item.hint}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Typeahead extends React.PureComponent<any, any> {
|
||||
render() {
|
||||
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
|
||||
return (
|
||||
<ul className="typeahead" ref={menuRef}>
|
||||
{groupedItems.map(g => (
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Typeahead;
|
After Width: | Height: | Size: 253 KiB |
After Width: | Height: | Size: 166 KiB |
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 251 KiB |
After Width: | Height: | Size: 103 KiB |
After Width: | Height: | Size: 60 KiB |
After Width: | Height: | Size: 9.4 KiB |
@ -0,0 +1,166 @@
|
||||
import LogAnalyticsQuerystringBuilder from './querystring_builder';
|
||||
import moment from 'moment';
|
||||
|
||||
describe('LogAnalyticsDatasource', () => {
|
||||
let builder: LogAnalyticsQuerystringBuilder;
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new LogAnalyticsQuerystringBuilder(
|
||||
'query=Tablename | where $__timeFilter()',
|
||||
{
|
||||
interval: '5m',
|
||||
range: {
|
||||
from: moment().subtract(24, 'hours'),
|
||||
to: moment(),
|
||||
},
|
||||
rangeRaw: {
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
},
|
||||
},
|
||||
'TimeGenerated'
|
||||
);
|
||||
});
|
||||
|
||||
describe('when $__timeFilter has no column parameter', () => {
|
||||
it('should generate a time filter condition with TimeGenerated as the datetime field', () => {
|
||||
const query = builder.generate().uriString;
|
||||
|
||||
expect(query).toContain('where%20TimeGenerated%20%3E%3D%20datetime(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when $__timeFilter has a column parameter', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = 'query=Tablename | where $__timeFilter(myTime)';
|
||||
});
|
||||
|
||||
it('should generate a time filter condition with myTime as the datetime field', () => {
|
||||
const query = builder.generate().uriString;
|
||||
|
||||
expect(query).toContain('where%20myTime%20%3E%3D%20datetime(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when $__contains and multi template variable has custom All value', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = 'query=Tablename | where $__contains(col, all)';
|
||||
});
|
||||
|
||||
it('should generate a where..in clause', () => {
|
||||
const query = builder.generate().rawQuery;
|
||||
|
||||
expect(query).toContain(`where 1 == 1`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when $__contains and multi template variable has one selected value', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = `query=Tablename | where $__contains(col, 'val1')`;
|
||||
});
|
||||
|
||||
it('should generate a where..in clause', () => {
|
||||
const query = builder.generate().rawQuery;
|
||||
|
||||
expect(query).toContain(`where col in ('val1')`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when $__contains and multi template variable has multiple selected values', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = `query=Tablename | where $__contains(col, 'val1','val2')`;
|
||||
});
|
||||
|
||||
it('should generate a where..in clause', () => {
|
||||
const query = builder.generate().rawQuery;
|
||||
|
||||
expect(query).toContain(`where col in ('val1','val2')`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when $__interval is in the query', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = 'query=Tablename | summarize count() by Category, bin(TimeGenerated, $__interval)';
|
||||
});
|
||||
|
||||
it('should replace $__interval with the inbuilt interval option', () => {
|
||||
const query = builder.generate().uriString;
|
||||
|
||||
expect(query).toContain('bin(TimeGenerated%2C%205m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using $__from and $__to is in the query and range is until now', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = 'query=Tablename | where myTime >= $__from and myTime <= $__to';
|
||||
});
|
||||
|
||||
it('should replace $__from and $__to with a datetime and the now() function', () => {
|
||||
const query = builder.generate().uriString;
|
||||
|
||||
expect(query).toContain('where%20myTime%20%3E%3D%20datetime(');
|
||||
expect(query).toContain('myTime%20%3C%3D%20now()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using $__from and $__to is in the query and range is a specific interval', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = 'query=Tablename | where myTime >= $__from and myTime <= $__to';
|
||||
builder.options.range.to = moment().subtract(1, 'hour');
|
||||
builder.options.rangeRaw.to = 'now-1h';
|
||||
});
|
||||
|
||||
it('should replace $__from and $__to with datetimes', () => {
|
||||
const query = builder.generate().uriString;
|
||||
|
||||
expect(query).toContain('where%20myTime%20%3E%3D%20datetime(');
|
||||
expect(query).toContain('myTime%20%3C%3D%20datetime(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using $__escape and multi template variable has one selected value', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = `$__escapeMulti('\\grafana-vm\Network(eth0)\Total Bytes Received')`;
|
||||
});
|
||||
|
||||
it('should replace $__escape(val) with KQL style escaped string', () => {
|
||||
const query = builder.generate().uriString;
|
||||
expect(query).toContain(`%40'%5Cgrafana-vmNetwork(eth0)Total%20Bytes%20Received'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using $__escape and multi template variable has multiple selected values', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = `CounterPath in ($__escapeMulti('\\grafana-vm\Network(eth0)\Total','\\grafana-vm\Network(eth0)\Total'))`;
|
||||
});
|
||||
|
||||
it('should replace $__escape(val) with multiple KQL style escaped string', () => {
|
||||
const query = builder.generate().uriString;
|
||||
expect(query).toContain(
|
||||
`CounterPath%20in%20(%40'%5Cgrafana-vmNetwork(eth0)Total'%2C%20%40'%5Cgrafana-vmNetwork(eth0)Total')`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using $__escape and multi template variable has one selected value that contains comma', () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = `$__escapeMulti('\\grafana-vm,\Network(eth0)\Total Bytes Received')`;
|
||||
});
|
||||
|
||||
it('should replace $__escape(val) with KQL style escaped string', () => {
|
||||
const query = builder.generate().uriString;
|
||||
expect(query).toContain(`%40'%5Cgrafana-vm%2CNetwork(eth0)Total%20Bytes%20Received'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when using $__escape and multi template variable value is not wrapped in single '`, () => {
|
||||
beforeEach(() => {
|
||||
builder.rawQueryString = `$__escapeMulti(\\grafana-vm,\Network(eth0)\Total Bytes Received)`;
|
||||
});
|
||||
|
||||
it('should not replace macro', () => {
|
||||
const query = builder.generate().uriString;
|
||||
expect(query).toContain(`%24__escapeMulti(%5Cgrafana-vm%2CNetwork(eth0)Total%20Bytes%20Received)`);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,84 @@
|
||||
import moment from 'moment';
|
||||
|
||||
export default class LogAnalyticsQuerystringBuilder {
|
||||
constructor(public rawQueryString, public options, public defaultTimeField) {}
|
||||
|
||||
generate() {
|
||||
let queryString = this.rawQueryString;
|
||||
const macroRegexp = /\$__([_a-zA-Z0-9]+)\(([^\)]*)\)/gi;
|
||||
queryString = queryString.replace(macroRegexp, (match, p1, p2) => {
|
||||
if (p1 === 'contains') {
|
||||
return this.getMultiContains(p2);
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
queryString = queryString.replace(/\$__escapeMulti\(('[^]*')\)/gi, (match, p1) => this.escape(p1));
|
||||
|
||||
if (this.options) {
|
||||
queryString = queryString.replace(macroRegexp, (match, p1, p2) => {
|
||||
if (p1 === 'timeFilter') {
|
||||
return this.getTimeFilter(p2, this.options);
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
queryString = queryString.replace(/\$__interval/gi, this.options.interval);
|
||||
queryString = queryString.replace(/\$__from/gi, this.getFrom(this.options));
|
||||
queryString = queryString.replace(/\$__to/gi, this.getUntil(this.options));
|
||||
}
|
||||
const rawQuery = queryString;
|
||||
queryString = encodeURIComponent(queryString);
|
||||
const uriString = `query=${queryString}`;
|
||||
|
||||
return { uriString, rawQuery };
|
||||
}
|
||||
|
||||
getFrom(options) {
|
||||
const from = options.range.from;
|
||||
return `datetime(${moment(from)
|
||||
.startOf('minute')
|
||||
.toISOString()})`;
|
||||
}
|
||||
|
||||
getUntil(options) {
|
||||
if (options.rangeRaw.to === 'now') {
|
||||
return 'now()';
|
||||
} else {
|
||||
const until = options.range.to;
|
||||
return `datetime(${moment(until)
|
||||
.startOf('minute')
|
||||
.toISOString()})`;
|
||||
}
|
||||
}
|
||||
|
||||
getTimeFilter(timeFieldArg, options) {
|
||||
const timeField = timeFieldArg || this.defaultTimeField;
|
||||
if (options.rangeRaw.to === 'now') {
|
||||
return `${timeField} >= ${this.getFrom(options)}`;
|
||||
} else {
|
||||
return `${timeField} >= ${this.getFrom(options)} and ${timeField} <= ${this.getUntil(options)}`;
|
||||
}
|
||||
}
|
||||
|
||||
getMultiContains(inputs: string) {
|
||||
const firstCommaIndex = inputs.indexOf(',');
|
||||
const field = inputs.substring(0, firstCommaIndex);
|
||||
const templateVar = inputs.substring(inputs.indexOf(',') + 1);
|
||||
|
||||
if (templateVar && templateVar.toLowerCase().trim() === 'all') {
|
||||
return '1 == 1';
|
||||
}
|
||||
|
||||
return `${field.trim()} in (${templateVar.trim()})`;
|
||||
}
|
||||
|
||||
escape(inputs: string) {
|
||||
return inputs
|
||||
.substring(1, inputs.length - 1)
|
||||
.split(`','`)
|
||||
.map(v => `@'${v}'`)
|
||||
.join(', ');
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import Datasource from './datasource';
|
||||
import { AzureMonitorQueryCtrl } from './query_ctrl';
|
||||
import { AzureMonitorAnnotationsQueryCtrl } from './annotations_query_ctrl';
|
||||
import { AzureMonitorConfigCtrl } from './config_ctrl';
|
||||
|
||||
export {
|
||||
Datasource,
|
||||
AzureMonitorQueryCtrl as QueryCtrl,
|
||||
AzureMonitorConfigCtrl as ConfigCtrl,
|
||||
AzureMonitorAnnotationsQueryCtrl as AnnotationsQueryCtrl,
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Service</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input service-dropdown" ng-model="ctrl.annotation.queryType" ng-options="f as f for f in ['Application Insights', 'Azure Monitor', 'Azure Log Analytics']"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="ctrl.annotation.queryType === 'Azure Log Analytics'">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Workspace</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input min-width-12" ng-model="ctrl.annotation.workspace" ng-options="f.value as f.text for f in ctrl.workspaces"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<div class="width-1"></div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<button class="btn btn-primary width-10" ng-click="ctrl.panelCtrl.refresh()">Run</button>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">(Run Query: Shift+Enter, Trigger Suggestion: Ctrl+Space)</label>
|
||||
</div>
|
||||
</div>
|
||||
<kusto-monaco-editor content="ctrl.annotation.rawQuery" get-schema="ctrl.datasource.azureLogAnalyticsDatasource.getSchema(ctrl.annotation.workspace)"
|
||||
default-time-field="TimeGenerated" plugin-base-url={{ctrl.datasource.meta.baseUrl}}></kusto-monaco-editor>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline" ng-show="ctrl.annotation.queryType !== 'Azure Log Analytics'">
|
||||
<div class="gf-form gf-form--grow">
|
||||
<label class="gf-form-label">No annotations support for {{ctrl.annotation.queryType}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
|
||||
Show Help
|
||||
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
|
||||
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form" ng-show="ctrl.showHelp">
|
||||
<pre class="gf-form-pre alert alert-info" ng-show="ctrl.annotation.queryType === 'Azure Log Analytics'"><h6>Annotation Query Format</h6>
|
||||
An annotation is an event that is overlaid on top of graphs. The query can have up to three columns per row, the datetime column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned.
|
||||
|
||||
- column with the datetime type.
|
||||
- column with alias: <b>Text</b> or <b>text</b> for the annotation text
|
||||
- column with alias: <b>Tags</b> or <b>tags</b> for annotation tags. This is should return a comma separated string of tags e.g. 'tag1,tag2'
|
||||
|
||||
Macros:
|
||||
- $__timeFilter() -> TimeGenerated ≥ datetime(2018-06-05T18:09:58.907Z) and TimeGenerated ≤ datetime(2018-06-05T20:09:58.907Z)
|
||||
- $__timeFilter(datetimeColumn) -> datetimeColumn ≥ datetime(2018-06-05T18:09:58.907Z) and datetimeColumn ≤ datetime(2018-06-05T20:09:58.907Z)
|
||||
|
||||
Or build your own conditionals using these built-in variables which just return the values:
|
||||
- $__from -> datetime(2018-06-05T18:09:58.907Z)
|
||||
- $__to -> datetime(2018-06-05T20:09:58.907Z)
|
||||
- $__interval -> 5m
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,196 @@
|
||||
<h3 class="page-heading">Azure Monitor API Details</h3>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Azure Cloud</span>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input" ng-model="ctrl.current.jsonData.cloudName" ng-options="f.value as f.text for f in [{value: 'azuremonitor', text: 'Azure'}, {value: 'govazuremonitor', text: 'Azure US Government'}, {value: 'germanyazuremonitor', text: 'Azure Germany'}, {value: 'chinaazuremonitor', text: 'Azure China'}]"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
<info-popover mode="right-normal">
|
||||
<p>Choose an Azure Cloud.</p>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Subscription Id</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.jsonData.subscriptionId" placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
<p>In the Azure Portal, navigate to Subscriptions -> Choose subscription -> Overview -> Subscription ID.</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Tenant Id</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.jsonData.tenantId" placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
<p>In the Azure Portal, navigate to Azure Active Directory -> Properties -> Directory ID.</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Client Id</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.jsonData.clientId" placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
<p>In the Azure Portal, navigate to Azure Active Directory -> App Registrations -> Choose your app ->
|
||||
Application ID.</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline" ng-if="!ctrl.current.secureJsonFields.clientSecret">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Client Secret</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.secureJsonData.clientSecret" placeholder=""></input>
|
||||
<info-popover mode="right-absolute">
|
||||
<p>To create a new key, log in to Azure Portal, navigate to Azure Active Directory -> App Registrations ->
|
||||
Choose your
|
||||
app -> Keys.</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-if="ctrl.current.secureJsonFields.clientSecret">
|
||||
<span class="gf-form-label width-9">Client Secret</span>
|
||||
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
|
||||
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.clientSecret = false">reset</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="page-heading">Azure Log Analytics API Details</h3>
|
||||
|
||||
<div class="grafana-info-box ng-scope">
|
||||
The Azure Log Analytics support is marked as being in a preview development state. This means it is in currently in active development and major changes might be made - depending on feedback from users.
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<gf-form-switch class="gf-form" label="Same details as Azure Monitor API" label-class="width-19" switch-class="max-width-6"
|
||||
checked="ctrl.current.jsonData.azureLogAnalyticsSameAs" on-change="ctrl.onSameAsToggle()">
|
||||
</gf-form-switch>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group" ng-show="!ctrl.current.jsonData.azureLogAnalyticsSameAs">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Subscription Id</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.jsonData.logAnalyticsSubscriptionId"
|
||||
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" />
|
||||
<info-popover mode="right-absolute">
|
||||
<p>In the Azure Portal, navigate to Subscriptions -> Choose subscription -> Overview -> Subscription ID.</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Tenant Id</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.jsonData.logAnalyticsTenantId"
|
||||
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" />
|
||||
<info-popover mode="right-absolute">
|
||||
<p>In the Azure Portal, navigate to Azure Active Directory -> Properties -> Directory ID.</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Client Id</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.jsonData.logAnalyticsClientId"
|
||||
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
<p>In the Azure Portal, navigate to Azure Active Directory -> App Registrations -> Choose your app ->
|
||||
Application ID.
|
||||
</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline" ng-if="!ctrl.current.secureJsonFields.logAnalyticsClientSecret">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Client Secret</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.secureJsonData.logAnalyticsClientSecret"
|
||||
placeholder="" />
|
||||
<info-popover mode="right-absolute">
|
||||
<p>To create a new key, log in to Azure Portal, navigate to Azure Active Directory -> App Registrations ->
|
||||
Choose your
|
||||
app -> Keys.</p>
|
||||
<a target="_blank" href="https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal">**Click
|
||||
here for detailed instructions on setting up an Azure Active Directory (AD) application.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-if="ctrl.current.secureJsonFields.logAnalyticsClientSecret">
|
||||
<span class="gf-form-label width-9">Client Secret</span>
|
||||
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
|
||||
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.logAnalyticsClientSecret = false">reset</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Default Workspace</span>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input" ng-model="ctrl.current.jsonData.logAnalyticsDefaultWorkspace" ng-options="f.value as f.text for f in ctrl.workspaces"
|
||||
ng-disabled="!ctrl.workspaces"></select>
|
||||
</div>
|
||||
<info-popover mode="right-normal">
|
||||
<p>Choose the default/preferred Workspace for Azure Log Analytics queries.</p>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-group" ng-if="ctrl.showMinVersionWarning()">
|
||||
<div class=" alert alert-error">
|
||||
<p>
|
||||
The Azure Log Analytics feature requires Grafana 5.2.0 or greater. Download a new version of
|
||||
Grafana
|
||||
<a class="external-link" target="_blank" href="https://grafana.com/get">here</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="page-heading">Application Insights Details</h3>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form-inline" ng-if="!ctrl.current.secureJsonFields.appInsightsApiKey">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">API Key</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.secureJsonData.appInsightsApiKey"
|
||||
placeholder="" />
|
||||
<info-popover mode="right-absolute">
|
||||
<p>Section 2 of the Quickstart guide shows where to find/create the API Key:</p>
|
||||
<a target="_blank" href="https://dev.applicationinsights.io/quickstart/">**Click here to open the Application
|
||||
Insights Quickstart.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-if="ctrl.current.secureJsonFields.appInsightsApiKey">
|
||||
<span class="gf-form-label width-9">API Key</span>
|
||||
<input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
|
||||
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.appInsightsApiKey = false">reset</a>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Application Id</span>
|
||||
<input class="gf-form-input width-30" type="text" ng-model="ctrl.current.jsonData.appInsightsAppId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"></input>
|
||||
<info-popover mode="right-absolute">
|
||||
<p>Section 2 of the Quickstart guide shows where to find the Application ID:</p>
|
||||
<a target="_blank" href="https://dev.applicationinsights.io/quickstart/">**Click here to open the Application
|
||||
Insights Quickstart.**</a>
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,325 @@
|
||||
<query-editor-row query-ctrl="ctrl" can-collapse="false" has-text-edit-mode="ctrl.target.queryType === 'Application Insights'">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Service</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input service-dropdown" ng-model="ctrl.target.queryType" ng-options="f as f for f in ['Application Insights', 'Azure Monitor', 'Azure Log Analytics']"
|
||||
ng-change="ctrl.onQueryTypeChange()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="ctrl.target.queryType === 'Azure Monitor'">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Resource Group</label>
|
||||
<gf-form-dropdown model="ctrl.target.azureMonitor.resourceGroup" allow-custom="true" lookup-text="true"
|
||||
get-options="ctrl.getResourceGroups($query)" on-change="ctrl.onResourceGroupChange()" css-class="min-width-12">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Namespace</label>
|
||||
<gf-form-dropdown model="ctrl.target.azureMonitor.metricDefinition" allow-custom="true" lookup-text="true"
|
||||
get-options="ctrl.getMetricDefinitions($query)" on-change="ctrl.onMetricDefinitionChange()" css-class="min-width-20">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Resource Name</label>
|
||||
<gf-form-dropdown model="ctrl.target.azureMonitor.resourceName" allow-custom="true" lookup-text="true"
|
||||
get-options="ctrl.getResourceNames($query)" on-change="ctrl.onResourceNameChange()" css-class="min-width-12">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Metric</label>
|
||||
<gf-form-dropdown model="ctrl.target.azureMonitor.metricName" allow-custom="true" lookup-text="true"
|
||||
get-options="ctrl.getMetricNames($query)" on-change="ctrl.onMetricNameChange()" css-class="min-width-12">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow aggregation-dropdown-wrapper">
|
||||
<label class="gf-form-label query-keyword width-9">Aggregation</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input width-11" ng-model="ctrl.target.azureMonitor.aggregation" ng-options="f as f for f in ctrl.target.azureMonitor.aggOptions"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Time Grain</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent timegrainunit-dropdown-wrapper">
|
||||
<select class="gf-form-input" ng-model="ctrl.target.azureMonitor.timeGrain" ng-options="f.value as f.text for f in ctrl.target.azureMonitor.timeGrains"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-show="ctrl.target.azureMonitor.timeGrain.trim() === 'auto'">
|
||||
<label class="gf-form-label">Auto Interval</label>
|
||||
<label class="gf-form-label">{{ctrl.getAutoInterval()}}</label>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline" ng-show="ctrl.target.azureMonitor.dimensions.length > 0">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Dimension</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input min-width-12" ng-model="ctrl.target.azureMonitor.dimension" ng-options="f.value as f.text for f in ctrl.target.azureMonitor.dimensions"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-3">eq</label>
|
||||
<input type="text" class="gf-form-input width-17" ng-model="ctrl.target.azureMonitor.dimensionFilter"
|
||||
spellcheck="false" placeholder="auto" ng-blur="ctrl.refresh()">
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Legend Format</label>
|
||||
<input type="text" class="gf-form-input width-30" ng-model="ctrl.target.azureMonitor.alias" spellcheck="false"
|
||||
placeholder="alias patterns (see help for more info)" ng-blur="ctrl.refresh()">
|
||||
</div>
|
||||
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.target.queryType === 'Azure Log Analytics'">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Workspace</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input min-width-12" ng-model="ctrl.target.azureLogAnalytics.workspace" ng-options="f.value as f.text for f in ctrl.workspaces"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<div class="width-1"></div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<button class="btn btn-primary width-10" ng-click="ctrl.refresh()">Run</button>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">(Run Query: Shift+Enter, Trigger Suggestion: Ctrl+Space)</label>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <kusto-monaco-editor content="ctrl.target.azureLogAnalytics.query" on-change="ctrl.refresh()" get-schema="ctrl.getAzureLogAnalyticsSchema()"
|
||||
default-time-field="TimeGenerated" plugin-base-url={{ctrl.datasource.meta.baseUrl}}></kusto-monaco-editor> -->
|
||||
|
||||
<div class="gf-form gf-form--grow">
|
||||
<kusto-editor
|
||||
class="gf-form gf-form--grow"
|
||||
request="ctrl.requestMetadata"
|
||||
style="border: none"
|
||||
query="ctrl.target.azureLogAnalytics.query"
|
||||
change="ctrl.onLogAnalyticsQueryChange"
|
||||
execute="ctrl.onLogAnalyticsQueryExecute"
|
||||
variables="ctrl.templateVariables"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-7">Format As</label>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.azureLogAnalytics.resultFormat" ng-options="f.value as f.text for f in ctrl.resultFormats"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
|
||||
Show Help
|
||||
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
|
||||
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gf-form" ng-show="ctrl.lastQuery">
|
||||
<label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuery = !ctrl.showLastQuery">
|
||||
Raw Query
|
||||
<i class="fa fa-caret-down" ng-show="ctrl.showLastQuery"></i>
|
||||
<i class="fa fa-caret-right" ng-hide="ctrl.showLastQuery"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form" ng-show="ctrl.showLastQuery">
|
||||
<pre class="gf-form-pre">{{ctrl.lastQuery}}</pre>
|
||||
</div>
|
||||
<div class="gf-form" ng-show="ctrl.showHelp">
|
||||
<pre class="gf-form-pre alert alert-info">
|
||||
Format as Table:
|
||||
- return any set of columns
|
||||
|
||||
Format as Time series:
|
||||
- Requires a column of type datetime
|
||||
- returns the first column with a numeric datatype as the value
|
||||
- (Optional: returns the first column with type string to represent the series name. If no column is found the column name of the value column is used as series name)
|
||||
|
||||
Example Time Series Query:
|
||||
|
||||
AzureActivity
|
||||
| where $__timeFilter()
|
||||
| summarize count() by Category, bin(TimeGenerated, 60min)
|
||||
| order by TimeGenerated asc
|
||||
|
||||
Macros:
|
||||
- $__timeFilter() -> TimeGenerated ≥ datetime(2018-06-05T18:09:58.907Z) and TimeGenerated ≤ datetime(2018-06-05T20:09:58.907Z)
|
||||
- $__timeFilter(datetimeColumn) -> datetimeColumn ≥ datetime(2018-06-05T18:09:58.907Z) and datetimeColumn ≤ datetime(2018-06-05T20:09:58.907Z)
|
||||
- $__escapeMulti($myTemplateVar) -> $myTemplateVar should be a multi-value template variables that contains illegal characters
|
||||
- $__contains(aColumn, $myTemplateVar) -> aColumn in ($myTemplateVar)
|
||||
If using the All option, then check the Include All Option checkbox and in the Custom all value field type in: all. If All is chosen -> 1 == 1
|
||||
|
||||
Or build your own conditionals using these built-in variables which just return the values:
|
||||
- $__from -> datetime(2018-06-05T18:09:58.907Z)
|
||||
- $__to -> datetime(2018-06-05T20:09:58.907Z)
|
||||
- $__interval -> 5m
|
||||
|
||||
Examples:
|
||||
- ¡ where $__timeFilter
|
||||
- | where TimeGenerated ≥ $__from and TimeGenerated ≤ $__to
|
||||
- | summarize count() by Category, bin(TimeGenerated, $__interval)
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.target.queryType === 'Application Insights'">
|
||||
<div ng-show="!ctrl.target.appInsights.rawQuery">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Metric</label>
|
||||
<gf-form-dropdown model="ctrl.target.appInsights.metricName" allow-custom="true" lookup-text="true"
|
||||
get-options="ctrl.getAppInsightsMetricNames($query)" on-change="ctrl.onAppInsightsMetricNameChange()"
|
||||
css-class="min-width-20">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Aggregation</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input" ng-model="ctrl.target.appInsights.aggregation" ng-options="f as f for f in ctrl.target.appInsights.aggOptions"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Group By</label>
|
||||
<gf-form-dropdown allow-custom="true" ng-hide="ctrl.target.appInsights.groupBy !== 'none'" model="ctrl.target.appInsights.groupBy"
|
||||
lookup-text="true" get-options="ctrl.getAppInsightsGroupBySegments($query)" on-change="ctrl.refresh()"
|
||||
css-class="min-width-20">
|
||||
</gf-form-dropdown>
|
||||
<label class="gf-form-label min-width-20 pointer" ng-hide="ctrl.target.appInsights.groupBy === 'none'"
|
||||
ng-click="ctrl.resetAppInsightsGroupBy()">{{ctrl.target.appInsights.groupBy}}
|
||||
<i class="fa fa-remove"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Filter</label>
|
||||
<input type="text" class="gf-form-input width-17" ng-model="ctrl.target.appInsights.filter" spellcheck="false"
|
||||
placeholder="your/groupby eq 'a_value'" ng-blur="ctrl.refresh()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Time Grain</label>
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
|
||||
<select class="gf-form-input" ng-model="ctrl.target.appInsights.timeGrainType" ng-options="f as f for f in ['auto', 'none', 'specific']"
|
||||
ng-change="ctrl.updateTimeGrainType()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-hide="ctrl.target.appInsights.timeGrainType === 'auto' || ctrl.target.appInsights.timeGrainType === 'none'">
|
||||
<input type="text" class="gf-form-input width-3" ng-model="ctrl.target.appInsights.timeGrain" spellcheck="false"
|
||||
placeholder="" ng-blur="ctrl.refresh()">
|
||||
</div>
|
||||
<div class="gf-form" ng-hide="ctrl.target.appInsights.timeGrainType === 'auto' || ctrl.target.appInsights.timeGrainType === 'none'">
|
||||
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent timegrainunit-dropdown-wrapper">
|
||||
<select class="gf-form-input" ng-model="ctrl.target.appInsights.timeGrainUnit" ng-options="f as f for f in ['minute', 'hour', 'day', 'month', 'year']"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-hide="ctrl.target.appInsights.timeGrainType !== 'auto'">
|
||||
<label class="gf-form-label">Auto Interval</label>
|
||||
<label class="gf-form-label">{{ctrl.getAppInsightsAutoInterval()}}</label>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Legend Format</label>
|
||||
<input type="text" class="gf-form-input width-30" ng-model="ctrl.target.appInsights.alias" spellcheck="false"
|
||||
placeholder="alias patterns (see help for more info)" ng-blur="ctrl.refresh()">
|
||||
</div>
|
||||
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="ctrl.target.appInsights.rawQuery">
|
||||
<div class="gf-form">
|
||||
<textarea rows="3" class="gf-form-input" ng-model="ctrl.target.appInsights.rawQueryString" spellcheck="false"
|
||||
placeholder="Application Insights Query" ng-model-onblur ng-change="ctrl.refresh()"></textarea>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">X-axis</label>
|
||||
<gf-form-dropdown model="ctrl.target.appInsights.xaxis" allow-custom="true" placeholder="eg. 'timestamp'"
|
||||
get-options="ctrl.getAppInsightsColumns($query)" on-change="ctrl.onAppInsightsColumnChange()" css-class="min-width-20">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Y-axis(es)</label>
|
||||
<gf-form-dropdown model="ctrl.target.appInsights.yaxis" allow-custom="true" get-options="ctrl.getAppInsightsColumns($query)"
|
||||
on-change="ctrl.onAppInsightsColumnChange()" css-class="min-width-20">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label query-keyword width-9">Split On</label>
|
||||
<gf-form-dropdown model="ctrl.target.appInsights.spliton" allow-custom="true" get-options="ctrl.getAppInsightsColumns($query)"
|
||||
on-change="ctrl.onAppInsightsColumnChange()" css-class="min-width-20">
|
||||
</gf-form-dropdown>
|
||||
</div>
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form" ng-show="ctrl.lastQueryError">
|
||||
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
|
||||
</div>
|
||||
</query-editor-row>
|
@ -0,0 +1,162 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Azure Monitor",
|
||||
"id": "grafana-azure-monitor-datasource",
|
||||
|
||||
"info": {
|
||||
"description": "Grafana data source for Azure Monitor/Application Insights",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"keywords": ["azure", "monitor", "Application Insights", "Log Analytics", "App Insights"],
|
||||
"logos": {
|
||||
"small": "img/logo.jpg",
|
||||
"large": "img/logo.jpg"
|
||||
},
|
||||
"links": [
|
||||
{ "name": "Project site", "url": "https://github.com/grafana/azure-monitor-datasource" },
|
||||
{ "name": "Apache License", "url": "https://github.com/grafana/azure-monitor-datasource/blob/master/LICENSE" }
|
||||
],
|
||||
"screenshots": [
|
||||
{ "name": "Azure Contoso Loans", "path": "img/contoso_loans_grafana_dashboard.png" },
|
||||
{ "name": "Azure Monitor Network", "path": "img/azure_monitor_network.png" },
|
||||
{ "name": "Azure Monitor CPU", "path": "img/azure_monitor_cpu.png" }
|
||||
],
|
||||
"version": "0.3.0",
|
||||
"updated": "2018-12-06"
|
||||
},
|
||||
|
||||
"routes": [
|
||||
{
|
||||
"path": "azuremonitor",
|
||||
"method": "GET",
|
||||
"url": "https://management.azure.com",
|
||||
"tokenAuth": {
|
||||
"url": "https://login.microsoftonline.com/{{.JsonData.tenantId}}/oauth2/token",
|
||||
"params": {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "{{.JsonData.clientId}}",
|
||||
"client_secret": "{{.SecureJsonData.clientSecret}}",
|
||||
"resource": "https://management.azure.com/"
|
||||
}
|
||||
},
|
||||
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
|
||||
},
|
||||
{
|
||||
"path": "govazuremonitor",
|
||||
"method": "GET",
|
||||
"url": "https://management.usgovcloudapi.net",
|
||||
"tokenAuth": {
|
||||
"url": "https://login.microsoftonline.us/{{.JsonData.tenantId}}/oauth2/token",
|
||||
"params": {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "{{.JsonData.clientId}}",
|
||||
"client_secret": "{{.SecureJsonData.clientSecret}}",
|
||||
"resource": "https://management.usgovcloudapi.net/"
|
||||
}
|
||||
},
|
||||
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
|
||||
},
|
||||
{
|
||||
"path": "germanyazuremonitor",
|
||||
"method": "GET",
|
||||
"url": "https://management.microsoftazure.de",
|
||||
"tokenAuth": {
|
||||
"url": "https://login.microsoftonline.de/{{.JsonData.tenantId}}/oauth2/token",
|
||||
"params": {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "{{.JsonData.clientId}}",
|
||||
"client_secret": "{{.SecureJsonData.clientSecret}}",
|
||||
"resource": "https://management.microsoftazure.de/"
|
||||
}
|
||||
},
|
||||
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
|
||||
},
|
||||
{
|
||||
"path": "chinaazuremonitor",
|
||||
"method": "GET",
|
||||
"url": "https://management.chinacloudapi.cn",
|
||||
"tokenAuth": {
|
||||
"url": "https://login.chinacloudapi.cn/{{.JsonData.tenantId}}/oauth2/token",
|
||||
"params": {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "{{.JsonData.clientId}}",
|
||||
"client_secret": "{{.SecureJsonData.clientSecret}}",
|
||||
"resource": "https://management.chinacloudapi.cn/"
|
||||
}
|
||||
},
|
||||
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
|
||||
},
|
||||
{
|
||||
"path": "appinsights",
|
||||
"method": "GET",
|
||||
"url": "https://api.applicationinsights.io",
|
||||
"headers": [
|
||||
{ "name": "X-API-Key", "content": "{{.SecureJsonData.appInsightsApiKey}}" },
|
||||
{ "name": "x-ms-app", "content": "Grafana" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "workspacesloganalytics",
|
||||
"method": "GET",
|
||||
"url": "https://management.azure.com",
|
||||
"tokenAuth": {
|
||||
"url": "https://login.microsoftonline.com/{{.JsonData.logAnalyticsTenantId}}/oauth2/token",
|
||||
"params": {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "{{.JsonData.logAnalyticsClientId}}",
|
||||
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}",
|
||||
"resource": "https://management.azure.com/"
|
||||
}
|
||||
},
|
||||
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
|
||||
},
|
||||
{
|
||||
"path": "loganalyticsazure",
|
||||
"method": "GET",
|
||||
"url": "https://api.loganalytics.io/v1/workspaces",
|
||||
"tokenAuth": {
|
||||
"url": "https://login.microsoftonline.com/{{.JsonData.logAnalyticsTenantId}}/oauth2/token",
|
||||
"params": {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "{{.JsonData.logAnalyticsClientId}}",
|
||||
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}",
|
||||
"resource": "https://api.loganalytics.io"
|
||||
}
|
||||
},
|
||||
"headers": [
|
||||
{ "name": "x-ms-app", "content": "Grafana" },
|
||||
{ "name": "Cache-Control", "content": "public, max-age=60" },
|
||||
{ "name": "Accept-Encoding", "content": "gzip" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "sameasloganalyticsazure",
|
||||
"method": "GET",
|
||||
"url": "https://api.loganalytics.io/v1/workspaces",
|
||||
"tokenAuth": {
|
||||
"url": "https://login.microsoftonline.com/{{.JsonData.tenantId}}/oauth2/token",
|
||||
"params": {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "{{.JsonData.clientId}}",
|
||||
"client_secret": "{{.SecureJsonData.clientSecret}}",
|
||||
"resource": "https://api.loganalytics.io"
|
||||
}
|
||||
},
|
||||
"headers": [
|
||||
{ "name": "x-ms-app", "content": "Grafana" },
|
||||
{ "name": "Cache-Control", "content": "public, max-age=60" },
|
||||
{ "name": "Accept-Encoding", "content": "gzip" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"dependencies": {
|
||||
"grafanaVersion": "5.2.x",
|
||||
"plugins": []
|
||||
},
|
||||
|
||||
"metrics": true,
|
||||
"annotations": true
|
||||
}
|
@ -0,0 +1,259 @@
|
||||
jest.mock('./css/query_editor.css', () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
import { AzureMonitorQueryCtrl } from './query_ctrl';
|
||||
import Q from 'q';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
describe('AzureMonitorQueryCtrl', () => {
|
||||
let queryCtrl: any;
|
||||
|
||||
beforeEach(() => {
|
||||
AzureMonitorQueryCtrl.prototype.panelCtrl = {
|
||||
events: { on: () => {} },
|
||||
panel: { scopedVars: [], targets: [] },
|
||||
};
|
||||
AzureMonitorQueryCtrl.prototype.target = {} as any;
|
||||
|
||||
queryCtrl = new AzureMonitorQueryCtrl({}, {}, new TemplateSrv());
|
||||
queryCtrl.datasource = { $q: Q, appInsightsDatasource: { isConfigured: () => false } };
|
||||
});
|
||||
|
||||
describe('init query_ctrl variables', () => {
|
||||
it('should set default query type to Azure Monitor', () => {
|
||||
expect(queryCtrl.target.queryType).toBe('Azure Monitor');
|
||||
});
|
||||
|
||||
it('should set default App Insights editor to be builder', () => {
|
||||
expect(queryCtrl.target.appInsights.rawQuery).toBe(false);
|
||||
});
|
||||
|
||||
it('should set query parts to select', () => {
|
||||
expect(queryCtrl.target.azureMonitor.resourceGroup).toBe('select');
|
||||
expect(queryCtrl.target.azureMonitor.metricDefinition).toBe('select');
|
||||
expect(queryCtrl.target.azureMonitor.resourceName).toBe('select');
|
||||
expect(queryCtrl.target.azureMonitor.metricName).toBe('select');
|
||||
expect(queryCtrl.target.appInsights.groupBy).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the query type is Azure Monitor', () => {
|
||||
describe('and getOptions for the Resource Group dropdown is called', () => {
|
||||
const response = [{ text: 'nodeapp', value: 'nodeapp' }, { text: 'otherapp', value: 'otherapp' }];
|
||||
|
||||
beforeEach(() => {
|
||||
queryCtrl.datasource.getResourceGroups = () => {
|
||||
return queryCtrl.datasource.$q.when(response);
|
||||
};
|
||||
queryCtrl.datasource.azureMonitorDatasource = {
|
||||
isConfigured: () => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of Resource Groups', () => {
|
||||
return queryCtrl.getResourceGroups('').then(result => {
|
||||
expect(result[0].text).toBe('nodeapp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getOptions for the Metric Definition dropdown is called', () => {
|
||||
describe('and resource group has a value', () => {
|
||||
const response = [
|
||||
{ text: 'Microsoft.Compute/virtualMachines', value: 'Microsoft.Compute/virtualMachines' },
|
||||
{ text: 'Microsoft.Network/publicIPAddresses', value: 'Microsoft.Network/publicIPAddresses' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.azureMonitor.resourceGroup = 'test';
|
||||
queryCtrl.datasource.getMetricDefinitions = function(query) {
|
||||
expect(query).toBe('test');
|
||||
return this.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of Metric Definitions', () => {
|
||||
return queryCtrl.getMetricDefinitions('').then(result => {
|
||||
expect(result[0].text).toBe('Microsoft.Compute/virtualMachines');
|
||||
expect(result[1].text).toBe('Microsoft.Network/publicIPAddresses');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and resource group has no value', () => {
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.azureMonitor.resourceGroup = 'select';
|
||||
});
|
||||
|
||||
it('should return without making a call to datasource', () => {
|
||||
expect(queryCtrl.getMetricDefinitions('')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getOptions for the ResourceNames dropdown is called', () => {
|
||||
describe('and resourceGroup and metricDefinition have values', () => {
|
||||
const response = [{ text: 'test1', value: 'test1' }, { text: 'test2', value: 'test2' }];
|
||||
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.azureMonitor.resourceGroup = 'test';
|
||||
queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines';
|
||||
queryCtrl.datasource.getResourceNames = function(resourceGroup, metricDefinition) {
|
||||
expect(resourceGroup).toBe('test');
|
||||
expect(metricDefinition).toBe('Microsoft.Compute/virtualMachines');
|
||||
return this.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of Resource Names', () => {
|
||||
return queryCtrl.getResourceNames('').then(result => {
|
||||
expect(result[0].text).toBe('test1');
|
||||
expect(result[1].text).toBe('test2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and resourceGroup and metricDefinition do not have values', () => {
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.azureMonitor.resourceGroup = 'select';
|
||||
queryCtrl.target.azureMonitor.metricDefinition = 'select';
|
||||
});
|
||||
|
||||
it('should return without making a call to datasource', () => {
|
||||
expect(queryCtrl.getResourceNames('')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getOptions for the Metric Names dropdown is called', () => {
|
||||
describe('and resourceGroup, metricDefinition and resourceName have values', () => {
|
||||
const response = [{ text: 'metric1', value: 'metric1' }, { text: 'metric2', value: 'metric2' }];
|
||||
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.azureMonitor.resourceGroup = 'test';
|
||||
queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines';
|
||||
queryCtrl.target.azureMonitor.resourceName = 'test';
|
||||
queryCtrl.datasource.getMetricNames = function(resourceGroup, metricDefinition, resourceName) {
|
||||
expect(resourceGroup).toBe('test');
|
||||
expect(metricDefinition).toBe('Microsoft.Compute/virtualMachines');
|
||||
expect(resourceName).toBe('test');
|
||||
return this.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of Metric Names', () => {
|
||||
return queryCtrl.getMetricNames('').then(result => {
|
||||
expect(result[0].text).toBe('metric1');
|
||||
expect(result[1].text).toBe('metric2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and resourceGroup, metricDefinition and resourceName do not have values', () => {
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.azureMonitor.resourceGroup = 'select';
|
||||
queryCtrl.target.azureMonitor.metricDefinition = 'select';
|
||||
queryCtrl.target.azureMonitor.resourceName = 'select';
|
||||
});
|
||||
|
||||
it('should return without making a call to datasource', () => {
|
||||
expect(queryCtrl.getMetricNames('')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when onMetricNameChange is triggered for the Metric Names dropdown', () => {
|
||||
const response = {
|
||||
primaryAggType: 'Average',
|
||||
supportAggOptions: ['Average', 'Total'],
|
||||
supportedTimeGrains: ['PT1M', 'P1D'],
|
||||
dimensions: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.azureMonitor.resourceGroup = 'test';
|
||||
queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines';
|
||||
queryCtrl.target.azureMonitor.resourceName = 'test';
|
||||
queryCtrl.target.azureMonitor.metricName = 'Percentage CPU';
|
||||
queryCtrl.datasource.getMetricMetadata = function(resourceGroup, metricDefinition, resourceName, metricName) {
|
||||
expect(resourceGroup).toBe('test');
|
||||
expect(metricDefinition).toBe('Microsoft.Compute/virtualMachines');
|
||||
expect(resourceName).toBe('test');
|
||||
expect(metricName).toBe('Percentage CPU');
|
||||
return this.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should set the options and default selected value for the Aggregations dropdown', () => {
|
||||
queryCtrl.onMetricNameChange().then(() => {
|
||||
expect(queryCtrl.target.azureMonitor.aggregation).toBe('Average');
|
||||
expect(queryCtrl.target.azureMonitor.aggOptions).toBe(['Average', 'Total']);
|
||||
expect(queryCtrl.target.azureMonitor.timeGrains).toBe(['PT1M', 'P1D']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and query type is Application Insights', () => {
|
||||
describe('when getOptions for the Metric Names dropdown is called', () => {
|
||||
const response = [{ text: 'metric1', value: 'metric1' }, { text: 'metric2', value: 'metric2' }];
|
||||
|
||||
beforeEach(() => {
|
||||
queryCtrl.datasource.appInsightsDatasource.isConfigured = () => true;
|
||||
queryCtrl.datasource.getAppInsightsMetricNames = () => {
|
||||
return queryCtrl.datasource.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should return a list of Metric Names', () => {
|
||||
return queryCtrl.getAppInsightsMetricNames().then(result => {
|
||||
expect(result[0].text).toBe('metric1');
|
||||
expect(result[1].text).toBe('metric2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getOptions for the GroupBy segments dropdown is called', () => {
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.appInsights.groupByOptions = ['opt1', 'opt2'];
|
||||
});
|
||||
|
||||
it('should return a list of GroupBy segments', () => {
|
||||
const result = queryCtrl.getAppInsightsGroupBySegments('');
|
||||
expect(result[0].text).toBe('opt1');
|
||||
expect(result[0].value).toBe('opt1');
|
||||
expect(result[1].text).toBe('opt2');
|
||||
expect(result[1].value).toBe('opt2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when onAppInsightsMetricNameChange is triggered for the Metric Names dropdown', () => {
|
||||
const response = {
|
||||
primaryAggType: 'avg',
|
||||
supportedAggTypes: ['avg', 'sum'],
|
||||
supportedGroupBy: ['client/os', 'client/city'],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
queryCtrl.target.appInsights.metricName = 'requests/failed';
|
||||
queryCtrl.datasource.getAppInsightsMetricMetadata = function(metricName) {
|
||||
expect(metricName).toBe('requests/failed');
|
||||
return this.$q.when(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('should set the options and default selected value for the Aggregations dropdown', () => {
|
||||
return queryCtrl.onAppInsightsMetricNameChange().then(() => {
|
||||
expect(queryCtrl.target.appInsights.aggregation).toBe('avg');
|
||||
expect(queryCtrl.target.appInsights.aggOptions).toContain('avg');
|
||||
expect(queryCtrl.target.appInsights.aggOptions).toContain('sum');
|
||||
expect(queryCtrl.target.appInsights.groupByOptions).toContain('client/os');
|
||||
expect(queryCtrl.target.appInsights.groupByOptions).toContain('client/city');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,404 @@
|
||||
import _ from 'lodash';
|
||||
import { QueryCtrl } from 'app/plugins/sdk';
|
||||
// import './css/query_editor.css';
|
||||
import TimegrainConverter from './time_grain_converter';
|
||||
// import './monaco/kusto_monaco_editor';
|
||||
import './editor/editor_component';
|
||||
|
||||
export interface ResultFormat {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class AzureMonitorQueryCtrl extends QueryCtrl {
|
||||
static templateUrl = 'partials/query.editor.html';
|
||||
|
||||
defaultDropdownValue = 'select';
|
||||
|
||||
target: {
|
||||
refId: string;
|
||||
queryType: string;
|
||||
azureMonitor: {
|
||||
resourceGroup: string;
|
||||
resourceName: string;
|
||||
metricDefinition: string;
|
||||
metricName: string;
|
||||
dimensionFilter: string;
|
||||
timeGrain: string;
|
||||
timeGrainUnit: string;
|
||||
timeGrains: any[];
|
||||
dimensions: any[];
|
||||
dimension: any;
|
||||
aggregation: string;
|
||||
aggOptions: string[];
|
||||
};
|
||||
azureLogAnalytics: {
|
||||
query: string;
|
||||
resultFormat: string;
|
||||
workspace: string;
|
||||
};
|
||||
appInsights: {
|
||||
metricName: string;
|
||||
rawQuery: boolean;
|
||||
rawQueryString: string;
|
||||
groupBy: string;
|
||||
timeGrainType: string;
|
||||
xaxis: string;
|
||||
yaxis: string;
|
||||
spliton: string;
|
||||
aggOptions: string[];
|
||||
aggregation: string;
|
||||
groupByOptions: string[];
|
||||
timeGrainUnit: string;
|
||||
timeGrain: string;
|
||||
};
|
||||
};
|
||||
|
||||
defaults = {
|
||||
queryType: 'Azure Monitor',
|
||||
azureMonitor: {
|
||||
resourceGroup: this.defaultDropdownValue,
|
||||
metricDefinition: this.defaultDropdownValue,
|
||||
resourceName: this.defaultDropdownValue,
|
||||
metricName: this.defaultDropdownValue,
|
||||
dimensionFilter: '*',
|
||||
timeGrain: 'auto',
|
||||
},
|
||||
azureLogAnalytics: {
|
||||
query: [
|
||||
'//change this example to create your own time series query',
|
||||
'<table name> ' +
|
||||
'//the table to query (e.g. Usage, Heartbeat, Perf)',
|
||||
'| where $__timeFilter(TimeGenerated) ' +
|
||||
'//this is a macro used to show the full chart’s time range, choose the datetime column here',
|
||||
'| summarize count() by <group by column>, bin(TimeGenerated, $__interval) ' +
|
||||
'//change “group by column” to a column in your table, such as “Computer”. ' +
|
||||
'The $__interval macro is used to auto-select the time grain. Can also use 1h, 5m etc.',
|
||||
'| order by TimeGenerated asc',
|
||||
].join('\n'),
|
||||
resultFormat: 'time_series',
|
||||
workspace:
|
||||
this.datasource && this.datasource.azureLogAnalyticsDatasource
|
||||
? this.datasource.azureLogAnalyticsDatasource.defaultOrFirstWorkspace
|
||||
: '',
|
||||
},
|
||||
appInsights: {
|
||||
metricName: this.defaultDropdownValue,
|
||||
rawQuery: false,
|
||||
rawQueryString: '',
|
||||
groupBy: 'none',
|
||||
timeGrainType: 'auto',
|
||||
xaxis: 'timestamp',
|
||||
yaxis: '',
|
||||
spliton: '',
|
||||
},
|
||||
};
|
||||
|
||||
resultFormats: ResultFormat[];
|
||||
workspaces: any[];
|
||||
showHelp: boolean;
|
||||
showLastQuery: boolean;
|
||||
lastQuery: string;
|
||||
lastQueryError?: string;
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, $injector, private templateSrv) {
|
||||
super($scope, $injector);
|
||||
|
||||
_.defaultsDeep(this.target, this.defaults);
|
||||
|
||||
this.migrateTimeGrains();
|
||||
|
||||
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
|
||||
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
|
||||
this.resultFormats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
|
||||
if (this.target.queryType === 'Azure Log Analytics') {
|
||||
this.getWorkspaces();
|
||||
}
|
||||
}
|
||||
|
||||
onDataReceived(dataList) {
|
||||
this.lastQueryError = undefined;
|
||||
this.lastQuery = '';
|
||||
|
||||
const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId });
|
||||
if (anySeriesFromQuery) {
|
||||
this.lastQuery = anySeriesFromQuery.query;
|
||||
}
|
||||
}
|
||||
|
||||
onDataError(err) {
|
||||
this.handleQueryCtrlError(err);
|
||||
}
|
||||
|
||||
handleQueryCtrlError(err) {
|
||||
if (err.query && err.query.refId && err.query.refId !== this.target.refId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.error && err.error.data && err.error.data.error && err.error.data.error.innererror) {
|
||||
if (err.error.data.error.innererror.innererror) {
|
||||
this.lastQueryError = err.error.data.error.innererror.innererror.message;
|
||||
} else {
|
||||
this.lastQueryError = err.error.data.error.innererror.message;
|
||||
}
|
||||
} else if (err.error && err.error.data && err.error.data.error) {
|
||||
this.lastQueryError = err.error.data.error.message;
|
||||
} else if (err.error && err.error.data) {
|
||||
this.lastQueryError = err.error.data.message;
|
||||
} else if (err.data && err.data.error) {
|
||||
this.lastQueryError = err.data.error.message;
|
||||
} else if (err.data && err.data.message) {
|
||||
this.lastQueryError = err.data.message;
|
||||
} else {
|
||||
this.lastQueryError = err;
|
||||
}
|
||||
}
|
||||
|
||||
migrateTimeGrains() {
|
||||
if (this.target.azureMonitor.timeGrainUnit) {
|
||||
if (this.target.azureMonitor.timeGrain !== 'auto') {
|
||||
this.target.azureMonitor.timeGrain = TimegrainConverter.createISO8601Duration(
|
||||
this.target.azureMonitor.timeGrain,
|
||||
this.target.azureMonitor.timeGrainUnit
|
||||
);
|
||||
}
|
||||
|
||||
delete this.target.azureMonitor.timeGrainUnit;
|
||||
this.onMetricNameChange();
|
||||
}
|
||||
}
|
||||
|
||||
replace(variable: string) {
|
||||
return this.templateSrv.replace(variable, this.panelCtrl.panel.scopedVars);
|
||||
}
|
||||
|
||||
onQueryTypeChange() {
|
||||
if (this.target.queryType === 'Azure Log Analytics') {
|
||||
return this.getWorkspaces();
|
||||
}
|
||||
}
|
||||
|
||||
/* Azure Monitor Section */
|
||||
getResourceGroups(query) {
|
||||
if (this.target.queryType !== 'Azure Monitor' || !this.datasource.azureMonitorDatasource.isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.datasource.getResourceGroups().catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
getMetricDefinitions(query) {
|
||||
if (
|
||||
this.target.queryType !== 'Azure Monitor' ||
|
||||
!this.target.azureMonitor.resourceGroup ||
|
||||
this.target.azureMonitor.resourceGroup === this.defaultDropdownValue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return this.datasource
|
||||
.getMetricDefinitions(this.replace(this.target.azureMonitor.resourceGroup))
|
||||
.catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
getResourceNames(query) {
|
||||
if (
|
||||
this.target.queryType !== 'Azure Monitor' ||
|
||||
!this.target.azureMonitor.resourceGroup ||
|
||||
this.target.azureMonitor.resourceGroup === this.defaultDropdownValue ||
|
||||
!this.target.azureMonitor.metricDefinition ||
|
||||
this.target.azureMonitor.metricDefinition === this.defaultDropdownValue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.datasource
|
||||
.getResourceNames(
|
||||
this.replace(this.target.azureMonitor.resourceGroup),
|
||||
this.replace(this.target.azureMonitor.metricDefinition)
|
||||
)
|
||||
.catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
getMetricNames(query) {
|
||||
if (
|
||||
this.target.queryType !== 'Azure Monitor' ||
|
||||
!this.target.azureMonitor.resourceGroup ||
|
||||
this.target.azureMonitor.resourceGroup === this.defaultDropdownValue ||
|
||||
!this.target.azureMonitor.metricDefinition ||
|
||||
this.target.azureMonitor.metricDefinition === this.defaultDropdownValue ||
|
||||
!this.target.azureMonitor.resourceName ||
|
||||
this.target.azureMonitor.resourceName === this.defaultDropdownValue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.datasource
|
||||
.getMetricNames(
|
||||
this.replace(this.target.azureMonitor.resourceGroup),
|
||||
this.replace(this.target.azureMonitor.metricDefinition),
|
||||
this.replace(this.target.azureMonitor.resourceName)
|
||||
)
|
||||
.catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
onResourceGroupChange() {
|
||||
this.target.azureMonitor.metricDefinition = this.defaultDropdownValue;
|
||||
this.target.azureMonitor.resourceName = this.defaultDropdownValue;
|
||||
this.target.azureMonitor.metricName = this.defaultDropdownValue;
|
||||
this.target.azureMonitor.dimensions = [];
|
||||
this.target.azureMonitor.dimension = '';
|
||||
}
|
||||
|
||||
onMetricDefinitionChange() {
|
||||
this.target.azureMonitor.resourceName = this.defaultDropdownValue;
|
||||
this.target.azureMonitor.metricName = this.defaultDropdownValue;
|
||||
this.target.azureMonitor.dimensions = [];
|
||||
this.target.azureMonitor.dimension = '';
|
||||
}
|
||||
|
||||
onResourceNameChange() {
|
||||
this.target.azureMonitor.metricName = this.defaultDropdownValue;
|
||||
this.target.azureMonitor.dimensions = [];
|
||||
this.target.azureMonitor.dimension = '';
|
||||
}
|
||||
|
||||
onMetricNameChange() {
|
||||
if (!this.target.azureMonitor.metricName || this.target.azureMonitor.metricName === this.defaultDropdownValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.datasource
|
||||
.getMetricMetadata(
|
||||
this.replace(this.target.azureMonitor.resourceGroup),
|
||||
this.replace(this.target.azureMonitor.metricDefinition),
|
||||
this.replace(this.target.azureMonitor.resourceName),
|
||||
this.replace(this.target.azureMonitor.metricName)
|
||||
)
|
||||
.then(metadata => {
|
||||
this.target.azureMonitor.aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType];
|
||||
this.target.azureMonitor.aggregation = metadata.primaryAggType;
|
||||
this.target.azureMonitor.timeGrains = [{ text: 'auto', value: 'auto' }].concat(metadata.supportedTimeGrains);
|
||||
|
||||
this.target.azureMonitor.dimensions = metadata.dimensions;
|
||||
if (metadata.dimensions.length > 0) {
|
||||
this.target.azureMonitor.dimension = metadata.dimensions[0].value;
|
||||
}
|
||||
return this.refresh();
|
||||
})
|
||||
.catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
getAutoInterval() {
|
||||
if (this.target.azureMonitor.timeGrain === 'auto') {
|
||||
return TimegrainConverter.findClosestTimeGrain(
|
||||
this.templateSrv.builtIns.__interval.value,
|
||||
_.map(this.target.azureMonitor.timeGrains, o =>
|
||||
TimegrainConverter.createKbnUnitFromISO8601Duration(o.value)
|
||||
) || ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d']
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/* Azure Log Analytics */
|
||||
|
||||
getWorkspaces() {
|
||||
return this.datasource.azureLogAnalyticsDatasource
|
||||
.getWorkspaces()
|
||||
.then(list => {
|
||||
this.workspaces = list;
|
||||
if (list.length > 0 && !this.target.azureLogAnalytics.workspace) {
|
||||
this.target.azureLogAnalytics.workspace = list[0].value;
|
||||
}
|
||||
})
|
||||
.catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
getAzureLogAnalyticsSchema() {
|
||||
return this.getWorkspaces()
|
||||
.then(() => {
|
||||
return this.datasource.azureLogAnalyticsDatasource.getSchema(this.target.azureLogAnalytics.workspace);
|
||||
})
|
||||
.catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
onLogAnalyticsQueryChange = (nextQuery: string) => {
|
||||
this.target.azureLogAnalytics.query = nextQuery;
|
||||
}
|
||||
|
||||
onLogAnalyticsQueryExecute = () => {
|
||||
this.panelCtrl.refresh();
|
||||
}
|
||||
|
||||
get templateVariables() {
|
||||
return this.templateSrv.variables.map(t => '$' + t.name);
|
||||
}
|
||||
|
||||
/* Application Insights Section */
|
||||
|
||||
getAppInsightsAutoInterval() {
|
||||
const interval = this.templateSrv.builtIns.__interval.value;
|
||||
if (interval[interval.length - 1] === 's') {
|
||||
return '1m';
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
getAppInsightsMetricNames() {
|
||||
if (!this.datasource.appInsightsDatasource.isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.datasource.getAppInsightsMetricNames().catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
getAppInsightsColumns() {
|
||||
return this.datasource.getAppInsightsColumns(this.target.refId);
|
||||
}
|
||||
|
||||
onAppInsightsColumnChange() {
|
||||
return this.refresh();
|
||||
}
|
||||
|
||||
onAppInsightsMetricNameChange() {
|
||||
if (!this.target.appInsights.metricName || this.target.appInsights.metricName === this.defaultDropdownValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.datasource
|
||||
.getAppInsightsMetricMetadata(this.replace(this.target.appInsights.metricName))
|
||||
.then(aggData => {
|
||||
this.target.appInsights.aggOptions = aggData.supportedAggTypes;
|
||||
this.target.appInsights.groupByOptions = aggData.supportedGroupBy;
|
||||
this.target.appInsights.aggregation = aggData.primaryAggType;
|
||||
return this.refresh();
|
||||
})
|
||||
.catch(this.handleQueryCtrlError.bind(this));
|
||||
}
|
||||
|
||||
getAppInsightsGroupBySegments(query) {
|
||||
return _.map(this.target.appInsights.groupByOptions, option => {
|
||||
return { text: option, value: option };
|
||||
});
|
||||
}
|
||||
|
||||
resetAppInsightsGroupBy() {
|
||||
this.target.appInsights.groupBy = 'none';
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
updateTimeGrainType() {
|
||||
if (this.target.appInsights.timeGrainType === 'specific') {
|
||||
this.target.appInsights.timeGrain = '1';
|
||||
this.target.appInsights.timeGrainUnit = 'minute';
|
||||
} else {
|
||||
this.target.appInsights.timeGrain = '';
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
toggleEditorMode() {
|
||||
this.target.appInsights.rawQuery = !this.target.appInsights.rawQuery;
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
Format the legend keys any way you want by using alias patterns.
|
||||
|
||||
- Example for Azure Monitor: `dimension: {{dimensionvalue}}`
|
||||
- Example for Application Insights: `server: {{groupbyvalue}}`
|
||||
|
||||
#### Alias Patterns for Application Insights
|
||||
|
||||
- {{groupbyvalue}} = replaced with the value of the group by
|
||||
- {{groupbyname}} = replaced with the name/label of the group by
|
||||
- {{metric}} = replaced with metric name (e.g. requests/count)
|
||||
|
||||
#### Alias Patterns for Azure Monitor
|
||||
|
||||
- {{resourcegroup}} = replaced with the value of the Resource Group
|
||||
- {{namespace}} = replaced with the value of the Namespace (e.g. Microsoft.Compute/virtualMachines)
|
||||
- {{resourcename}} = replaced with the value of the Resource Name
|
||||
- {{metric}} = replaced with metric name (e.g. Percentage CPU)
|
||||
- {{dimensionname}} = replaced with dimension key/label (e.g. blobtype)
|
||||
- {{dimensionvalue}} = replaced with dimension value (e.g. BlockBlob)
|
||||
|
||||
#### Filter Expressions for Application Insights
|
||||
|
||||
The filter field takes an OData filter expression.
|
||||
|
||||
Examples:
|
||||
|
||||
- `client/city eq 'Boydton'`
|
||||
- `client/city ne 'Boydton'`
|
||||
- `client/city ne 'Boydton' and client/city ne 'Dublin'`
|
||||
- `client/city eq 'Boydton' or client/city eq 'Dublin'`
|
||||
|
||||
#### Writing Queries for Template Variables
|
||||
|
||||
See the [docs](https://github.com/grafana/azure-monitor-datasource#templating-with-variables) for details and examples.
|
@ -0,0 +1,23 @@
|
||||
import TimeGrainConverter from './time_grain_converter';
|
||||
|
||||
describe('TimeGrainConverter', () => {
|
||||
describe('with duration of PT1H', () => {
|
||||
it('should convert it to text', () => {
|
||||
expect(TimeGrainConverter.createTimeGrainFromISO8601Duration('PT1H')).toEqual('1 hour');
|
||||
});
|
||||
|
||||
it('should convert it to kbn', () => {
|
||||
expect(TimeGrainConverter.createKbnUnitFromISO8601Duration('PT1H')).toEqual('1h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with duration of P1D', () => {
|
||||
it('should convert it to text', () => {
|
||||
expect(TimeGrainConverter.createTimeGrainFromISO8601Duration('P1D')).toEqual('1 day');
|
||||
});
|
||||
|
||||
it('should convert it to kbn', () => {
|
||||
expect(TimeGrainConverter.createKbnUnitFromISO8601Duration('P1D')).toEqual('1d');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,122 @@
|
||||
import _ from 'lodash';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
export default class TimeGrainConverter {
|
||||
static createISO8601Duration(timeGrain, timeGrainUnit) {
|
||||
const timeIntervals = ['hour', 'minute', 'h', 'm'];
|
||||
if (_.includes(timeIntervals, timeGrainUnit)) {
|
||||
return `PT${timeGrain}${timeGrainUnit[0].toUpperCase()}`;
|
||||
}
|
||||
|
||||
return `P${timeGrain}${timeGrainUnit[0].toUpperCase()}`;
|
||||
}
|
||||
|
||||
static createISO8601DurationFromInterval(interval: string) {
|
||||
const timeGrain = +interval.slice(0, interval.length - 1);
|
||||
const unit = interval[interval.length - 1];
|
||||
|
||||
if (interval.indexOf('ms') > -1) {
|
||||
return TimeGrainConverter.createISO8601Duration(1, 'm');
|
||||
}
|
||||
|
||||
if (interval[interval.length - 1] === 's') {
|
||||
let toMinutes = (timeGrain * 60) % 60;
|
||||
|
||||
if (toMinutes < 1) {
|
||||
toMinutes = 1;
|
||||
}
|
||||
|
||||
return TimeGrainConverter.createISO8601Duration(toMinutes, 'm');
|
||||
}
|
||||
|
||||
return TimeGrainConverter.createISO8601Duration(timeGrain, unit);
|
||||
}
|
||||
|
||||
static findClosestTimeGrain(interval, allowedTimeGrains) {
|
||||
const timeGrains = _.filter(allowedTimeGrains, o => o !== 'auto');
|
||||
|
||||
let closest = timeGrains[0];
|
||||
const intervalMs = kbn.interval_to_ms(interval);
|
||||
|
||||
for (let i = 0; i < timeGrains.length; i++) {
|
||||
// abs (num - val) < abs (num - curr):
|
||||
if (intervalMs > kbn.interval_to_ms(timeGrains[i])) {
|
||||
if (i + 1 < timeGrains.length) {
|
||||
closest = timeGrains[i + 1];
|
||||
} else {
|
||||
closest = timeGrains[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
static createTimeGrainFromISO8601Duration(duration: string) {
|
||||
let offset = 1;
|
||||
if (duration.substring(0, 2) === 'PT') {
|
||||
offset = 2;
|
||||
}
|
||||
|
||||
const value = duration.substring(offset, duration.length - 1);
|
||||
const unit = duration.substring(duration.length - 1);
|
||||
|
||||
return value + ' ' + TimeGrainConverter.timeUnitToText(+value, unit);
|
||||
}
|
||||
|
||||
static timeUnitToText(value: number, unit: string) {
|
||||
let text = '';
|
||||
|
||||
if (unit === 'S') {
|
||||
text = 'second';
|
||||
}
|
||||
if (unit === 'M') {
|
||||
text = 'minute';
|
||||
}
|
||||
if (unit === 'H') {
|
||||
text = 'hour';
|
||||
}
|
||||
if (unit === 'D') {
|
||||
text = 'day';
|
||||
}
|
||||
|
||||
if (value > 1) {
|
||||
return text + 's';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
static createKbnUnitFromISO8601Duration(duration: string) {
|
||||
if (duration === 'auto') {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
let offset = 1;
|
||||
if (duration.substring(0, 2) === 'PT') {
|
||||
offset = 2;
|
||||
}
|
||||
|
||||
const value = duration.substring(offset, duration.length - 1);
|
||||
const unit = duration.substring(duration.length - 1);
|
||||
|
||||
return value + TimeGrainConverter.timeUnitToKbn(+value, unit);
|
||||
}
|
||||
|
||||
static timeUnitToKbn(value: number, unit: string) {
|
||||
if (unit === 'S') {
|
||||
return 's';
|
||||
}
|
||||
if (unit === 'M') {
|
||||
return 'm';
|
||||
}
|
||||
if (unit === 'H') {
|
||||
return 'h';
|
||||
}
|
||||
if (unit === 'D') {
|
||||
return 'd';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
import { SemVersion, isVersionGtOrEq } from './version';
|
||||
|
||||
describe('SemVersion', () => {
|
||||
let version = '1.0.0-alpha.1';
|
||||
|
||||
describe('parsing', () => {
|
||||
it('should parse version properly', () => {
|
||||
const semver = new SemVersion(version);
|
||||
expect(semver.major).toBe(1);
|
||||
expect(semver.minor).toBe(0);
|
||||
expect(semver.patch).toBe(0);
|
||||
expect(semver.meta).toBe('alpha.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparing', () => {
|
||||
beforeEach(() => {
|
||||
version = '3.4.5';
|
||||
});
|
||||
|
||||
it('should detect greater version properly', () => {
|
||||
const semver = new SemVersion(version);
|
||||
const cases = [
|
||||
{ value: '3.4.5', expected: true },
|
||||
{ value: '3.4.4', expected: true },
|
||||
{ value: '3.4.6', expected: false },
|
||||
{ value: '4', expected: false },
|
||||
{ value: '3.5', expected: false },
|
||||
];
|
||||
cases.forEach(testCase => {
|
||||
expect(semver.isGtOrEq(testCase.value)).toBe(testCase.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVersionGtOrEq', () => {
|
||||
it('should compare versions properly (a >= b)', () => {
|
||||
const cases = [
|
||||
{ values: ['3.4.5', '3.4.5'], expected: true },
|
||||
{ values: ['3.4.5', '3.4.4'], expected: true },
|
||||
{ values: ['3.4.5', '3.4.6'], expected: false },
|
||||
{ values: ['3.4', '3.4.0'], expected: true },
|
||||
{ values: ['3', '3.0.0'], expected: true },
|
||||
{ values: ['3.1.1-beta1', '3.1'], expected: true },
|
||||
{ values: ['3.4.5', '4'], expected: false },
|
||||
{ values: ['3.4.5', '3.5'], expected: false },
|
||||
];
|
||||
cases.forEach(testCase => {
|
||||
expect(isVersionGtOrEq(testCase.values[0], testCase.values[1])).toBe(testCase.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,34 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
const versionPattern = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z\.]+))?/;
|
||||
|
||||
export class SemVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
meta: string;
|
||||
|
||||
constructor(version: string) {
|
||||
const match = versionPattern.exec(version);
|
||||
if (match) {
|
||||
this.major = Number(match[1]);
|
||||
this.minor = Number(match[2] || 0);
|
||||
this.patch = Number(match[3] || 0);
|
||||
this.meta = match[4];
|
||||
}
|
||||
}
|
||||
|
||||
isGtOrEq(version: string): boolean {
|
||||
const compared = new SemVersion(version);
|
||||
return !(this.major < compared.major || this.minor < compared.minor || this.patch < compared.patch);
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
return _.isNumber(this.major);
|
||||
}
|
||||
}
|
||||
|
||||
export function isVersionGtOrEq(a: string, b: string): boolean {
|
||||
const aSemver = new SemVersion(a);
|
||||
return aSemver.isGtOrEq(b);
|
||||
}
|
@ -226,26 +226,30 @@ class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryF
|
||||
const chooserText = getChooserText(syntaxLoaded, hasLogLabels);
|
||||
|
||||
return (
|
||||
<div className="prom-query-field">
|
||||
<div className="prom-query-field-tools">
|
||||
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels} loadData={this.loadOptions}>
|
||||
<button className="btn navbar-button navbar-button--tight" disabled={!syntaxLoaded}>
|
||||
{chooserText}
|
||||
</button>
|
||||
</Cascader>
|
||||
<>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels} loadData={this.loadOptions}>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
|
||||
{chooserText} <i className="fa fa-caret-down" />
|
||||
</button>
|
||||
</Cascader>
|
||||
</div>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialQuery={initialQuery.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a Loki query"
|
||||
portalOrigin="loki"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prom-query-field-wrapper">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialQuery={initialQuery.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a Loki query"
|
||||
portalOrigin="loki"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
<div>
|
||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
|
||||
{hint ? (
|
||||
<div className="prom-query-field-info text-warning">
|
||||
@ -258,7 +262,7 @@ class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryF
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -248,39 +248,41 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
const chooserText = syntaxLoaded ? 'Metrics' : 'Loading metrics...';
|
||||
|
||||
return (
|
||||
<div className="prom-query-field">
|
||||
<div className="prom-query-field-tools">
|
||||
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
|
||||
<button className="btn navbar-button navbar-button--tight" disabled={!syntaxLoaded}>
|
||||
{chooserText}
|
||||
</button>
|
||||
</Cascader>
|
||||
<>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
|
||||
{chooserText} <i className="fa fa-caret-down" />
|
||||
</button>
|
||||
</Cascader>
|
||||
</div>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialQuery={initialQuery.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
portalOrigin="prometheus"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prom-query-field-wrapper">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialQuery={initialQuery.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
portalOrigin="prometheus"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
|
||||
{hint ? (
|
||||
<div className="prom-query-field-info text-warning">
|
||||
{hint.label}{' '}
|
||||
{hint.fix ? (
|
||||
<a className="text-link muted" onClick={this.onClickHintFix}>
|
||||
{hint.fix.label}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
|
||||
{hint ? (
|
||||
<div className="prom-query-field-info text-warning">
|
||||
{hint.label}{' '}
|
||||
{hint.fix ? (
|
||||
<a className="text-link muted" onClick={this.onClickHintFix}>
|
||||
{hint.fix.label}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ export default class StackdriverDatasource {
|
||||
intervalMs: options.intervalMs,
|
||||
datasourceId: this.id,
|
||||
metricType: this.templateSrv.replace(t.metricType, options.scopedVars || {}),
|
||||
primaryAggregation: this.templateSrv.replace(t.crossSeriesReducer || 'REDUCE_MEAN', options.scopedVars || {}),
|
||||
crossSeriesReducer: this.templateSrv.replace(t.crossSeriesReducer || 'REDUCE_MEAN', options.scopedVars || {}),
|
||||
perSeriesAligner: this.templateSrv.replace(t.perSeriesAligner, options.scopedVars || {}),
|
||||
alignmentPeriod: this.templateSrv.replace(t.alignmentPeriod, options.scopedVars || {}),
|
||||
groupBys: this.interpolateGroupBys(t.groupBys, options.scopedVars),
|
||||
@ -138,7 +138,7 @@ export default class StackdriverDatasource {
|
||||
refId: 'annotationQuery',
|
||||
datasourceId: this.id,
|
||||
metricType: this.templateSrv.replace(annotation.target.metricType, options.scopedVars || {}),
|
||||
primaryAggregation: 'REDUCE_NONE',
|
||||
crossSeriesReducer: 'REDUCE_NONE',
|
||||
perSeriesAligner: 'ALIGN_NONE',
|
||||
title: this.templateSrv.replace(annotation.target.title, options.scopedVars || {}),
|
||||
text: this.templateSrv.replace(annotation.target.text, options.scopedVars || {}),
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "Gauge",
|
||||
"id": "gauge",
|
||||
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Project",
|
||||
|
@ -392,8 +392,8 @@ $panel-editor-tabs-line-color: #e3e3e3;
|
||||
$panel-editor-viz-item-bg-hover: darken($blue, 47%);
|
||||
$panel-editor-viz-item-bg-hover-active: darken($orange, 45%);
|
||||
|
||||
$panel-options-group-border: 1px solid $dark-3;
|
||||
$panel-options-group-header-bg: linear-gradient(0deg, $gray-blue, $dark-1);
|
||||
$panel-options-group-border: none;
|
||||
$panel-options-group-header-bg: $gray-blue;
|
||||
|
||||
$panel-grid-placeholder-bg: darken($blue, 47%);
|
||||
$panel-grid-placeholder-shadow: 0 0 4px $blue;
|
||||
|
@ -400,8 +400,8 @@ $panel-editor-tabs-line-color: $dark-5;
|
||||
$panel-editor-viz-item-bg-hover: lighten($blue, 62%);
|
||||
$panel-editor-viz-item-bg-hover-active: lighten($orange, 34%);
|
||||
|
||||
$panel-options-group-border: 1px solid $gray-6;
|
||||
$panel-options-group-header-bg: linear-gradient(0deg, $gray-6, $gray-7);
|
||||
$panel-options-group-border: none;
|
||||
$panel-options-group-header-bg: $gray-5;
|
||||
|
||||
$panel-grid-placeholder-bg: lighten($blue, 62%);
|
||||
$panel-grid-placeholder-shadow: 0 0 4px $blue-light;
|
||||
|