diff --git a/.circleci/config.yml b/.circleci/config.yml index f8f0ba6789a..dc30554fc5c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -510,6 +510,7 @@ workflows: - grafana-docker-release: requires: - build-all + - build-all-enterprise - test-backend - test-frontend - codespell diff --git a/CHANGELOG.md b/CHANGELOG.md index 43743a3ba2a..571989ea506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 5.4.0 (unreleased) +* **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150) + +# 5.4.0-beta1 (2018-11-20) + ### New Features * **Alerting**: Introduce alert debouncing with the `FOR` setting. [#7886](https://github.com/grafana/grafana/issues/7886) & [#6202](https://github.com/grafana/grafana/issues/6202) @@ -12,12 +16,14 @@ * **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550) * **Graph**: Time regions support enabling highlight of weekdays and/or certain timespans [#5930](https://github.com/grafana/grafana/issues/5930) * **OAuth**: Automatic redirect to sign-in with OAuth [#11893](https://github.com/grafana/grafana/issues/11893), thx [@Nick-Triller](https://github.com/Nick-Triller) +* **Stackdriver**: Template query editor [#13561](https://github.com/grafana/grafana/issues/13561) ### Minor * **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043) * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) * **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy) +* **Cloudwatch**: CloudHSM metrics and dimensions [#14129](https://github.com/grafana/grafana/pull/14129), thx [@daktari](https://github.com/daktari) * **Cloudwatch**: Enable using variables in the stats field [#13810](https://github.com/grafana/grafana/issues/13810), thx [@mtanda](https://github.com/mtanda) * **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm) * **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367) @@ -37,10 +43,12 @@ * **Dashboard**: Fix render dashboard row drag handle only in edit mode [#13555](https://github.com/grafana/grafana/issues/13555), thx [@praveensastry](https://github.com/praveensastry) * **Teams**: Fix cannot select team if not included in initial search [#13425](https://github.com/grafana/grafana/issues/13425) * **Render**: Support full height screenshots using phantomjs render script [#13352](https://github.com/grafana/grafana/pull/13352), thx [@amuraru](https://github.com/amuraru) +* **HTTP API**: Support retrieving teams by user [#14120](https://github.com/grafana/grafana/pull/14120), thx [@supercharlesliu](https://github.com/supercharlesliu) +* **Metrics**: Add basic authentication to metrics endpoint [#13577](https://github.com/grafana/grafana/issues/13577), thx [@bobmshannon](https://github.com/bobmshannon) ### Breaking changes -* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited) +* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited). # 5.3.4 (2018-11-13) diff --git a/conf/defaults.ini b/conf/defaults.ini index 679a6a88eb7..306c625d980 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -490,6 +490,10 @@ enabled = false enabled = true interval_seconds = 10 +#If both are set, basic auth will be required for the metrics endpoint. +basic_auth_username = +basic_auth_password = + # Send internal Grafana metrics to graphite [metrics.graphite] # Enable by setting the address setting (ex localhost:2003) diff --git a/devenv/dev-dashboards/testdata_alerts.json b/devenv/dev-dashboards/testdata_alerts.json index 8fd7d4d9db5..9f36638c012 100644 --- a/devenv/dev-dashboards/testdata_alerts.json +++ b/devenv/dev-dashboards/testdata_alerts.json @@ -104,6 +104,7 @@ } ], "timeFrom": null, + "timeRegions": [], "timeShift": null, "title": "Always OK", "tooltip": { @@ -232,6 +233,7 @@ } ], "timeFrom": null, + "timeRegions": [], "timeShift": null, "title": "Always Alerting", "tooltip": { @@ -362,6 +364,7 @@ } ], "timeFrom": null, + "timeRegions": [], "timeShift": null, "title": "No data", "tooltip": { @@ -432,7 +435,7 @@ "for": "1m", "frequency": "1m", "handler": 1, - "name": "TestData - Always Alerting with For", + "name": "TestData - Always Pending", "noDataState": "no_data", "notifications": [] }, @@ -492,6 +495,138 @@ } ], "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Always Alerting with For", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "alert": { + "conditions": [ + { + "evaluator": { + "params": [ + 100 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A", + "5m", + "now" + ] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "for": "900000h", + "frequency": "1m", + "handler": 1, + "name": "Always Pending", + "noDataState": "no_data", + "notifications": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 1, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 7, + "isNew": true, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenario": "random_walk", + "scenarioId": "csv_metric_values", + "stringInput": "200,445,100,150,200,220,190", + "target": "" + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "gt", + "value": 100 + } + ], + "timeFrom": null, + "timeRegions": [], "timeShift": null, "title": "Always Alerting with For", "tooltip": { @@ -573,5 +708,5 @@ "timezone": "browser", "title": "Alerting with TestData", "uid": "7MeksYbmk", - "version": 1 + "version": 7 } \ No newline at end of file diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md index 387132ecfeb..036ff97056d 100644 --- a/docs/sources/alerting/rules.md +++ b/docs/sources/alerting/rules.md @@ -54,7 +54,10 @@ Here you can specify the name of the alert rule and how often the scheduler shou If an alert rule has a configured `For` and the query violates the configured threshold it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications. -Typically, it's always a good idea to use this setting since its often worse to get false positive than wait a few minutes before the alert notification triggers. +Typically, it's always a good idea to use this setting since it's often worse to get false positive than wait a few minutes before the alert notification triggers. Looking at the `Alert list` or `Alert list panels` you will be able to see alerts in pending state. + +Below you can see an example timeline of an alert using the `For` setting. At ~16:04 the alert state changes to `Pending` and after 4 minutes it changes to `Alerting` which is when alert notifications are sent. Once the series falls back to normal the alert rule goes back to `OK`. +{{< imgbox img="/img/docs/v54/alerting-for-dark-theme.png" caption="Alerting For" >}} {{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}} @@ -71,7 +74,7 @@ avg() OF query(A, 15m, now) IS BELOW 14 ``` - `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function. -- `query(A, 15m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `15m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data. +- `query(A, 15m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `15m, now` means 15 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data. - `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold. The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially. diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md index d19dbe4ea50..2c14d897d8e 100644 --- a/docs/sources/features/datasources/stackdriver.md +++ b/docs/sources/features/datasources/stackdriver.md @@ -158,9 +158,9 @@ Example Result: `compute.googleapis.com/instance/cpu/usage_time - server1-prod` It is also possible to resolve the name of the Monitored Resource Type. -| Alias Pattern Format | Description | Example Result | -| ------------------------ | ------------------------------------------------| ---------------- | -| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` | +| Alias Pattern Format | Description | Example Result | +| -------------------- | ----------------------------------------------- | -------------- | +| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` | Example Alias By: `{{resource.type}} - {{metric.type}}` @@ -177,7 +177,17 @@ types of template variables. ### Query Variable -Writing variable queries is not supported yet. +Variable of the type *Query* allows you to query Stackdriver for various types of data. The Stackdriver data source plugin provides the following `Query Types`. + +| Name | Description | +| ------------------- | ------------------------------------------------------------------------------------------------- | +| *Metric Types* | Returns a list of metric type names that are available for the specified service. | +| *Labels Keys* | Returns a list of keys for `metric label` and `resource label` in the specified metric. | +| *Labels Values* | Returns a list of values for the label in the specified metric. | +| *Resource Types* | Returns a list of resource types for the the specified metric. | +| *Aggregations* | Returns a list of aggregations (cross series reducers) for the the specified metric. | +| *Aligners* | Returns a list of aligners (per series aligners) for the the specified metric. | +| *Alignment periods* | Returns a list of all alignment periods that are available in Stackdriver query editor in Grafana | ### Using variables in queries diff --git a/docs/sources/guides/whats-new-in-v5-4.md b/docs/sources/guides/whats-new-in-v5-4.md new file mode 100644 index 00000000000..e0a3fd5c4ba --- /dev/null +++ b/docs/sources/guides/whats-new-in-v5-4.md @@ -0,0 +1,18 @@ ++++ +title = "What's New in Grafana v5.4" +description = "Feature & improvement highlights for Grafana v5.4" +keywords = ["grafana", "new", "documentation", "5.4"] +type = "docs" +[menu.docs] +name = "Version 5.4" +identifier = "v5.4" +parent = "whatsnew" +weight = -10 ++++ + +# What's New in Grafana v5.4 + +## Changelog + +Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index b9047187b2d..047d4473603 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -226,6 +226,40 @@ Content-Type: application/json ] ``` +## Get Teams for user + +`GET /api/users/:id/teams` + +**Example Request**: + +```http +GET /api/users/1/teams HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + +Requires basic authentication and that the authenticated user is a Grafana Admin. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id":1, + "orgId":1, + "name":"team1", + "email":"", + "avatarUrl":"/avatar/3fcfe295eae3bcb67a49349377428a66", + "memberCount":1 + } +] +``` + + ## User ## Actual User diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 8d156e739bf..30ef020a3de 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -454,6 +454,12 @@ Ex `filters = sqlstore:debug` ### enabled Enable metrics reporting. defaults true. Available via HTTP API `/metrics`. +### basic_auth_username +If set configures the username to use for basic authentication on the metrics endpoint. + +### basic_auth_password +If set configures the password to use for basic authentication on the metrics endpoint. + ### interval_seconds Flush/Write interval when sending metrics to external TSDB. Defaults to 10s. diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh index 2f59e436d95..e10e0d691e8 100755 --- a/packaging/docker/build-enterprise.sh +++ b/packaging/docker/build-enterprise.sh @@ -1,9 +1,17 @@ #!/bin/sh set -e -_grafana_tag=$1 +_raw_grafana_tag=$1 _docker_repo=${2:-grafana/grafana-enterprise} +if echo "$_raw_grafana_tag" | grep -q "^v"; then + _grafana_tag=$(echo "${_raw_grafana_tag}" | cut -d "v" -f 2) +else + _grafana_tag="${_raw_grafana_tag}" +fi + +echo "Building and deploying ${_docker_repo}:${_grafana_tag}" + docker build \ --tag "${_docker_repo}:${_grafana_tag}"\ --no-cache=true \ diff --git a/pkg/api/api.go b/pkg/api/api.go index c372debdb72..0526ee80afe 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -140,6 +140,7 @@ func (hs *HTTPServer) registerRoutes() { usersRoute.Get("/", Wrap(SearchUsers)) usersRoute.Get("/search", Wrap(SearchUsersWithPaging)) usersRoute.Get("/:id", Wrap(GetUserByID)) + usersRoute.Get("/:id/teams", Wrap(GetUserTeams)) usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail)) diff --git a/pkg/api/basic_auth.go b/pkg/api/basic_auth.go new file mode 100644 index 00000000000..376cfb24c91 --- /dev/null +++ b/pkg/api/basic_auth.go @@ -0,0 +1,19 @@ +package api + +import ( + "crypto/subtle" + macaron "gopkg.in/macaron.v1" +) + +// BasicAuthenticatedRequest parses the provided HTTP request for basic authentication credentials +// and returns true if the provided credentials match the expected username and password. +// Returns false if the request is unauthenticated. +// Uses constant-time comparison in order to mitigate timing attacks. +func BasicAuthenticatedRequest(req macaron.Request, expectedUser, expectedPass string) bool { + user, pass, ok := req.BasicAuth() + if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(expectedUser)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 { + return false + } + + return true +} diff --git a/pkg/api/basic_auth_test.go b/pkg/api/basic_auth_test.go new file mode 100644 index 00000000000..0b5051c3e2a --- /dev/null +++ b/pkg/api/basic_auth_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "encoding/base64" + "fmt" + "net/http" + "testing" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/macaron.v1" +) + +func TestBasicAuthenticatedRequest(t *testing.T) { + expectedUser := "prometheus" + expectedPass := "password" + + Convey("Given a valid set of basic auth credentials", t, func() { + httpReq, err := http.NewRequest("GET", "http://localhost:3000/metrics", nil) + So(err, ShouldBeNil) + req := macaron.Request{ + Request: httpReq, + } + encodedCreds := encodeBasicAuthCredentials(expectedUser, expectedPass) + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", encodedCreds)) + authenticated := BasicAuthenticatedRequest(req, expectedUser, expectedPass) + So(authenticated, ShouldBeTrue) + }) + + Convey("Given an invalid set of basic auth credentials", t, func() { + httpReq, err := http.NewRequest("GET", "http://localhost:3000/metrics", nil) + So(err, ShouldBeNil) + req := macaron.Request{ + Request: httpReq, + } + encodedCreds := encodeBasicAuthCredentials("invaliduser", "invalidpass") + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", encodedCreds)) + authenticated := BasicAuthenticatedRequest(req, expectedUser, expectedPass) + So(authenticated, ShouldBeFalse) + }) +} + +func encodeBasicAuthCredentials(user, pass string) string { + creds := fmt.Sprintf("%s:%s", user, pass) + return base64.StdEncoding.EncodeToString([]byte(creds)) +} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index ce28e4716ee..d4d7b41bec5 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -245,6 +245,11 @@ func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) { return } + if hs.metricsEndpointBasicAuthEnabled() && !BasicAuthenticatedRequest(ctx.Req, hs.Cfg.MetricsEndpointBasicAuthUsername, hs.Cfg.MetricsEndpointBasicAuthPassword) { + ctx.Resp.WriteHeader(http.StatusUnauthorized) + return + } + promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}). ServeHTTP(ctx.Resp, ctx.Req.Request) } @@ -299,3 +304,7 @@ func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string, }, )) } + +func (hs *HTTPServer) metricsEndpointBasicAuthEnabled() bool { + return hs.Cfg.MetricsEndpointBasicAuthUsername != "" && hs.Cfg.MetricsEndpointBasicAuthPassword != "" +} diff --git a/pkg/api/http_server_test.go b/pkg/api/http_server_test.go new file mode 100644 index 00000000000..0f99ae82db5 --- /dev/null +++ b/pkg/api/http_server_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestHTTPServer(t *testing.T) { + Convey("Given a HTTPServer", t, func() { + ts := &HTTPServer{ + Cfg: setting.NewCfg(), + } + + Convey("Given that basic auth on the metrics endpoint is enabled", func() { + ts.Cfg.MetricsEndpointBasicAuthUsername = "foo" + ts.Cfg.MetricsEndpointBasicAuthPassword = "bar" + + So(ts.metricsEndpointBasicAuthEnabled(), ShouldBeTrue) + }) + + Convey("Given that basic auth on the metrics endpoint is disabled", func() { + ts.Cfg.MetricsEndpointBasicAuthUsername = "" + ts.Cfg.MetricsEndpointBasicAuthPassword = "" + + So(ts.metricsEndpointBasicAuthEnabled(), ShouldBeFalse) + }) + }) +} diff --git a/pkg/api/user.go b/pkg/api/user.go index 7116ad83f3f..6db9c6f6baf 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -113,7 +113,16 @@ func GetSignedInUserOrgList(c *m.ReqContext) Response { // GET /api/user/teams func GetSignedInUserTeamList(c *m.ReqContext) Response { - query := m.GetTeamsByUserQuery{OrgId: c.OrgId, UserId: c.UserId} + return getUserTeamList(c.OrgId, c.UserId) +} + +// GET /api/users/:id/teams +func GetUserTeams(c *m.ReqContext) Response { + return getUserTeamList(c.OrgId, c.ParamsInt64(":id")) +} + +func getUserTeamList(userID int64, orgID int64) Response { + query := m.GetTeamsByUserQuery{OrgId: orgID, UserId: userID} if err := bus.Dispatch(&query); err != nil { return Error(500, "Failed to get user teams", err) @@ -122,11 +131,10 @@ func GetSignedInUserTeamList(c *m.ReqContext) Response { for _, team := range query.Result { team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name) } - return JSON(200, query.Result) } -// GET /api/user/:id/orgs +// GET /api/users/:id/orgs func GetUserOrgList(c *m.ReqContext) Response { return getUserOrgList(c.ParamsInt64(":id")) } diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 285bd7ff1c3..3bdaf0cc80e 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -54,7 +54,10 @@ func main() { if *profile { runtime.SetBlockProfileRate(1) go func() { - http.ListenAndServe(fmt.Sprintf("localhost:%d", *profilePort), nil) + err := http.ListenAndServe(fmt.Sprintf("localhost:%d", *profilePort), nil) + if err != nil { + panic(err) + } }() f, err := os.Create("trace.out") diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 2c67a06a843..d9fd1e62ce7 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -67,6 +67,7 @@ type GrafanaServerImpl struct { } func (g *GrafanaServerImpl) Run() error { + var err error g.loadConfiguration() g.writePIDFile() @@ -74,20 +75,38 @@ func (g *GrafanaServerImpl) Run() error { social.NewOAuthService() serviceGraph := inject.Graph{} - serviceGraph.Provide(&inject.Object{Value: bus.GetBus()}) - serviceGraph.Provide(&inject.Object{Value: g.cfg}) - serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) - serviceGraph.Provide(&inject.Object{Value: cache.New(5*time.Minute, 10*time.Minute)}) + err = serviceGraph.Provide(&inject.Object{Value: bus.GetBus()}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } + err = serviceGraph.Provide(&inject.Object{Value: g.cfg}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } + err = serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } + err = serviceGraph.Provide(&inject.Object{Value: cache.New(5*time.Minute, 10*time.Minute)}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } // self registered services services := registry.GetServices() // Add all services to dependency graph for _, service := range services { - serviceGraph.Provide(&inject.Object{Value: service.Instance}) + err = serviceGraph.Provide(&inject.Object{Value: service.Instance}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } } - serviceGraph.Provide(&inject.Object{Value: g}) + err = serviceGraph.Provide(&inject.Object{Value: g}) + if err != nil { + return fmt.Errorf("Failed to provide object to the graph: %v", err) + } // Inject dependencies to services if err := serviceGraph.Populate(); err != nil { @@ -144,6 +163,7 @@ func (g *GrafanaServerImpl) Run() error { } sendSystemdNotification("READY=1") + return g.childRoutines.Wait() } diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go index eef07c8c24a..b8ca85fb45b 100644 --- a/pkg/middleware/recovery.go +++ b/pkg/middleware/recovery.go @@ -115,6 +115,7 @@ func Recovery() macaron.Handler { c.Data["Title"] = "Server Error" c.Data["AppSubUrl"] = setting.AppSubUrl + c.Data["Theme"] = setting.DefaultTheme if setting.Env == setting.DEV { if theErr, ok := err.(error); ok { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 76a50eefb09..1417392fdf8 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -219,6 +219,8 @@ type Cfg struct { DisableBruteForceLoginProtection bool TempDataLifetime time.Duration MetricsEndpointEnabled bool + MetricsEndpointBasicAuthUsername string + MetricsEndpointBasicAuthPassword string EnableAlphaPanels bool EnterpriseLicensePath string } @@ -681,6 +683,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs") cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true) + cfg.MetricsEndpointBasicAuthUsername = iniFile.Section("metrics").Key("basic_auth_username").String() + cfg.MetricsEndpointBasicAuthPassword = iniFile.Section("metrics").Key("basic_auth_password").String() analytics := iniFile.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 437457df52a..8bb1ab6c928 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -126,6 +126,18 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo } eg.Go(func() error { + defer func() { + if err := recover(); err != nil { + plog.Error("Execute Query Panic", "error", err, "stack", log.Stack(1)) + if theErr, ok := err.(error); ok { + resultChan <- &tsdb.QueryResult{ + RefId: query.RefId, + Error: theErr, + } + } + } + }() + queryRes, err := e.executeQuery(ectx, query, queryContext) if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { return err @@ -146,6 +158,17 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo for region, getMetricDataQuery := range getMetricDataQueries { q := getMetricDataQuery eg.Go(func() error { + defer func() { + if err := recover(); err != nil { + plog.Error("Execute Get Metric Data Query Panic", "error", err, "stack", log.Stack(1)) + if theErr, ok := err.(error); ok { + resultChan <- &tsdb.QueryResult{ + Error: theErr, + } + } + } + }() + queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext) if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { return err @@ -188,8 +211,8 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatch return nil, err } - if endTime.Before(startTime) { - return nil, fmt.Errorf("Invalid time range: End time can't be before start time") + if !startTime.Before(endTime) { + return nil, fmt.Errorf("Invalid time range: Start time must be before end time") } params := &cloudwatch.GetMetricStatisticsInput{ diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index 32b8c910f2b..3b0b073eb06 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -1,9 +1,13 @@ package cloudwatch import ( + "context" "testing" "time" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/tsdb" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/components/null" @@ -14,6 +18,24 @@ import ( func TestCloudWatch(t *testing.T) { Convey("CloudWatch", t, func() { + Convey("executeQuery", func() { + e := &CloudWatchExecutor{ + DataSource: &models.DataSource{ + JsonData: simplejson.New(), + }, + } + + Convey("End time before start time should result in error", func() { + _, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")}) + So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") + }) + + Convey("End time equals start time should result in error", func() { + _, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")}) + So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") + }) + }) + Convey("can parse cloudwatch json model", func() { json := ` { diff --git a/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap b/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap index 748fcbee4aa..b376ab24934 100644 --- a/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap +++ b/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap @@ -14,3 +14,4 @@ exports[`PickerOption renders correctly 1`] = ` `; + \ No newline at end of file diff --git a/public/app/core/services/analytics.ts b/public/app/core/services/analytics.ts index 40e20b16a29..be4371adb26 100644 --- a/public/app/core/services/analytics.ts +++ b/public/app/core/services/analytics.ts @@ -26,7 +26,7 @@ export class Analytics { init() { this.$rootScope.$on('$viewContentLoaded', () => { - const track = { location: this.$location.url() }; + const track = { page: this.$location.url() }; const ga = (window as any).ga || this.gaInit(); ga('set', track); ga('send', 'pageview'); diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 4252730338d..37d3d3bfac9 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -1,5 +1,13 @@ -import { DEFAULT_RANGE, serializeStateToUrlParam, parseUrlState } from './explore'; +import { + DEFAULT_RANGE, + serializeStateToUrlParam, + parseUrlState, + updateHistory, + clearHistory, + hasNonEmptyQuery, +} from './explore'; import { ExploreState } from 'app/types/explore'; +import store from 'app/core/store'; const DEFAULT_EXPLORE_STATE: ExploreState = { datasource: null, @@ -10,7 +18,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = { exploreDatasources: [], graphRange: DEFAULT_RANGE, history: [], - queries: [], + initialQueries: [], queryTransactions: [], range: DEFAULT_RANGE, showingGraph: true, @@ -33,10 +41,10 @@ describe('state functions', () => { it('returns a valid Explore state from URL parameter', () => { const paramValue = - '%7B"datasource":"Local","queries":%5B%7B"query":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D'; + '%7B"datasource":"Local","queries":%5B%7B"expr":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D'; expect(parseUrlState(paramValue)).toMatchObject({ datasource: 'Local', - queries: [{ query: 'metric' }], + queries: [{ expr: 'metric' }], range: { from: 'now-1h', to: 'now', @@ -45,10 +53,10 @@ describe('state functions', () => { }); it('returns a valid Explore state from a compact URL parameter', () => { - const paramValue = '%5B"now-1h","now","Local","metric"%5D'; + const paramValue = '%5B"now-1h","now","Local",%7B"expr":"metric"%7D%5D'; expect(parseUrlState(paramValue)).toMatchObject({ datasource: 'Local', - queries: [{ query: 'metric' }], + queries: [{ expr: 'metric' }], range: { from: 'now-1h', to: 'now', @@ -66,18 +74,20 @@ describe('state functions', () => { from: 'now-5h', to: 'now', }, - queries: [ + initialQueries: [ { - query: 'metric{test="a/b"}', + refId: '1', + expr: 'metric{test="a/b"}', }, { - query: 'super{foo="x/z"}', + refId: '2', + expr: 'super{foo="x/z"}', }, ], }; expect(serializeStateToUrlParam(state)).toBe( - '{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' + - '{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}' + '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' + + '{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}' ); }); @@ -89,17 +99,19 @@ describe('state functions', () => { from: 'now-5h', to: 'now', }, - queries: [ + initialQueries: [ { - query: 'metric{test="a/b"}', + refId: '1', + expr: 'metric{test="a/b"}', }, { - query: 'super{foo="x/z"}', + refId: '2', + expr: 'super{foo="x/z"}', }, ], }; expect(serializeStateToUrlParam(state, true)).toBe( - '["now-5h","now","foo","metric{test=\\"a/b\\"}","super{foo=\\"x/z\\"}"]' + '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]' ); }); }); @@ -113,12 +125,14 @@ describe('state functions', () => { from: 'now - 5h', to: 'now', }, - queries: [ + initialQueries: [ { - query: 'metric{test="a/b"}', + refId: '1', + expr: 'metric{test="a/b"}', }, { - query: 'super{foo="x/z"}', + refId: '2', + expr: 'super{foo="x/z"}', }, ], }; @@ -126,14 +140,50 @@ describe('state functions', () => { const parsed = parseUrlState(serialized); // Account for datasource vs datasourceName - const { datasource, ...rest } = parsed; - const sameState = { + const { datasource, queries, ...rest } = parsed; + const resultState = { ...rest, datasource: DEFAULT_EXPLORE_STATE.datasource, datasourceName: datasource, + initialQueries: queries, }; - expect(state).toMatchObject(sameState); + expect(state).toMatchObject(resultState); }); }); }); + +describe('updateHistory()', () => { + const datasourceId = 'myDatasource'; + const key = `grafana.explore.history.${datasourceId}`; + + beforeEach(() => { + clearHistory(datasourceId); + expect(store.exists(key)).toBeFalsy(); + }); + + test('should save history item to localStorage', () => { + const expected = [ + { + query: { refId: '1', expr: 'metric' }, + }, + ]; + expect(updateHistory([], datasourceId, [{ refId: '1', expr: 'metric' }])).toMatchObject(expected); + expect(store.exists(key)).toBeTruthy(); + expect(store.getObject(key)).toMatchObject(expected); + }); +}); + +describe('hasNonEmptyQuery', () => { + test('should return true if one query is non-empty', () => { + expect(hasNonEmptyQuery([{ refId: '1', key: '2', expr: 'foo' }])).toBeTruthy(); + }); + + test('should return false if query is empty', () => { + expect(hasNonEmptyQuery([{ refId: '1', key: '2' }])).toBeFalsy(); + }); + + test('should return false if no queries exist', () => { + expect(hasNonEmptyQuery([])).toBeFalsy(); + }); +}); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index ecd11a495ad..9ecc36a192f 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,11 +1,20 @@ import { renderUrl } from 'app/core/utils/url'; -import { ExploreState, ExploreUrlState } from 'app/types/explore'; +import { ExploreState, ExploreUrlState, HistoryItem } from 'app/types/explore'; +import { DataQuery, RawTimeRange } from 'app/types/series'; + +import kbn from 'app/core/utils/kbn'; +import colors from 'app/core/utils/colors'; +import TimeSeries from 'app/core/time_series2'; +import { parse as parseDate } from 'app/core/utils/datemath'; +import store from 'app/core/store'; export const DEFAULT_RANGE = { from: 'now-6h', to: 'now', }; +const MAX_HISTORY_ITEMS = 100; + /** * Returns an Explore-URL that contains a panel's queries and the dashboard time range. * @@ -23,7 +32,7 @@ export async function getExploreUrl( timeSrv: any ) { let exploreDatasource = panelDatasource; - let exploreTargets = panelTargets; + let exploreTargets: DataQuery[] = panelTargets; let url; // Mixed datasources need to choose only one datasource @@ -57,6 +66,8 @@ export async function getExploreUrl( return url; } +const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; + export function parseUrlState(initial: string | undefined): ExploreUrlState { if (initial) { try { @@ -70,7 +81,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { to: parsed[1], }; const datasource = parsed[2]; - const queries = parsed.slice(3).map(query => ({ query })); + const queries = parsed.slice(3); return { datasource, queries, range }; } return parsed; @@ -84,16 +95,97 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string { const urlState: ExploreUrlState = { datasource: state.datasourceName, - queries: state.queries.map(q => ({ query: q.query })), + queries: state.initialQueries.map(clearQueryKeys), range: state.range, }; if (compact) { - return JSON.stringify([ - urlState.range.from, - urlState.range.to, - urlState.datasource, - ...urlState.queries.map(q => q.query), - ]); + return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]); } return JSON.stringify(urlState); } + +export function generateKey(index = 0): string { + return `Q-${Date.now()}-${Math.random()}-${index}`; +} + +export function generateRefId(index = 0): string { + return `${index + 1}`; +} + +export function generateQueryKeys(index = 0): { refId: string; key: string } { + return { refId: generateRefId(index), key: generateKey(index) }; +} + +/** + * Ensure at least one target exists and that targets have the necessary keys + */ +export function ensureQueries(queries?: DataQuery[]): DataQuery[] { + if (queries && typeof queries === 'object' && queries.length > 0) { + return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) })); + } + return [{ ...generateQueryKeys() }]; +} + +/** + * A target is non-empty when it has keys other than refId and key. + */ +export function hasNonEmptyQuery(queries: DataQuery[]): boolean { + return queries.some(query => Object.keys(query).length > 2); +} + +export function getIntervals( + range: RawTimeRange, + datasource, + resolution: number +): { interval: string; intervalMs: number } { + if (!datasource || !resolution) { + return { interval: '1s', intervalMs: 1000 }; + } + const absoluteRange: RawTimeRange = { + from: parseDate(range.from, false), + to: parseDate(range.to, true), + }; + return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); +} + +export function makeTimeSeriesList(dataList) { + return dataList.map((seriesData, index) => { + const datapoints = seriesData.datapoints || []; + const alias = seriesData.target; + const colorIndex = index % colors.length; + const color = colors[colorIndex]; + + const series = new TimeSeries({ + datapoints, + alias, + color, + unit: seriesData.unit, + }); + + return series; + }); +} + +/** + * Update the query history. Side-effect: store history in local storage + */ +export function updateHistory(history: HistoryItem[], datasourceId: string, queries: DataQuery[]): HistoryItem[] { + const ts = Date.now(); + queries.forEach(query => { + history = [{ query, ts }, ...history]; + }); + + if (history.length > MAX_HISTORY_ITEMS) { + history = history.slice(0, MAX_HISTORY_ITEMS); + } + + // Combine all queries of a datasource type into one history + const historyKey = `grafana.explore.history.${datasourceId}`; + store.setObject(historyKey, history); + return history; +} + +export function clearHistory(datasourceId: string) { + const historyKey = `grafana.explore.history.${datasourceId}`; + store.delete(historyKey); +} diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index 2b5e9bdf0cb..2ebe2b53a76 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -64,9 +64,9 @@
- - - + + +
)}
- + ); } diff --git a/public/app/features/explore/Legend.tsx b/public/app/features/explore/Legend.tsx index 439b6c3e54f..3b67aa74d91 100644 --- a/public/app/features/explore/Legend.tsx +++ b/public/app/features/explore/Legend.tsx @@ -1,23 +1,65 @@ -import React, { PureComponent } from 'react'; +import React, { MouseEvent, PureComponent } from 'react'; +import classNames from 'classnames'; +import { TimeSeries } from 'app/core/core'; -const LegendItem = ({ series }) => ( -
-
- -
- - {series.alias} - -
-); +interface LegendProps { + data: TimeSeries[]; + hiddenSeries: Set; + onToggleSeries?: (series: TimeSeries, exclusive: boolean) => void; +} + +interface LegendItemProps { + hidden: boolean; + onClickLabel?: (series: TimeSeries, event: MouseEvent) => void; + series: TimeSeries; +} + +class LegendItem extends PureComponent { + onClickLabel = e => this.props.onClickLabel(this.props.series, e); -export default class Legend extends PureComponent { render() { - const { className = '', data } = this.props; - const items = data || []; + const { hidden, series } = this.props; + const seriesClasses = classNames({ + 'graph-legend-series-hidden': hidden, + }); return ( -
- {items.map(series => )} + + ); + } +} + +export default class Legend extends PureComponent { + static defaultProps = { + onToggleSeries: () => {}, + }; + + onClickLabel = (series: TimeSeries, event: MouseEvent) => { + const { onToggleSeries } = this.props; + const exclusive = event.ctrlKey || event.metaKey || event.shiftKey; + onToggleSeries(series, !exclusive); + }; + + render() { + const { data, hiddenSeries } = this.props; + const items = data || []; + return ( +
+ {items.map((series, i) => ( +
); } diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index 224d34574b8..d5cba981951 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -27,14 +27,14 @@ function hasSuggestions(suggestions: CompletionItemGroup[]): boolean { return suggestions && suggestions.length > 0; } -interface QueryFieldProps { +export interface QueryFieldProps { additionalPlugins?: any[]; cleanText?: (text: string) => string; - initialValue: string | null; + initialQuery: string | null; onBlur?: () => void; onFocus?: () => void; onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput; - onValueChanged?: (value: Value) => void; + onValueChanged?: (value: string) => void; onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string; placeholder?: string; portalOrigin?: string; @@ -60,16 +60,22 @@ export interface TypeaheadInput { wrapperNode: Element; } +/** + * Renders an editor field. + * Pass initial value as initialQuery and listen to changes in props.onValueChanged. + * This component can only process strings. Internally it uses Slate Value. + * Implement props.onTypeahead to use suggestions, see PromQueryField.tsx as an example. + */ export class QueryField extends React.PureComponent { menuEl: HTMLElement | null; placeholdersBuffer: PlaceholdersBuffer; plugins: any[]; resetTimer: any; - constructor(props, context) { + constructor(props: QueryFieldProps, context) { super(props, context); - this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || ''); + this.placeholdersBuffer = new PlaceholdersBuffer(props.initialQuery || ''); // Base plugins this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p); @@ -92,7 +98,7 @@ export class QueryField extends React.PureComponent qt.hints && qt.hints.length > 0); @@ -16,7 +16,7 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHi interface QueryRowEventHandlers { onAddQueryRow: (index: number) => void; - onChangeQuery: (value: string, index: number, override?: boolean) => void; + onChangeQuery: (value: DataQuery, index: number, override?: boolean) => void; onClickHintFix: (action: object, index?: number) => void; onExecuteQuery: () => void; onRemoveQueryRow: (index: number) => void; @@ -32,11 +32,11 @@ interface QueryRowCommonProps { type QueryRowProps = QueryRowCommonProps & QueryRowEventHandlers & { index: number; - query: string; + initialQuery: DataQuery; }; class QueryRow extends PureComponent { - onChangeQuery = (value, override?: boolean) => { + onChangeQuery = (value: DataQuery, override?: boolean) => { const { index, onChangeQuery } = this.props; if (onChangeQuery) { onChangeQuery(value, index, override); @@ -51,7 +51,7 @@ class QueryRow extends PureComponent { }; onClickClearButton = () => { - this.onChangeQuery('', true); + this.onChangeQuery(null, true); }; onClickHintFix = action => { @@ -76,7 +76,7 @@ class QueryRow extends PureComponent { }; render() { - const { datasource, history, query, transactions } = this.props; + const { datasource, history, initialQuery, transactions } = this.props; const transactionWithError = transactions.find(t => t.error !== undefined); const hint = getFirstHintFromTransactions(transactions); const queryError = transactionWithError ? transactionWithError.error : null; @@ -91,7 +91,7 @@ class QueryRow extends PureComponent { datasource={datasource} error={queryError} hint={hint} - initialQuery={query} + initialQuery={initialQuery} history={history} onClickHintFix={this.onClickHintFix} onPressEnter={this.onPressEnter} @@ -116,19 +116,19 @@ class QueryRow extends PureComponent { type QueryRowsProps = QueryRowCommonProps & QueryRowEventHandlers & { - queries: Query[]; + initialQueries: DataQuery[]; }; export default class QueryRows extends PureComponent { render() { - const { className = '', queries, transactions, ...handlers } = this.props; + const { className = '', initialQueries, transactions, ...handlers } = this.props; return (
- {queries.map((q, index) => ( + {initialQueries.map((query, index) => ( t.rowIndex === index)} {...handlers} /> diff --git a/public/app/features/explore/QueryTransactionStatus.tsx b/public/app/features/explore/QueryTransactionStatus.tsx index 77a50b7d2ca..6f47f147645 100644 --- a/public/app/features/explore/QueryTransactionStatus.tsx +++ b/public/app/features/explore/QueryTransactionStatus.tsx @@ -35,7 +35,9 @@ export default class QueryTransactionStatus extends PureComponent - {transactions.map((t, i) => )} + {transactions.map((t, i) => ( + + ))}
); } diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap index 6b9553d2e1d..a7ec6deb22c 100644 --- a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap @@ -453,6 +453,8 @@ exports[`Render should render component 1`] = ` }, ] } + hiddenSeries={Set {}} + onToggleSeries={[Function]} /> `; @@ -947,6 +949,8 @@ exports[`Render should render component with disclaimer 1`] = ` }, ] } + hiddenSeries={Set {}} + onToggleSeries={[Function]} /> `; @@ -964,6 +968,8 @@ exports[`Render should show query return no time series 1`] = ` /> `; diff --git a/public/app/features/explore/utils/query.ts b/public/app/features/explore/utils/query.ts deleted file mode 100644 index 193ee2dbc52..00000000000 --- a/public/app/features/explore/utils/query.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Query } from 'app/types/explore'; - -export function generateQueryKey(index = 0): string { - return `Q-${Date.now()}-${Math.random()}-${index}`; -} - -export function ensureQueries(queries?: Query[]): Query[] { - if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') { - return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query })); - } - return [{ key: generateQueryKey(), query: '' }]; -} - -export function hasQuery(queries: string[]): boolean { - return queries.some(q => Boolean(q)); -} diff --git a/public/app/features/explore/utils/set.test.ts b/public/app/features/explore/utils/set.test.ts new file mode 100644 index 00000000000..4f586814fc7 --- /dev/null +++ b/public/app/features/explore/utils/set.test.ts @@ -0,0 +1,52 @@ +import { equal, intersect } from './set'; + +describe('equal', () => { + it('returns false for two sets of differing sizes', () => { + const s1 = new Set([1, 2, 3]); + const s2 = new Set([4, 5, 6, 7]); + expect(equal(s1, s2)).toBe(false); + }); + it('returns false for two sets where one is a subset of the other', () => { + const s1 = new Set([1, 2, 3]); + const s2 = new Set([1, 2, 3, 4]); + expect(equal(s1, s2)).toBe(false); + }); + it('returns false for two sets with uncommon elements', () => { + const s1 = new Set([1, 2, 3, 4]); + const s2 = new Set([1, 2, 5, 6]); + expect(equal(s1, s2)).toBe(false); + }); + it('returns false for two deeply equivalent sets', () => { + const s1 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]); + const s2 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]); + expect(equal(s1, s2)).toBe(false); + }); + it('returns true for two sets with the same elements', () => { + const s1 = new Set([1, 2, 3, 4]); + const s2 = new Set([4, 3, 2, 1]); + expect(equal(s1, s2)).toBe(true); + }); +}); + +describe('intersect', () => { + it('returns an empty set for two sets without any common elements', () => { + const s1 = new Set([1, 2, 3, 4]); + const s2 = new Set([5, 6, 7, 8]); + expect(intersect(s1, s2)).toEqual(new Set()); + }); + it('returns an empty set for two deeply equivalent sets', () => { + const s1 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]); + const s2 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]); + expect(intersect(s1, s2)).toEqual(new Set()); + }); + it('returns a set containing common elements between two sets of the same size', () => { + const s1 = new Set([1, 2, 3, 4]); + const s2 = new Set([5, 2, 7, 4]); + expect(intersect(s1, s2)).toEqual(new Set([2, 4])); + }); + it('returns a set containing common elements between two sets of differing sizes', () => { + const s1 = new Set([1, 2, 3, 4]); + const s2 = new Set([5, 4, 3, 2, 1]); + expect(intersect(s1, s2)).toEqual(new Set([1, 2, 3, 4])); + }); +}); diff --git a/public/app/features/explore/utils/set.ts b/public/app/features/explore/utils/set.ts new file mode 100644 index 00000000000..12430384fe4 --- /dev/null +++ b/public/app/features/explore/utils/set.ts @@ -0,0 +1,35 @@ +/** + * Performs a shallow comparison of two sets with the same item type. + */ +export function equal(a: Set, b: Set): boolean { + if (a.size !== b.size) { + return false; + } + const it = a.values(); + while (true) { + const { value, done } = it.next(); + if (done) { + return true; + } + if (!b.has(value)) { + return false; + } + } +} + +/** + * Returns a new set with items in both sets using shallow comparison. + */ +export function intersect(a: Set, b: Set): Set { + const result = new Set(); + const it = b.values(); + while (true) { + const { value, done } = it.next(); + if (done) { + return result; + } + if (a.has(value)) { + result.add(value); + } + } +} diff --git a/public/app/features/plugins/VariableQueryComponentLoader.tsx b/public/app/features/plugins/VariableQueryComponentLoader.tsx new file mode 100644 index 00000000000..631161b4e9b --- /dev/null +++ b/public/app/features/plugins/VariableQueryComponentLoader.tsx @@ -0,0 +1,36 @@ +import coreModule from 'app/core/core_module'; +import { importPluginModule } from './plugin_loader'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import DefaultVariableQueryEditor from '../templating/DefaultVariableQueryEditor'; + +async function loadComponent(module) { + const component = await importPluginModule(module); + if (component && component.VariableQueryEditor) { + return component.VariableQueryEditor; + } else { + return DefaultVariableQueryEditor; + } +} + +/** @ngInject */ +function variableQueryEditorLoader(templateSrv) { + return { + restrict: 'E', + link: async (scope, elem) => { + const Component = await loadComponent(scope.currentDatasource.meta.module); + const props = { + datasource: scope.currentDatasource, + query: scope.current.query, + onChange: scope.onQueryChange, + templateSrv, + }; + ReactDOM.render(, elem[0]); + scope.$on('$destroy', () => { + ReactDOM.unmountComponentAtNode(elem[0]); + }); + }, + }; +} + +coreModule.directive('variableQueryEditorLoader', variableQueryEditorLoader); diff --git a/public/app/features/plugins/all.ts b/public/app/features/plugins/all.ts index ce9bcbff3bc..cece501112f 100644 --- a/public/app/features/plugins/all.ts +++ b/public/app/features/plugins/all.ts @@ -3,3 +3,4 @@ import './import_list/import_list'; import './ds_edit_ctrl'; import './datasource_srv'; import './plugin_component'; +import './VariableQueryComponentLoader'; diff --git a/public/app/features/templating/DefaultVariableQueryEditor.tsx b/public/app/features/templating/DefaultVariableQueryEditor.tsx new file mode 100644 index 00000000000..ea5f2acaade --- /dev/null +++ b/public/app/features/templating/DefaultVariableQueryEditor.tsx @@ -0,0 +1,34 @@ +import React, { PureComponent } from 'react'; +import { VariableQueryProps } from 'app/types/plugins'; + +export default class DefaultVariableQueryEditor extends PureComponent { + constructor(props) { + super(props); + this.state = { value: props.query }; + } + + handleChange(event) { + this.setState({ value: event.target.value }); + } + + handleBlur(event) { + this.props.onChange(event.target.value, event.target.value); + } + + render() { + return ( +
+ Query + this.handleChange(e)} + onBlur={e => this.handleBlur(e)} + placeholder="metric name or tags query" + required + /> +
+ ); + } +} diff --git a/public/app/features/templating/editor_ctrl.ts b/public/app/features/templating/editor_ctrl.ts index cef7c9cc912..fdab5587b42 100644 --- a/public/app/features/templating/editor_ctrl.ts +++ b/public/app/features/templating/editor_ctrl.ts @@ -72,6 +72,7 @@ export class VariableEditorCtrl { if ( $scope.current.type === 'query' && + _.isString($scope.current.query) && $scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)')) ) { appEvents.emit('alert-warning', [ @@ -106,11 +107,20 @@ export class VariableEditorCtrl { }); }; + $scope.onQueryChange = (query, definition) => { + $scope.current.query = query; + $scope.current.definition = definition; + $scope.runQuery(); + }; + $scope.edit = variable => { $scope.current = variable; $scope.currentIsNew = false; $scope.mode = 'edit'; $scope.validate(); + datasourceSrv.get($scope.current.datasource).then(ds => { + $scope.currentDatasource = ds; + }); }; $scope.duplicate = variable => { @@ -171,6 +181,13 @@ export class VariableEditorCtrl { $scope.showMoreOptions = () => { $scope.optionsLimit += 20; }; + + $scope.datasourceChanged = async () => { + datasourceSrv.get($scope.current.datasource).then(ds => { + $scope.current.query = ''; + $scope.currentDatasource = ds; + }); + }; } } diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html index c4463972177..15984eba7d6 100644 --- a/public/app/features/templating/partials/editor.html +++ b/public/app/features/templating/partials/editor.html @@ -17,14 +17,16 @@
What do variables do?
-

Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names - in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of - the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. +

Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor + names + in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the + top of + the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. - Check out the - - Templating documentation - for more information. + Check out the + + Templating documentation + for more information.

@@ -32,7 +34,7 @@
@@ -51,7 +53,7 @@ @@ -77,7 +79,8 @@
Name - +
@@ -87,13 +90,15 @@
- +
- Template names cannot begin with '__', that's reserved for Grafana's global variables + Template names cannot begin with '__', that's reserved for + Grafana's global variables
@@ -115,7 +120,8 @@
Values - +
@@ -127,14 +133,16 @@ Step count How many times should the current time range be divided to calculate the value
- +
Min interval The calculated value will not go below this threshold - +
@@ -143,7 +151,8 @@
Custom Options
Values separated by comma - +
@@ -168,15 +177,17 @@
- Data source + Data source
-
+
- + Refresh When to update the values of this variable. @@ -187,28 +198,32 @@
+ + + + + +
- Query - -
-
- + Regex Optional, if you want to extract part of a series name or metric node segment. - +
- + Sort How to sort the values of this variable.
- +
@@ -219,7 +234,8 @@
- +
@@ -234,7 +250,8 @@ - + @@ -243,7 +260,8 @@
Data source
-
@@ -253,18 +271,11 @@
Selection Options
- + - +
@@ -279,11 +290,13 @@
Tags query - +
  • Tag values query
  • - +
    @@ -291,11 +304,11 @@
    Preview of values
    - {{option.text}} -
    -
    - Show more -
    + {{option.text}} +
    +
    + Show more +
    @@ -309,5 +322,4 @@ - - + \ No newline at end of file diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index d3f39023cfb..0aec1d8f412 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -23,6 +23,7 @@ export class QueryVariable implements Variable { tagValuesQuery: string; tags: any[]; skipUrlSync: boolean; + definition: string; defaults = { type: 'query', @@ -44,6 +45,7 @@ export class QueryVariable implements Variable { tagsQuery: '', tagValuesQuery: '', skipUrlSync: false, + definition: '', }; /** @ngInject */ diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index 1994e86eff0..930d2c49228 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -1,3 +1,4 @@ +import _ from 'lodash'; import { assignModelProperties } from 'app/core/utils/model_utils'; /* @@ -28,6 +29,7 @@ export { assignModelProperties }; export function containsVariable(...args: any[]) { const variableName = args[args.length - 1]; + args[0] = _.isString(args[0]) ? args[0] : Object['values'](args[0]).join(' '); const variableString = args.slice(0, -1).join(' '); const matches = variableString.match(variableRegex); const isMatchingVariable = diff --git a/public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx b/public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx index a7af48b6eda..651c28783d9 100644 --- a/public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx +++ b/public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx @@ -19,7 +19,10 @@ export default (props: any) => ( {CHEAT_SHEET_ITEMS.map(item => (
    {item.title}
    -
    props.onClickQuery(item.expression)}> +
    props.onClickExample({ refId: '1', expr: item.expression })} + > {item.expression}
    {item.label}
    diff --git a/public/app/plugins/datasource/logging/components/LoggingQueryField.tsx b/public/app/plugins/datasource/logging/components/LoggingQueryField.tsx index ce79d38f9a8..5667bd9a20d 100644 --- a/public/app/plugins/datasource/logging/components/LoggingQueryField.tsx +++ b/public/app/plugins/datasource/logging/components/LoggingQueryField.tsx @@ -10,7 +10,8 @@ import { TypeaheadOutput } from 'app/types/explore'; import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; import BracesPlugin from 'app/features/explore/slate-plugins/braces'; import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; -import TypeaheadField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; +import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; +import { DataQuery } from 'app/types'; const PRISM_SYNTAX = 'promql'; @@ -53,10 +54,10 @@ interface LoggingQueryFieldProps { error?: string | JSX.Element; hint?: any; history?: any[]; - initialQuery?: string | null; + initialQuery?: DataQuery; onClickHintFix?: (action: any) => void; onPressEnter?: () => void; - onQueryChange?: (value: string, override?: boolean) => void; + onQueryChange?: (value: DataQuery, override?: boolean) => void; } interface LoggingQueryFieldState { @@ -134,9 +135,13 @@ class LoggingQueryField extends React.PureComponent { // Send text change to parent - const { onQueryChange } = this.props; + const { initialQuery, onQueryChange } = this.props; if (onQueryChange) { - onQueryChange(value, override); + const query = { + ...initialQuery, + expr: value, + }; + onQueryChange(query, override); } }; @@ -196,15 +201,15 @@ class LoggingQueryField extends React.PureComponent
    - {error ?
    {error}
    : null} diff --git a/public/app/plugins/datasource/logging/components/LoggingStartPage.tsx b/public/app/plugins/datasource/logging/components/LoggingStartPage.tsx index 89262999637..2c25a248fa9 100644 --- a/public/app/plugins/datasource/logging/components/LoggingStartPage.tsx +++ b/public/app/plugins/datasource/logging/components/LoggingStartPage.tsx @@ -52,7 +52,7 @@ export default class LoggingStartPage extends PureComponent
    - {active === 'start' && } + {active === 'start' && }
    ); diff --git a/public/app/plugins/datasource/logging/language_provider.test.ts b/public/app/plugins/datasource/logging/language_provider.test.ts index e0844cf0c7a..79f696843bb 100644 --- a/public/app/plugins/datasource/logging/language_provider.test.ts +++ b/public/app/plugins/datasource/logging/language_provider.test.ts @@ -7,12 +7,37 @@ describe('Language completion provider', () => { metadataRequest: () => ({ data: { data: [] } }), }; - it('returns default suggestions on emtpty context', () => { - const instance = new LanguageProvider(datasource); - const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(0); + describe('empty query suggestions', () => { + it('returns default suggestions on emtpty context', () => { + const instance = new LanguageProvider(datasource); + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(0); + }); + + it('returns default suggestions with history on emtpty context when history was provided', () => { + const instance = new LanguageProvider(datasource); + const value = Plain.deserialize(''); + const history = [ + { + query: { refId: '1', expr: '{app="foo"}' }, + }, + ]; + const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }, { history }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions).toMatchObject([ + { + label: 'History', + items: [ + { + label: '{app="foo"}', + }, + ], + }, + ]); + }); }); describe('label suggestions', () => { diff --git a/public/app/plugins/datasource/logging/language_provider.ts b/public/app/plugins/datasource/logging/language_provider.ts index 00745d2eee8..eb47b3b1e27 100644 --- a/public/app/plugins/datasource/logging/language_provider.ts +++ b/public/app/plugins/datasource/logging/language_provider.ts @@ -7,6 +7,7 @@ import { LanguageProvider, TypeaheadInput, TypeaheadOutput, + HistoryItem, } from 'app/types/explore'; import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasource/prometheus/language_utils'; import PromqlSyntax from 'app/plugins/datasource/prometheus/promql'; @@ -19,9 +20,9 @@ const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h const wrapLabel = (label: string) => ({ label }); -export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem { +export function addHistoryMetadata(item: CompletionItem, history: HistoryItem[]): CompletionItem { const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; - const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label); + const historyForItem = history.filter(h => h.ts > cutoffTs && (h.query.expr as string) === item.label); const count = historyForItem.length; const recent = historyForItem[0]; let hint = `Queried ${count} times in the last 24h.`; @@ -96,9 +97,9 @@ export default class LoggingLanguageProvider extends LanguageProvider { if (history && history.length > 0) { const historyItems = _.chain(history) - .uniqBy('query') + .uniqBy('query.expr') .take(HISTORY_ITEM_COUNT) - .map(h => h.query) + .map(h => h.query.expr) .map(wrapLabel) .map(item => addHistoryMetadata(item, history)) .value(); @@ -177,6 +178,10 @@ export default class LoggingLanguageProvider extends LanguageProvider { } async importPrometheusQuery(query: string): Promise { + if (!query) { + return ''; + } + // Consider only first selector in query const selectorMatch = query.match(selectorRegexp); if (selectorMatch) { @@ -192,7 +197,7 @@ export default class LoggingLanguageProvider extends LanguageProvider { const commonLabels = {}; for (const key in labels) { const existingKeys = this.labelKeys[EMPTY_SELECTOR]; - if (existingKeys.indexOf(key) > -1) { + if (existingKeys && existingKeys.indexOf(key) > -1) { // Should we check for label value equality here? commonLabels[key] = labels[key]; } diff --git a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx index a2d3a03d794..ea9a373e67a 100644 --- a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx @@ -25,7 +25,10 @@ export default (props: any) => ( {CHEAT_SHEET_ITEMS.map(item => (
    {item.title}
    -
    props.onClickQuery(item.expression)}> +
    props.onClickExample({ refId: '1', expr: item.expression })} + > {item.expression}
    {item.label}
    diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index a7787096d85..6171c662127 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -10,7 +10,8 @@ import { TypeaheadOutput } from 'app/types/explore'; import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; import BracesPlugin from 'app/features/explore/slate-plugins/braces'; import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; -import TypeaheadField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; +import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; +import { DataQuery } from 'app/types'; const HISTOGRAM_GROUP = '__histograms__'; const METRIC_MARK = 'metric'; @@ -87,13 +88,13 @@ interface CascaderOption { interface PromQueryFieldProps { datasource: any; error?: string | JSX.Element; + initialQuery: DataQuery; hint?: any; history?: any[]; - initialQuery?: string | null; metricsByPrefix?: CascaderOption[]; onClickHintFix?: (action: any) => void; onPressEnter?: () => void; - onQueryChange?: (value: string, override?: boolean) => void; + onQueryChange?: (value: DataQuery, override?: boolean) => void; } interface PromQueryFieldState { @@ -163,9 +164,13 @@ class PromQueryField extends React.PureComponent { // Send text change to parent - const { onQueryChange } = this.props; + const { initialQuery, onQueryChange } = this.props; if (onQueryChange) { - onQueryChange(value, override); + const query: DataQuery = { + ...initialQuery, + expr: value, + }; + onQueryChange(query, override); } }; @@ -230,7 +235,7 @@ class PromQueryField extends React.PureComponent @@ -242,10 +247,10 @@ class PromQueryField extends React.PureComponent
    - {
    - {active === 'start' && } + {active === 'start' && }
    ); diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 0cedafdff75..41f78ec7421 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -11,6 +11,8 @@ import { BackendSrv } from 'app/core/services/backend_srv'; import addLabelToQuery from './add_label_to_query'; import { getQueryHints } from './query_hints'; import { expandRecordingRules } from './language_utils'; +import { DataQuery } from 'app/types'; +import { ExploreUrlState } from 'app/types/explore'; export function alignRange(start, end, step) { const alignedEnd = Math.ceil(end / step) * step; @@ -419,24 +421,23 @@ export class PrometheusDatasource { }); } - getExploreState(targets: any[]) { - let state = {}; - if (targets && targets.length > 0) { - const queries = targets.map(t => ({ - query: this.templateSrv.replace(t.expr, {}, this.interpolateQueryExpr), - format: t.format, + getExploreState(queries: DataQuery[]): Partial { + let state: Partial = { datasource: this.name }; + if (queries && queries.length > 0) { + const expandedQueries = queries.map(query => ({ + ...query, + expr: this.templateSrv.replace(query.expr, {}, this.interpolateQueryExpr), })); state = { ...state, - queries, - datasource: this.name, + queries: expandedQueries, }; } return state; } - getQueryHints(query: string, result: any[]) { - return getQueryHints(query, result, this); + getQueryHints(query: DataQuery, result: any[]) { + return getQueryHints(query.expr, result, this); } loadRules() { @@ -454,28 +455,35 @@ export class PrometheusDatasource { }); } - modifyQuery(query: string, action: any): string { + modifyQuery(query: DataQuery, action: any): DataQuery { + let expression = query.expr || ''; switch (action.type) { case 'ADD_FILTER': { - return addLabelToQuery(query, action.key, action.value); + expression = addLabelToQuery(expression, action.key, action.value); + break; } case 'ADD_HISTOGRAM_QUANTILE': { - return `histogram_quantile(0.95, sum(rate(${query}[5m])) by (le))`; + expression = `histogram_quantile(0.95, sum(rate(${expression}[5m])) by (le))`; + break; } case 'ADD_RATE': { - return `rate(${query}[5m])`; + expression = `rate(${expression}[5m])`; + break; } case 'ADD_SUM': { - return `sum(${query.trim()}) by ($1)`; + expression = `sum(${expression.trim()}) by ($1)`; + break; } case 'EXPAND_RULES': { if (action.mapping) { - return expandRecordingRules(query, action.mapping); + expression = expandRecordingRules(expression, action.mapping); } + break; } default: - return query; + break; } + return { ...query, expr: expression }; } getPrometheusTime(date, roundUp) { diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 6e6f461d341..5fd8fcebaaf 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -125,9 +125,9 @@ export default class PromQlLanguageProvider extends LanguageProvider { if (history && history.length > 0) { const historyItems = _.chain(history) - .uniqBy('query') + .uniqBy('query.expr') .take(HISTORY_ITEM_COUNT) - .map(h => h.query) + .map(h => h.query.expr) .map(wrapLabel) .map(item => addHistoryMetadata(item, history)) .value(); diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts index bcb8cb34082..d3eb6de3087 100644 --- a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts @@ -36,6 +36,32 @@ describe('Language completion provider', () => { }, ]); }); + + it('returns default suggestions with history on emtpty context when history was provided', () => { + const instance = new LanguageProvider(datasource); + const value = Plain.deserialize(''); + const history = [ + { + query: { refId: '1', expr: 'metric' }, + }, + ]; + const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }, { history }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions).toMatchObject([ + { + label: 'History', + items: [ + { + label: 'metric', + }, + ], + }, + { + label: 'Functions', + }, + ]); + }); }); describe('range suggestions', () => { diff --git a/public/app/plugins/datasource/stackdriver/StackdriverMetricFindQuery.ts b/public/app/plugins/datasource/stackdriver/StackdriverMetricFindQuery.ts new file mode 100644 index 00000000000..f8fc2e796ce --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/StackdriverMetricFindQuery.ts @@ -0,0 +1,129 @@ +import isString from 'lodash/isString'; +import { alignmentPeriods } from './constants'; +import { MetricFindQueryTypes } from './types'; +import { + getMetricTypesByService, + getAlignmentOptionsByMetric, + getAggregationOptionsByMetric, + extractServicesFromMetricDescriptors, + getLabelKeys, +} from './functions'; + +export default class StackdriverMetricFindQuery { + constructor(private datasource) {} + + async execute(query: any) { + try { + switch (query.selectedQueryType) { + case MetricFindQueryTypes.Services: + return this.handleServiceQuery(); + case MetricFindQueryTypes.MetricTypes: + return this.handleMetricTypesQuery(query); + case MetricFindQueryTypes.LabelKeys: + return this.handleLabelKeysQuery(query); + case MetricFindQueryTypes.LabelValues: + return this.handleLabelValuesQuery(query); + case MetricFindQueryTypes.ResourceTypes: + return this.handleResourceTypeQuery(query); + case MetricFindQueryTypes.Aligners: + return this.handleAlignersQuery(query); + case MetricFindQueryTypes.AlignmentPeriods: + return this.handleAlignmentPeriodQuery(); + case MetricFindQueryTypes.Aggregations: + return this.handleAggregationQuery(query); + default: + return []; + } + } catch (error) { + console.error(`Could not run StackdriverMetricFindQuery ${query}`, error); + return []; + } + } + + async handleServiceQuery() { + const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName); + const services = extractServicesFromMetricDescriptors(metricDescriptors); + return services.map(s => ({ + text: s.serviceShortName, + value: s.service, + expandable: true, + })); + } + + async handleMetricTypesQuery({ selectedService }) { + if (!selectedService) { + return []; + } + const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName); + return getMetricTypesByService(metricDescriptors, this.datasource.templateSrv.replace(selectedService)).map(s => ({ + text: s.displayName, + value: s.type, + expandable: true, + })); + } + + async handleLabelKeysQuery({ selectedMetricType }) { + if (!selectedMetricType) { + return []; + } + const labelKeys = await getLabelKeys(this.datasource, selectedMetricType); + return labelKeys.map(this.toFindQueryResult); + } + + async handleLabelValuesQuery({ selectedMetricType, labelKey }) { + if (!selectedMetricType) { + return []; + } + const refId = 'handleLabelValuesQuery'; + const response = await this.datasource.getLabels(selectedMetricType, refId); + const interpolatedKey = this.datasource.templateSrv.replace(labelKey); + const [name] = interpolatedKey.split('.').reverse(); + let values = []; + if (response.meta && response.meta.metricLabels && response.meta.metricLabels.hasOwnProperty(name)) { + values = response.meta.metricLabels[name]; + } else if (response.meta && response.meta.resourceLabels && response.meta.resourceLabels.hasOwnProperty(name)) { + values = response.meta.resourceLabels[name]; + } + + return values.map(this.toFindQueryResult); + } + + async handleResourceTypeQuery({ selectedMetricType }) { + if (!selectedMetricType) { + return []; + } + const refId = 'handleResourceTypeQueryQueryType'; + const response = await this.datasource.getLabels(selectedMetricType, refId); + return response.meta.resourceTypes ? response.meta.resourceTypes.map(this.toFindQueryResult) : []; + } + + async handleAlignersQuery({ selectedMetricType }) { + if (!selectedMetricType) { + return []; + } + const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName); + const { valueType, metricKind } = metricDescriptors.find( + m => m.type === this.datasource.templateSrv.replace(selectedMetricType) + ); + return getAlignmentOptionsByMetric(valueType, metricKind).map(this.toFindQueryResult); + } + + async handleAggregationQuery({ selectedMetricType }) { + if (!selectedMetricType) { + return []; + } + const metricDescriptors = await this.datasource.getMetricTypes(this.datasource.projectName); + const { valueType, metricKind } = metricDescriptors.find( + m => m.type === this.datasource.templateSrv.replace(selectedMetricType) + ); + return getAggregationOptionsByMetric(valueType, metricKind).map(this.toFindQueryResult); + } + + handleAlignmentPeriodQuery() { + return alignmentPeriods.map(this.toFindQueryResult); + } + + toFindQueryResult(x) { + return isString(x) ? { text: x, expandable: true } : { ...x, expandable: true }; + } +} diff --git a/public/app/plugins/datasource/stackdriver/components/SimpleSelect.tsx b/public/app/plugins/datasource/stackdriver/components/SimpleSelect.tsx new file mode 100644 index 00000000000..3a4a0707a2c --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/SimpleSelect.tsx @@ -0,0 +1,28 @@ +import React, { SFC } from 'react'; + +interface Props { + onValueChange: (e) => void; + options: any[]; + value: string; + label: string; +} + +const SimpleSelect: SFC = props => { + const { label, onValueChange, value, options } = props; + return ( +
    + {label} +
    + +
    +
    + ); +}; + +export default SimpleSelect; diff --git a/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.test.tsx new file mode 100644 index 00000000000..0f31d25ee4e --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { StackdriverVariableQueryEditor } from './VariableQueryEditor'; +import { VariableQueryProps } from 'app/types/plugins'; +import { MetricFindQueryTypes } from '../types'; + +jest.mock('../functions', () => ({ + getMetricTypes: () => ({ metricTypes: [], selectedMetricType: '' }), + extractServicesFromMetricDescriptors: () => [], +})); + +const props: VariableQueryProps = { + onChange: (query, definition) => {}, + query: {}, + datasource: { + getMetricTypes: async p => [], + }, + templateSrv: { replace: s => s, variables: [] }, +}; + +describe('VariableQueryEditor', () => { + it('renders correctly', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + describe('and a new variable is created', () => { + it('should trigger a query using the first query type in the array', done => { + props.onChange = (query, definition) => { + expect(definition).toBe('Stackdriver - Services'); + done(); + }; + renderer.create().toJSON(); + }); + }); + + describe('and an existing variable is edited', () => { + it('should trigger new query using the saved query type', done => { + props.query = { selectedQueryType: MetricFindQueryTypes.LabelKeys }; + props.onChange = (query, definition) => { + expect(definition).toBe('Stackdriver - Label Keys'); + done(); + }; + renderer.create().toJSON(); + }); + }); +}); diff --git a/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.tsx new file mode 100644 index 00000000000..1f349ccab59 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/VariableQueryEditor.tsx @@ -0,0 +1,196 @@ +import React, { PureComponent } from 'react'; +import { VariableQueryProps } from 'app/types/plugins'; +import SimpleSelect from './SimpleSelect'; +import { getMetricTypes, getLabelKeys, extractServicesFromMetricDescriptors } from '../functions'; +import { MetricFindQueryTypes, VariableQueryData } from '../types'; + +export class StackdriverVariableQueryEditor extends PureComponent { + queryTypes: Array<{ value: string; name: string }> = [ + { value: MetricFindQueryTypes.Services, name: 'Services' }, + { value: MetricFindQueryTypes.MetricTypes, name: 'Metric Types' }, + { value: MetricFindQueryTypes.LabelKeys, name: 'Label Keys' }, + { value: MetricFindQueryTypes.LabelValues, name: 'Label Values' }, + { value: MetricFindQueryTypes.ResourceTypes, name: 'Resource Types' }, + { value: MetricFindQueryTypes.Aggregations, name: 'Aggregations' }, + { value: MetricFindQueryTypes.Aligners, name: 'Aligners' }, + { value: MetricFindQueryTypes.AlignmentPeriods, name: 'Alignment Periods' }, + ]; + + defaults: VariableQueryData = { + selectedQueryType: this.queryTypes[0].value, + metricDescriptors: [], + selectedService: '', + selectedMetricType: '', + labels: [], + labelKey: '', + metricTypes: [], + services: [], + }; + + constructor(props: VariableQueryProps) { + super(props); + this.state = Object.assign(this.defaults, this.props.query); + } + + async componentDidMount() { + const metricDescriptors = await this.props.datasource.getMetricTypes(this.props.datasource.projectName); + const services = extractServicesFromMetricDescriptors(metricDescriptors).map(m => ({ + value: m.service, + name: m.serviceShortName, + })); + + let selectedService = ''; + if (services.some(s => s.value === this.props.templateSrv.replace(this.state.selectedService))) { + selectedService = this.state.selectedService; + } else if (services && services.length > 0) { + selectedService = services[0].value; + } + + const { metricTypes, selectedMetricType } = getMetricTypes( + metricDescriptors, + this.state.selectedMetricType, + this.props.templateSrv.replace(this.state.selectedMetricType), + this.props.templateSrv.replace(selectedService) + ); + const state: any = { + services, + selectedService, + metricTypes, + selectedMetricType, + metricDescriptors, + ...await this.getLabels(selectedMetricType), + }; + this.setState(state); + } + + async handleQueryTypeChange(event) { + const state: any = { + selectedQueryType: event.target.value, + ...await this.getLabels(this.state.selectedMetricType, event.target.value), + }; + this.setState(state); + } + + async onServiceChange(event) { + const { metricTypes, selectedMetricType } = getMetricTypes( + this.state.metricDescriptors, + this.state.selectedMetricType, + this.props.templateSrv.replace(this.state.selectedMetricType), + this.props.templateSrv.replace(event.target.value) + ); + const state: any = { + selectedService: event.target.value, + metricTypes, + selectedMetricType, + ...await this.getLabels(selectedMetricType), + }; + this.setState(state); + } + + async onMetricTypeChange(event) { + const state: any = { selectedMetricType: event.target.value, ...await this.getLabels(event.target.value) }; + this.setState(state); + } + + onLabelKeyChange(event) { + this.setState({ labelKey: event.target.value }); + } + + componentDidUpdate() { + const { metricDescriptors, labels, metricTypes, services, ...queryModel } = this.state; + const query = this.queryTypes.find(q => q.value === this.state.selectedQueryType); + this.props.onChange(queryModel, `Stackdriver - ${query.name}`); + } + + async getLabels(selectedMetricType, selectedQueryType = this.state.selectedQueryType) { + let result = { labels: this.state.labels, labelKey: this.state.labelKey }; + if (selectedMetricType && selectedQueryType === MetricFindQueryTypes.LabelValues) { + const labels = await getLabelKeys(this.props.datasource, selectedMetricType); + const labelKey = labels.some(l => l === this.props.templateSrv.replace(this.state.labelKey)) + ? this.state.labelKey + : labels[0]; + result = { labels, labelKey }; + } + return result; + } + + insertTemplateVariables(options) { + const templateVariables = this.props.templateSrv.variables.map(v => ({ name: `$${v.name}`, value: `$${v.name}` })); + return [...templateVariables, ...options]; + } + + renderQueryTypeSwitch(queryType) { + switch (queryType) { + case MetricFindQueryTypes.MetricTypes: + return ( + this.onServiceChange(e)} + label="Service" + /> + ); + case MetricFindQueryTypes.LabelKeys: + case MetricFindQueryTypes.LabelValues: + case MetricFindQueryTypes.ResourceTypes: + return ( + + this.onServiceChange(e)} + label="Service" + /> + this.onMetricTypeChange(e)} + label="Metric Type" + /> + {queryType === MetricFindQueryTypes.LabelValues && ( + ({ value: l, name: l })))} + onValueChange={e => this.onLabelKeyChange(e)} + label="Label Key" + /> + )} + + ); + case MetricFindQueryTypes.Aligners: + case MetricFindQueryTypes.Aggregations: + return ( + + this.onServiceChange(e)} + label="Service" + /> + this.onMetricTypeChange(e)} + label="Metric Type" + /> + + ); + default: + return ''; + } + } + + render() { + return ( + + this.handleQueryTypeChange(e)} + label="Query Type" + /> + {this.renderQueryTypeSwitch(this.state.selectedQueryType)} + + ); + } +} diff --git a/public/app/plugins/datasource/stackdriver/components/__snapshots__/VariableQueryEditor.test.tsx.snap b/public/app/plugins/datasource/stackdriver/components/__snapshots__/VariableQueryEditor.test.tsx.snap new file mode 100644 index 00000000000..e4ad23bc8e7 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/components/__snapshots__/VariableQueryEditor.test.tsx.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VariableQueryEditor renders correctly 1`] = ` +Array [ +
    + + Query Type + +
    + +
    +
    , + "", +] +`; diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index 034333cbb86..d1545655652 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -1,6 +1,7 @@ import { stackdriverUnitMappings } from './constants'; import appEvents from 'app/core/app_events'; import _ from 'lodash'; +import StackdriverMetricFindQuery from './StackdriverMetricFindQuery'; export default class StackdriverDatasource { id: number; @@ -9,6 +10,7 @@ export default class StackdriverDatasource { projectName: string; authenticationType: string; queryPromise: Promise; + metricTypes: any[]; /** @ngInject */ constructor(instanceSettings, private backendSrv, private templateSrv, private timeSrv) { @@ -18,6 +20,7 @@ export default class StackdriverDatasource { this.id = instanceSettings.id; this.projectName = instanceSettings.jsonData.defaultProject || ''; this.authenticationType = instanceSettings.jsonData.authenticationType || 'jwt'; + this.metricTypes = []; } async getTimeSeries(options) { @@ -67,7 +70,7 @@ export default class StackdriverDatasource { } async getLabels(metricType, refId) { - return await this.getTimeSeries({ + const response = await this.getTimeSeries({ targets: [ { refId: refId, @@ -81,6 +84,8 @@ export default class StackdriverDatasource { ], range: this.timeSrv.timeRange(), }); + + return response.results[refId]; } interpolateGroupBys(groupBys: string[], scopedVars): string[] { @@ -177,8 +182,9 @@ export default class StackdriverDatasource { return results; } - metricFindQuery(query) { - throw new Error('Template variables support is not yet imlemented'); + async metricFindQuery(query) { + const stackdriverMetricFindQuery = new StackdriverMetricFindQuery(this); + return stackdriverMetricFindQuery.execute(query); } async testDatasource() { @@ -258,19 +264,21 @@ export default class StackdriverDatasource { async getMetricTypes(projectName: string) { try { - const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`; - const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`); + if (this.metricTypes.length === 0) { + const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`; + const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`); - const metrics = data.metricDescriptors.map(m => { - const [service] = m.type.split('/'); - const [serviceShortName] = service.split('.'); - m.service = service; - m.serviceShortName = serviceShortName; - m.displayName = m.displayName || m.type; - return m; - }); + this.metricTypes = data.metricDescriptors.map(m => { + const [service] = m.type.split('/'); + const [serviceShortName] = service.split('.'); + m.service = service; + m.serviceShortName = serviceShortName; + m.displayName = m.displayName || m.type; + return m; + }); + } - return metrics; + return this.metricTypes; } catch (error) { appEvents.emit('ds-request-error', this.formatStackdriverError(error)); return []; diff --git a/public/app/plugins/datasource/stackdriver/functions.ts b/public/app/plugins/datasource/stackdriver/functions.ts new file mode 100644 index 00000000000..e39a7d42508 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/functions.ts @@ -0,0 +1,48 @@ +import uniqBy from 'lodash/uniqBy'; +import { alignOptions, aggOptions } from './constants'; + +export const extractServicesFromMetricDescriptors = metricDescriptors => uniqBy(metricDescriptors, 'service'); + +export const getMetricTypesByService = (metricDescriptors, service) => + metricDescriptors.filter(m => m.service === service); + +export const getMetricTypes = (metricDescriptors, metricType, interpolatedMetricType, selectedService) => { + const metricTypes = getMetricTypesByService(metricDescriptors, selectedService).map(m => ({ + value: m.type, + name: m.displayName, + })); + const metricTypeExistInArray = metricTypes.some(m => m.value === interpolatedMetricType); + const selectedMetricType = metricTypeExistInArray ? metricType : metricTypes[0].value; + return { + metricTypes, + selectedMetricType, + }; +}; + +export const getAlignmentOptionsByMetric = (metricValueType, metricKind) => { + return !metricValueType + ? [] + : alignOptions.filter(i => { + return i.valueTypes.indexOf(metricValueType) !== -1 && i.metricKinds.indexOf(metricKind) !== -1; + }); +}; + +export const getAggregationOptionsByMetric = (valueType, metricKind) => { + return !metricKind + ? [] + : aggOptions.filter(i => { + return i.valueTypes.indexOf(valueType) !== -1 && i.metricKinds.indexOf(metricKind) !== -1; + }); +}; + +export const getLabelKeys = async (datasource, selectedMetricType) => { + const refId = 'handleLabelKeysQuery'; + const response = await datasource.getLabels(selectedMetricType, refId); + const labelKeys = response.meta + ? [ + ...Object.keys(response.meta.resourceLabels).map(l => `resource.label.${l}`), + ...Object.keys(response.meta.metricLabels).map(l => `metric.label.${l}`), + ] + : []; + return labelKeys; +}; diff --git a/public/app/plugins/datasource/stackdriver/module.ts b/public/app/plugins/datasource/stackdriver/module.ts index 183c5c9ff88..1b81d29af73 100644 --- a/public/app/plugins/datasource/stackdriver/module.ts +++ b/public/app/plugins/datasource/stackdriver/module.ts @@ -2,10 +2,12 @@ import StackdriverDatasource from './datasource'; import { StackdriverQueryCtrl } from './query_ctrl'; import { StackdriverConfigCtrl } from './config_ctrl'; import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl'; +import { StackdriverVariableQueryEditor } from './components/VariableQueryEditor'; export { StackdriverDatasource as Datasource, StackdriverQueryCtrl as QueryCtrl, StackdriverConfigCtrl as ConfigCtrl, StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl, + StackdriverVariableQueryEditor as VariableQueryEditor, }; diff --git a/public/app/plugins/datasource/stackdriver/partials/query.aggregation.html b/public/app/plugins/datasource/stackdriver/partials/query.aggregation.html index 379b9a36dc3..5f16d3fc61d 100755 --- a/public/app/plugins/datasource/stackdriver/partials/query.aggregation.html +++ b/public/app/plugins/datasource/stackdriver/partials/query.aggregation.html @@ -2,8 +2,8 @@
    - +
    @@ -20,8 +20,8 @@
    - +
    @@ -33,8 +33,8 @@
    - +
    diff --git a/public/app/plugins/datasource/stackdriver/partials/query.editor.html b/public/app/plugins/datasource/stackdriver/partials/query.editor.html index 98c8fcc83e8..5c7bc8935b1 100755 --- a/public/app/plugins/datasource/stackdriver/partials/query.editor.html +++ b/public/app/plugins/datasource/stackdriver/partials/query.editor.html @@ -14,7 +14,7 @@
    - Project + Project
    @@ -70,4 +70,4 @@
    {{ctrl.lastQueryError}}
    - + \ No newline at end of file diff --git a/public/app/plugins/datasource/stackdriver/partials/query.filter.html b/public/app/plugins/datasource/stackdriver/partials/query.filter.html index 5043161c492..b96b0720e33 100644 --- a/public/app/plugins/datasource/stackdriver/partials/query.filter.html +++ b/public/app/plugins/datasource/stackdriver/partials/query.filter.html @@ -1,37 +1,52 @@
    - Service - + Service +
    - Metric - -
    -
    -
    + Metric +
    +
    Filter
    - +
    -
    -
    -
    +
    Group By
    - +
    -
    -
    -
    +
    diff --git a/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts index 6cd6c805463..628cc494242 100644 --- a/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts @@ -1,6 +1,7 @@ import coreModule from 'app/core/core_module'; import _ from 'lodash'; import * as options from './constants'; +import { getAlignmentOptionsByMetric, getAggregationOptionsByMetric } from './functions'; import kbn from 'app/core/utils/kbn'; export class StackdriverAggregation { @@ -25,7 +26,7 @@ export class StackdriverAggregationCtrl { target: any; /** @ngInject */ - constructor(private $scope) { + constructor(private $scope, private templateSrv) { this.$scope.ctrl = this; this.target = $scope.target; this.alignmentPeriods = options.alignmentPeriods; @@ -41,28 +42,16 @@ export class StackdriverAggregationCtrl { } setAlignOptions() { - this.alignOptions = !this.target.valueType - ? [] - : options.alignOptions.filter(i => { - return ( - i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1 - ); - }); - if (!this.alignOptions.find(o => o.value === this.target.aggregation.perSeriesAligner)) { + this.alignOptions = getAlignmentOptionsByMetric(this.target.valueType, this.target.metricKind); + if (!this.alignOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner))) { this.target.aggregation.perSeriesAligner = this.alignOptions.length > 0 ? this.alignOptions[0].value : ''; } } setAggOptions() { - this.aggOptions = !this.target.metricKind - ? [] - : options.aggOptions.filter(i => { - return ( - i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1 - ); - }); + this.aggOptions = getAggregationOptionsByMetric(this.target.valueType, this.target.metricKind); - if (!this.aggOptions.find(o => o.value === this.target.aggregation.crossSeriesReducer)) { + if (!this.aggOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.crossSeriesReducer))) { this.deselectAggregationOption('REDUCE_NONE'); } @@ -73,8 +62,12 @@ export class StackdriverAggregationCtrl { } formatAlignmentText() { - const selectedAlignment = this.alignOptions.find(ap => ap.value === this.target.aggregation.perSeriesAligner); - return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${selectedAlignment.text})`; + const selectedAlignment = this.alignOptions.find( + ap => ap.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner) + ); + return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${ + selectedAlignment ? selectedAlignment.text : '' + })`; } deselectAggregationOption(notValidOptionValue: string) { diff --git a/public/app/plugins/datasource/stackdriver/query_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_ctrl.ts index 3a1961eb14e..c2607964456 100644 --- a/public/app/plugins/datasource/stackdriver/query_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_ctrl.ts @@ -62,7 +62,6 @@ export class StackdriverQueryCtrl extends QueryCtrl { constructor($scope, $injector) { super($scope, $injector); _.defaultsDeep(this.target, this.defaults); - this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope); this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope); } diff --git a/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts index 4c383e5d09e..0f5dce559fd 100644 --- a/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts @@ -139,7 +139,7 @@ export class StackdriverFilterCtrl { result = metrics.filter(m => m.service === this.target.service); } - if (result.find(m => m.value === this.target.metricType)) { + if (result.find(m => m.value === this.templateSrv.replace(this.target.metricType))) { this.metricType = this.target.metricType; } else if (result.length > 0) { this.metricType = this.target.metricType = result[0].value; @@ -150,10 +150,10 @@ export class StackdriverFilterCtrl { async getLabels() { this.loadLabelsPromise = new Promise(async resolve => { try { - const data = await this.datasource.getLabels(this.target.metricType, this.target.refId); - this.metricLabels = data.results[this.target.refId].meta.metricLabels; - this.resourceLabels = data.results[this.target.refId].meta.resourceLabels; - this.resourceTypes = data.results[this.target.refId].meta.resourceTypes; + const { meta } = await this.datasource.getLabels(this.target.metricType, this.target.refId); + this.metricLabels = meta.metricLabels; + this.resourceLabels = meta.resourceLabels; + this.resourceTypes = meta.resourceTypes; resolve(); } catch (error) { if (error.data && error.data.message) { @@ -187,7 +187,9 @@ export class StackdriverFilterCtrl { setMetricType() { this.target.metricType = this.metricType; - const { valueType, metricKind, unit } = this.metricDescriptors.find(m => m.type === this.target.metricType); + const { valueType, metricKind, unit } = this.metricDescriptors.find( + m => m.type === this.templateSrv.replace(this.metricType) + ); this.target.unit = unit; this.target.valueType = valueType; this.target.metricKind = metricKind; diff --git a/public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts b/public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts index ac9ea2ac6bc..81011f5dfe0 100644 --- a/public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts +++ b/public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts @@ -6,10 +6,19 @@ describe('StackdriverAggregationCtrl', () => { describe('when new query result is returned from the server', () => { describe('and result is double and gauge and no group by is used', () => { beforeEach(async () => { - ctrl = new StackdriverAggregationCtrl({ - $on: () => {}, - target: { valueType: 'DOUBLE', metricKind: 'GAUGE', aggregation: { crossSeriesReducer: '', groupBys: [] } }, - }); + ctrl = new StackdriverAggregationCtrl( + { + $on: () => {}, + target: { + valueType: 'DOUBLE', + metricKind: 'GAUGE', + aggregation: { crossSeriesReducer: '', groupBys: [] }, + }, + }, + { + replace: s => s, + } + ); }); it('should populate all aggregate options except two', () => { @@ -31,14 +40,19 @@ describe('StackdriverAggregationCtrl', () => { describe('and result is double and gauge and a group by is used', () => { beforeEach(async () => { - ctrl = new StackdriverAggregationCtrl({ - $on: () => {}, - target: { - valueType: 'DOUBLE', - metricKind: 'GAUGE', - aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] }, + ctrl = new StackdriverAggregationCtrl( + { + $on: () => {}, + target: { + valueType: 'DOUBLE', + metricKind: 'GAUGE', + aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] }, + }, }, - }); + { + replace: s => s, + } + ); }); it('should populate all aggregate options except three', () => { diff --git a/public/app/plugins/datasource/stackdriver/types.ts b/public/app/plugins/datasource/stackdriver/types.ts new file mode 100644 index 00000000000..df4c2886522 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/types.ts @@ -0,0 +1,21 @@ +export enum MetricFindQueryTypes { + Services = 'services', + MetricTypes = 'metricTypes', + LabelKeys = 'labelKeys', + LabelValues = 'labelValues', + ResourceTypes = 'resourceTypes', + Aggregations = 'aggregations', + Aligners = 'aligners', + AlignmentPeriods = 'alignmentPeriods', +} + +export interface VariableQueryData { + selectedQueryType: string; + metricDescriptors: any[]; + selectedService: string; + selectedMetricType: string; + labels: string[]; + labelKey: string; + metricTypes: Array<{ value: string; name: string }>; + services: Array<{ value: string; name: string }>; +} diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index c5f98792568..ff248d68201 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -58,15 +58,7 @@ class GraphElement { // panel events this.ctrl.events.on('panel-teardown', this.onPanelTeardown.bind(this)); - - /** - * Split graph rendering into two parts. - * First, calculate series stats in buildFlotPairs() function. Then legend rendering started - * (see ctrl.events.on('render') in legend.ts). - * When legend is rendered it emits 'legend-rendering-complete' and graph rendered. - */ this.ctrl.events.on('render', this.onRender.bind(this)); - this.ctrl.events.on('legend-rendering-complete', this.onLegendRenderingComplete.bind(this)); // global events appEvents.on('graph-hover', this.onGraphHover.bind(this), scope); @@ -85,11 +77,20 @@ class GraphElement { if (!this.data) { return; } + this.annotations = this.ctrl.annotations || []; this.buildFlotPairs(this.data); const graphHeight = this.elem.height(); updateLegendValues(this.data, this.panel, graphHeight); + if (!this.panel.legend.show) { + if (this.legendElem.hasChildNodes()) { + ReactDOM.unmountComponentAtNode(this.legendElem); + } + this.renderPanel(); + return; + } + const { values, min, max, avg, current, total } = this.panel.legend; const { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero } = this.panel.legend; const legendOptions = { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero }; @@ -104,12 +105,9 @@ class GraphElement { onColorChange: this.ctrl.onColorChange, onToggleAxis: this.ctrl.onToggleAxis, }; - const legendReactElem = React.createElement(Legend, legendProps); - ReactDOM.render(legendReactElem, this.legendElem, () => this.onLegendRenderingComplete()); - } - onLegendRenderingComplete() { - this.render_panel(); + const legendReactElem = React.createElement(Legend, legendProps); + ReactDOM.render(legendReactElem, this.legendElem, () => this.renderPanel()); } onGraphHover(evt) { @@ -281,7 +279,7 @@ class GraphElement { } // Function for rendering panel - render_panel() { + renderPanel() { this.panelWidth = this.elem.width(); if (this.shouldAbortRender()) { return; diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index d86e860b27d..be243587820 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -125,7 +125,7 @@ describe('grafanaGraph', () => { //Emulate functions called by event listeners link.buildFlotPairs(link.data); - link.render_panel(); + link.renderPanel(); ctx.plotData = ctrl.plot.mock.calls[0][1]; ctx.plotOptions = ctrl.plot.mock.calls[0][2]; diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 662835633db..f80a485fc29 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -1,6 +1,6 @@ import { Value } from 'slate'; -import { RawTimeRange } from './series'; +import { DataQuery, RawTimeRange } from './series'; export interface CompletionItem { /** @@ -79,7 +79,7 @@ interface ExploreDatasource { export interface HistoryItem { ts: number; - query: string; + query: DataQuery; } export abstract class LanguageProvider { @@ -107,11 +107,6 @@ export interface TypeaheadOutput { suggestions: CompletionItemGroup[]; } -export interface Query { - query: string; - key?: string; -} - export interface QueryFix { type: string; label: string; @@ -130,6 +125,10 @@ export interface QueryHint { fix?: QueryFix; } +export interface QueryHintGetter { + (query: DataQuery, results: any[], ...rest: any): QueryHint[]; +} + export interface QueryTransaction { id: string; done: boolean; @@ -137,7 +136,7 @@ export interface QueryTransaction { hints?: QueryHint[]; latency: number; options: any; - query: string; + query: DataQuery; result?: any; // Table model / Timeseries[] / Logs resultType: ResultType; rowIndex: number; @@ -160,15 +159,7 @@ export interface ExploreState { exploreDatasources: ExploreDatasource[]; graphRange: RawTimeRange; history: HistoryItem[]; - /** - * Initial rows of queries to push down the tree. - * Modifications do not end up here, but in `this.queryExpressions`. - * The only way to reset a query is to change its `key`. - */ - queries: Query[]; - /** - * Hints gathered for the query row. - */ + initialQueries: DataQuery[]; queryTransactions: QueryTransaction[]; range: RawTimeRange; showingGraph: boolean; @@ -182,7 +173,7 @@ export interface ExploreState { export interface ExploreUrlState { datasource: string; - queries: Query[]; + queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense range: RawTimeRange; } diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts index 4e598382a9a..ff1deb4e790 100644 --- a/public/app/types/plugins.ts +++ b/public/app/types/plugins.ts @@ -6,6 +6,7 @@ export interface PluginExports { QueryCtrl?: any; ConfigCtrl?: any; AnnotationsQueryCtrl?: any; + VariableQueryEditor?: any; ExploreQueryField?: any; ExploreStartPage?: any; @@ -98,3 +99,10 @@ export interface PluginsState { hasFetched: boolean; dashboards: PluginDashboard[]; } + +export interface VariableQueryProps { + query: any; + onChange: (query: any, definition: string) => void; + datasource: any; + templateSrv: any; +} diff --git a/scripts/build/publish.sh b/scripts/build/publish.sh index e688748b8b1..264d930e51b 100755 --- a/scripts/build/publish.sh +++ b/scripts/build/publish.sh @@ -4,10 +4,10 @@ EXTRA_OPTS="$@" -# Right now we hack this in into the publish script. +# Right now we hack this in into the publish script. # Eventually we might want to keep a list of all previous releases somewhere. -_releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-3-x/10244" -_whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-3/" +_releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-4-x/12215" +_whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-4/" ./scripts/build/release_publisher/release_publisher \ --wn ${_whatsNewUrl} \ diff --git a/scripts/webpack/webpack.dev.js b/scripts/webpack/webpack.dev.js index 228df79b3f8..7e103b32606 100644 --- a/scripts/webpack/webpack.dev.js +++ b/scripts/webpack/webpack.dev.js @@ -85,7 +85,7 @@ module.exports = merge(common, { new HtmlWebpackPlugin({ filename: path.resolve(__dirname, '../../public/views/error.html'), template: path.resolve(__dirname, '../../public/views/error-template.html'), - inject: 'false', + inject: false, }), new HtmlWebpackPlugin({ filename: path.resolve(__dirname, '../../public/views/index.html'), diff --git a/scripts/webpack/webpack.prod.js b/scripts/webpack/webpack.prod.js index 5d3ffa61219..d45c78352b0 100644 --- a/scripts/webpack/webpack.prod.js +++ b/scripts/webpack/webpack.prod.js @@ -74,17 +74,17 @@ module.exports = merge(common, { filename: "grafana.[name].[hash].css" }), new ngAnnotatePlugin(), + new HtmlWebpackPlugin({ + filename: path.resolve(__dirname, '../../public/views/error.html'), + template: path.resolve(__dirname, '../../public/views/error-template.html'), + inject: false, + }), new HtmlWebpackPlugin({ filename: path.resolve(__dirname, '../../public/views/index.html'), template: path.resolve(__dirname, '../../public/views/index-template.html'), inject: 'body', chunks: ['vendor', 'app'], }), - new HtmlWebpackPlugin({ - filename: path.resolve(__dirname, '../../public/views/error.html'), - template: path.resolve(__dirname, '../../public/views/error-template.html'), - inject: false, - }), function () { this.hooks.done.tap('Done', function (stats) { if (stats.compilation.errors && stats.compilation.errors.length) {
    - {{variable.query}} + {{variable.definition ? variable.definition : variable.query}}