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 @@
-
- TO
-
+
+ TO
+
diff --git a/public/app/features/alerting/state/alertDef.ts b/public/app/features/alerting/state/alertDef.ts
index 378be0afb91..8e50697d33c 100644
--- a/public/app/features/alerting/state/alertDef.ts
+++ b/public/app/features/alerting/state/alertDef.ts
@@ -8,9 +8,9 @@ const alertQueryDef = new QueryPartDef({
{
name: 'from',
type: 'string',
- options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h'],
+ options: ['10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h'],
},
- { name: 'to', type: 'string', options: ['now'] },
+ { name: 'to', type: 'string', options: ['now', 'now-1m', 'now-5m', 'now-10m', 'now-1h'] },
],
defaultParams: ['#A', '15m', 'now', 'avg'],
});
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index 5d39992c4a2..d4e9b689495 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -4,14 +4,26 @@ import Select from 'react-select';
import _ from 'lodash';
import { DataSource } from 'app/types/datasources';
-import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
+import {
+ ExploreState,
+ ExploreUrlState,
+ QueryTransaction,
+ ResultType,
+ QueryHintGetter,
+ QueryHint,
+} from 'app/types/explore';
import { RawTimeRange, DataQuery } from 'app/types/series';
-import kbn from 'app/core/utils/kbn';
-import colors from 'app/core/utils/colors';
import store from 'app/core/store';
-import TimeSeries from 'app/core/time_series2';
-import { parse as parseDate } from 'app/core/utils/datemath';
-import { DEFAULT_RANGE } from 'app/core/utils/explore';
+import {
+ DEFAULT_RANGE,
+ ensureQueries,
+ getIntervals,
+ generateKey,
+ generateQueryKeys,
+ hasNonEmptyQuery,
+ makeTimeSeriesList,
+ updateHistory,
+} from 'app/core/utils/explore';
import ResetStyles from 'app/core/components/Picker/ResetStyles';
import PickerOption from 'app/core/components/Picker/PickerOption';
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
@@ -26,57 +38,6 @@ import Logs from './Logs';
import Table from './Table';
import ErrorBoundary from './ErrorBoundary';
import TimePicker from './TimePicker';
-import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
-
-const MAX_HISTORY_ITEMS = 100;
-
-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);
-}
-
-function makeTimeSeriesList(dataList, options) {
- 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
- */
-function updateHistory(history: HistoryItem[], datasourceId: string, queries: string[]): 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;
-}
interface ExploreProps {
datasourceSrv: DatasourceSrv;
@@ -89,14 +50,49 @@ interface ExploreProps {
urlState: ExploreUrlState;
}
+/**
+ * Explore provides an area for quick query iteration for a given datasource.
+ * Once a datasource is selected it populates the query section at the top.
+ * When queries are run, their results are being displayed in the main section.
+ * The datasource determines what kind of query editor it brings, and what kind
+ * of results viewers it supports.
+ *
+ * QUERY HANDLING
+ *
+ * TLDR: to not re-render Explore during edits, query editing is not "controlled"
+ * in a React sense: values need to be pushed down via `initialQueries`, while
+ * edits travel up via `this.modifiedQueries`.
+ *
+ * By default the query rows start without prior state: `initialQueries` will
+ * contain one empty DataQuery. While the user modifies the DataQuery, the
+ * modifications are being tracked in `this.modifiedQueries`, which need to be
+ * used whenever a query is sent to the datasource to reflect what the user sees
+ * on the screen. Query rows can be initialized or reset using `initialQueries`,
+ * by giving the respective row a new key. This wipes the old row and its state.
+ * This property is also used to govern how many query rows there are (minimum 1).
+ *
+ * This flow makes sure that a query row can be arbitrarily complex without the
+ * fear of being wiped or re-initialized via props. The query row is free to keep
+ * its own state while the user edits or builds a query. Valid queries can be sent
+ * up to Explore via the `onChangeQuery` prop.
+ *
+ * DATASOURCE REQUESTS
+ *
+ * A click on Run Query creates transactions for all DataQueries for all expanded
+ * result viewers. New runs are discarding previous runs. Upon completion a transaction
+ * saves the result. The result viewers construct their data from the currently existing
+ * transactions.
+ *
+ * The result viewers determine some of the query options sent to the datasource, e.g.,
+ * `format`, to indicate eventual transformations by the datasources' result transformers.
+ */
export class Explore extends React.PureComponent {
el: any;
/**
* Current query expressions of the rows including their modifications, used for running queries.
* Not kept in component state to prevent edit-render roundtrips.
- * TODO: make this generic (other datasources might not have string representations of current query state)
*/
- queryExpressions: string[];
+ modifiedQueries: DataQuery[];
/**
* Local ID cache to compare requested vs selected datasource
*/
@@ -105,11 +101,11 @@ export class Explore extends React.PureComponent {
constructor(props) {
super(props);
const splitState: ExploreState = props.splitState;
- let initialQueries: Query[];
+ let initialQueries: DataQuery[];
if (splitState) {
// Split state overrides everything
this.state = splitState;
- initialQueries = splitState.queries;
+ initialQueries = splitState.initialQueries;
} else {
const { datasource, queries, range } = props.urlState as ExploreUrlState;
initialQueries = ensureQueries(queries);
@@ -122,8 +118,8 @@ export class Explore extends React.PureComponent {
datasourceName: datasource,
exploreDatasources: [],
graphRange: initialRange,
+ initialQueries,
history: [],
- queries: initialQueries,
queryTransactions: [],
range: initialRange,
showingGraph: true,
@@ -135,7 +131,7 @@ export class Explore extends React.PureComponent {
supportsTable: null,
};
}
- this.queryExpressions = initialQueries.map(q => q.query);
+ this.modifiedQueries = initialQueries.slice();
}
async componentDidMount() {
@@ -198,32 +194,26 @@ export class Explore extends React.PureComponent {
}
// Check if queries can be imported from previously selected datasource
- let queryExpressions = this.queryExpressions;
+ let modifiedQueries = this.modifiedQueries;
if (origin) {
if (origin.meta.id === datasource.meta.id) {
// Keep same queries if same type of datasource
- queryExpressions = [...this.queryExpressions];
+ modifiedQueries = [...this.modifiedQueries];
} else if (datasource.importQueries) {
- // Datasource-specific importers, wrapping to satisfy interface
- const wrappedQueries: DataQuery[] = this.queryExpressions.map((query, index) => ({
- refId: String(index),
- expr: query,
- }));
- const modifiedQueries: DataQuery[] = await datasource.importQueries(wrappedQueries, origin.meta);
- queryExpressions = modifiedQueries.map(({ expr }) => expr);
+ // Datasource-specific importers
+ modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta);
} else {
// Default is blank queries
- queryExpressions = this.queryExpressions.map(() => '');
+ modifiedQueries = ensureQueries();
}
}
// Reset edit state with new queries
- const nextQueries = this.state.queries.map((q, i) => ({
- ...q,
- key: generateQueryKey(i),
- query: queryExpressions[i],
+ const nextQueries = this.state.initialQueries.map((q, i) => ({
+ ...modifiedQueries[i],
+ ...generateQueryKeys(i),
}));
- this.queryExpressions = queryExpressions;
+ this.modifiedQueries = modifiedQueries;
// Custom components
const StartPage = datasource.pluginExports.ExploreStartPage;
@@ -239,7 +229,7 @@ export class Explore extends React.PureComponent {
supportsTable,
datasourceLoading: false,
datasourceName: datasource.name,
- queries: nextQueries,
+ initialQueries: nextQueries,
showingStartPage: Boolean(StartPage),
},
() => {
@@ -256,16 +246,15 @@ export class Explore extends React.PureComponent {
onAddQueryRow = index => {
// Local cache
- this.queryExpressions[index + 1] = '';
+ this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) };
this.setState(state => {
- const { queries, queryTransactions } = state;
+ const { initialQueries, queryTransactions } = state;
- // Add row by generating new react key
const nextQueries = [
- ...queries.slice(0, index + 1),
- { query: '', key: generateQueryKey() },
- ...queries.slice(index + 1),
+ ...initialQueries.slice(0, index + 1),
+ { ...this.modifiedQueries[index + 1] },
+ ...initialQueries.slice(index + 1),
];
// Ongoing transactions need to update their row indices
@@ -279,7 +268,7 @@ export class Explore extends React.PureComponent {
return qt;
});
- return { queries: nextQueries, queryTransactions: nextQueryTransactions };
+ return { initialQueries: nextQueries, queryTransactions: nextQueryTransactions };
});
};
@@ -296,26 +285,32 @@ export class Explore extends React.PureComponent {
this.setDatasource(datasource as any, origin);
};
- onChangeQuery = (value: string, index: number, override?: boolean) => {
+ onChangeQuery = (value: DataQuery, index: number, override?: boolean) => {
+ // Null value means reset
+ if (value === null) {
+ value = { ...generateQueryKeys(index) };
+ }
+
// Keep current value in local cache
- this.queryExpressions[index] = value;
+ this.modifiedQueries[index] = value;
if (override) {
this.setState(state => {
- // Replace query row
- const { queries, queryTransactions } = state;
- const nextQuery: Query = {
- key: generateQueryKey(index),
- query: value,
+ // Replace query row by injecting new key
+ const { initialQueries, queryTransactions } = state;
+ const query: DataQuery = {
+ ...value,
+ ...generateQueryKeys(index),
};
- const nextQueries = [...queries];
- nextQueries[index] = nextQuery;
+ const nextQueries = [...initialQueries];
+ nextQueries[index] = query;
+ this.modifiedQueries = [...nextQueries];
// Discard ongoing transaction related to row query
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
return {
- queries: nextQueries,
+ initialQueries: nextQueries,
queryTransactions: nextQueryTransactions,
};
}, this.onSubmit);
@@ -330,10 +325,10 @@ export class Explore extends React.PureComponent {
};
onClickClear = () => {
- this.queryExpressions = [''];
+ this.modifiedQueries = ensureQueries();
this.setState(
prevState => ({
- queries: ensureQueries(),
+ initialQueries: [...this.modifiedQueries],
queryTransactions: [],
showingStartPage: Boolean(prevState.StartPage),
}),
@@ -387,10 +382,10 @@ export class Explore extends React.PureComponent {
};
// Use this in help pages to set page to a single query
- onClickQuery = query => {
- const nextQueries = [{ query, key: generateQueryKey() }];
- this.queryExpressions = nextQueries.map(q => q.query);
- this.setState({ queries: nextQueries }, this.onSubmit);
+ onClickExample = (query: DataQuery) => {
+ const nextQueries = [{ ...query, ...generateQueryKeys() }];
+ this.modifiedQueries = [...nextQueries];
+ this.setState({ initialQueries: nextQueries }, this.onSubmit);
};
onClickSplit = () => {
@@ -430,28 +425,28 @@ export class Explore extends React.PureComponent {
const preventSubmit = action.preventSubmit;
this.setState(
state => {
- const { queries, queryTransactions } = state;
- let nextQueries;
+ const { initialQueries, queryTransactions } = state;
+ let nextQueries: DataQuery[];
let nextQueryTransactions;
if (index === undefined) {
// Modify all queries
- nextQueries = queries.map((q, i) => ({
- key: generateQueryKey(i),
- query: datasource.modifyQuery(this.queryExpressions[i], action),
+ nextQueries = initialQueries.map((query, i) => ({
+ ...datasource.modifyQuery(this.modifiedQueries[i], action),
+ ...generateQueryKeys(i),
}));
// Discard all ongoing transactions
nextQueryTransactions = [];
} else {
// Modify query only at index
- nextQueries = queries.map((q, i) => {
+ nextQueries = initialQueries.map((query, i) => {
// Synchronise all queries with local query cache to ensure consistency
- q.query = this.queryExpressions[i];
+ // TODO still needed?
return i === index
? {
- key: generateQueryKey(index),
- query: datasource.modifyQuery(q.query, action),
+ ...datasource.modifyQuery(this.modifiedQueries[i], action),
+ ...generateQueryKeys(i),
}
- : q;
+ : query;
});
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action
@@ -464,9 +459,9 @@ export class Explore extends React.PureComponent {
// Preserve previous row query transaction to keep results visible if next query is incomplete
.filter(qt => preventSubmit || qt.rowIndex !== index);
}
- this.queryExpressions = nextQueries.map(q => q.query);
+ this.modifiedQueries = [...nextQueries];
return {
- queries: nextQueries,
+ initialQueries: nextQueries,
queryTransactions: nextQueryTransactions,
};
},
@@ -478,22 +473,22 @@ export class Explore extends React.PureComponent {
onRemoveQueryRow = index => {
// Remove from local cache
- this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)];
+ this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)];
this.setState(
state => {
- const { queries, queryTransactions } = state;
- if (queries.length <= 1) {
+ const { initialQueries, queryTransactions } = state;
+ if (initialQueries.length <= 1) {
return null;
}
// Remove row from react state
- const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
+ const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
// Discard transactions related to row query
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
return {
- queries: nextQueries,
+ initialQueries: nextQueries,
queryTransactions: nextQueryTransactions,
};
},
@@ -503,52 +498,68 @@ export class Explore extends React.PureComponent {
onSubmit = () => {
const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
+ // Keep table queries first since they need to return quickly
if (showingTable && supportsTable) {
- this.runTableQuery();
+ this.runQueries(
+ 'Table',
+ {
+ format: 'table',
+ instant: true,
+ valueWithRefId: true,
+ },
+ data => data[0]
+ );
}
if (showingGraph && supportsGraph) {
- this.runGraphQueries();
+ this.runQueries(
+ 'Graph',
+ {
+ format: 'time_series',
+ instant: false,
+ },
+ makeTimeSeriesList
+ );
}
if (showingLogs && supportsLogs) {
- this.runLogsQuery();
+ this.runQueries('Logs', { format: 'logs' });
}
this.saveState();
};
- buildQueryOptions(
- query: string,
- rowIndex: number,
- targetOptions: { format: string; hinting?: boolean; instant?: boolean }
- ) {
+ buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) {
const { datasource, range } = this.state;
const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth);
- const targets = [
+
+ const configuredQueries = [
{
- ...targetOptions,
- // Target identifier is needed for table transformations
- refId: rowIndex + 1,
- expr: query,
+ ...queryOptions,
+ ...query,
},
];
// Clone range for query request
const queryRange: RawTimeRange = { ...range };
+ // Datasource is using `panelId + query.refId` for cancellation logic.
+ // Using `format` here because it relates to the view panel that the request is for.
+ const panelId = queryOptions.format;
+
return {
interval,
intervalMs,
- targets,
+ panelId,
+ targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
range: queryRange,
};
}
- startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
- const queryOptions = this.buildQueryOptions(query, rowIndex, options);
+ startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
+ const queryOptions = this.buildQueryOptions(query, options);
const transaction: QueryTransaction = {
query,
resultType,
rowIndex,
- id: generateQueryKey(),
+ id: generateKey(), // reusing for unique ID
done: false,
latency: 0,
options: queryOptions,
@@ -578,7 +589,7 @@ export class Explore extends React.PureComponent {
transactionId: string,
result: any,
latency: number,
- queries: string[],
+ queries: DataQuery[],
datasourceId: string
) {
const { datasource } = this.state;
@@ -597,8 +608,8 @@ export class Explore extends React.PureComponent {
}
// Get query hints
- let hints;
- if (datasource.getQueryHints) {
+ let hints: QueryHint[];
+ if (datasource.getQueryHints as QueryHintGetter) {
hints = datasource.getQueryHints(transaction.query, result);
}
@@ -634,7 +645,7 @@ export class Explore extends React.PureComponent {
failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
const { datasource } = this.state;
- if (datasource.meta.id !== datasourceId) {
+ if (datasource.meta.id !== datasourceId || response.cancelled) {
// Navigated away, queries did not matter
return;
}
@@ -678,88 +689,25 @@ export class Explore extends React.PureComponent {
});
}
- async runGraphQueries() {
- const queries = [...this.queryExpressions];
- if (!hasQuery(queries)) {
+ async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) {
+ const queries = [...this.modifiedQueries];
+ if (!hasNonEmptyQuery(queries)) {
return;
}
const { datasource } = this.state;
const datasourceId = datasource.meta.id;
// Run all queries concurrently
queries.forEach(async (query, rowIndex) => {
- if (query) {
- const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', {
- format: 'time_series',
- instant: false,
- });
- try {
- const now = Date.now();
- const res = await datasource.query(transaction.options);
- const latency = Date.now() - now;
- const results = makeTimeSeriesList(res.data, transaction.options);
- this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
- this.setState({ graphRange: transaction.options.range });
- } catch (response) {
- this.failQueryTransaction(transaction.id, response, datasourceId);
- }
- } else {
- this.discardTransactions(rowIndex);
- }
- });
- }
-
- async runTableQuery() {
- const queries = [...this.queryExpressions];
- if (!hasQuery(queries)) {
- return;
- }
- const { datasource } = this.state;
- const datasourceId = datasource.meta.id;
- // Run all queries concurrently
- queries.forEach(async (query, rowIndex) => {
- if (query) {
- const transaction = this.startQueryTransaction(query, rowIndex, 'Table', {
- format: 'table',
- instant: true,
- valueWithRefId: true,
- });
- try {
- const now = Date.now();
- const res = await datasource.query(transaction.options);
- const latency = Date.now() - now;
- const results = res.data[0];
- this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
- } catch (response) {
- this.failQueryTransaction(transaction.id, response, datasourceId);
- }
- } else {
- this.discardTransactions(rowIndex);
- }
- });
- }
-
- async runLogsQuery() {
- const queries = [...this.queryExpressions];
- if (!hasQuery(queries)) {
- return;
- }
- const { datasource } = this.state;
- const datasourceId = datasource.meta.id;
- // Run all queries concurrently
- queries.forEach(async (query, rowIndex) => {
- if (query) {
- const transaction = this.startQueryTransaction(query, rowIndex, 'Logs', { format: 'logs' });
- try {
- const now = Date.now();
- const res = await datasource.query(transaction.options);
- const latency = Date.now() - now;
- const results = res.data;
- this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
- } catch (response) {
- this.failQueryTransaction(transaction.id, response, datasourceId);
- }
- } else {
- this.discardTransactions(rowIndex);
+ const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions);
+ try {
+ const now = Date.now();
+ const res = await datasource.query(transaction.options);
+ const latency = Date.now() - now;
+ const results = resultGetter ? resultGetter(res.data) : res.data;
+ this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
+ this.setState({ graphRange: transaction.options.range });
+ } catch (response) {
+ this.failQueryTransaction(transaction.id, response, datasourceId);
}
});
}
@@ -769,7 +717,7 @@ export class Explore extends React.PureComponent {
return {
...this.state,
queryTransactions: [],
- queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
+ initialQueries: [...this.modifiedQueries],
};
}
@@ -789,7 +737,7 @@ export class Explore extends React.PureComponent {
exploreDatasources,
graphRange,
history,
- queries,
+ initialQueries,
queryTransactions,
range,
showingGraph,
@@ -903,7 +851,7 @@ export class Explore extends React.PureComponent {
{
/>
- {showingStartPage && }
+ {showingStartPage && }
{!showingStartPage && (
<>
{supportsGraph && (
diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx
index 61cf5753b19..01e9d5e7921 100644
--- a/public/app/features/explore/Graph.tsx
+++ b/public/app/features/explore/Graph.tsx
@@ -13,6 +13,7 @@ import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2';
import Legend from './Legend';
+import { equal, intersect } from './utils/set';
const MAX_NUMBER_OF_TIME_SERIES = 20;
@@ -85,13 +86,20 @@ interface GraphProps {
}
interface GraphState {
+ /**
+ * Type parameter refers to the `alias` property of a `TimeSeries`.
+ * Consequently, all series sharing the same alias will share visibility state.
+ */
+ hiddenSeries: Set;
showAllTimeSeries: boolean;
}
export class Graph extends PureComponent {
$el: any;
+ dynamicOptions = null;
state = {
+ hiddenSeries: new Set(),
showAllTimeSeries: false,
};
@@ -107,13 +115,14 @@ export class Graph extends PureComponent {
this.$el.bind('plotselected', this.onPlotSelected);
}
- componentDidUpdate(prevProps: GraphProps) {
+ componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
if (
prevProps.data !== this.props.data ||
prevProps.range !== this.props.range ||
prevProps.split !== this.props.split ||
prevProps.height !== this.props.height ||
- (prevProps.size && prevProps.size.width !== this.props.size.width)
+ (prevProps.size && prevProps.size.width !== this.props.size.width) ||
+ !equal(prevState.hiddenSeries, this.state.hiddenSeries)
) {
this.draw();
}
@@ -133,30 +142,8 @@ export class Graph extends PureComponent {
}
};
- onShowAllTimeSeries = () => {
- this.setState(
- {
- showAllTimeSeries: true,
- },
- this.draw
- );
- };
-
- draw() {
- const { range, size, userOptions = {} } = this.props;
- const data = this.getGraphData();
-
- const $el = $(`#${this.props.id}`);
- let series = [{ data: [[0, 0]] }];
-
- if (data && data.length > 0) {
- series = data.map((ts: TimeSeries) => ({
- color: ts.color,
- label: ts.label,
- data: ts.getFlotPairs('null'),
- }));
- }
-
+ getDynamicOptions() {
+ const { range, size } = this.props;
const ticks = (size.width || 0) / 100;
let { from, to } = range;
if (!moment.isMoment(from)) {
@@ -167,7 +154,7 @@ export class Graph extends PureComponent {
}
const min = from.valueOf();
const max = to.valueOf();
- const dynamicOptions = {
+ return {
xaxis: {
mode: 'time',
min: min,
@@ -178,16 +165,76 @@ export class Graph extends PureComponent {
timeformat: time_format(ticks, min, max),
},
};
+ }
+
+ onShowAllTimeSeries = () => {
+ this.setState(
+ {
+ showAllTimeSeries: true,
+ },
+ this.draw
+ );
+ };
+
+ onToggleSeries = (series: TimeSeries, exclusive: boolean) => {
+ this.setState((state, props) => {
+ const { data } = props;
+ const { hiddenSeries } = state;
+ const hidden = hiddenSeries.has(series.alias);
+ // Deduplicate series as visibility tracks the alias property
+ const oneSeriesVisible = hiddenSeries.size === new Set(data.map(d => d.alias)).size - 1;
+ if (exclusive) {
+ return {
+ hiddenSeries:
+ !hidden && oneSeriesVisible
+ ? new Set()
+ : new Set(data.filter(d => d.alias !== series.alias).map(d => d.alias)),
+ };
+ }
+ // Prune hidden series no longer part of those available from the most recent query
+ const availableSeries = new Set(data.map(d => d.alias));
+ const nextHiddenSeries = intersect(new Set(hiddenSeries), availableSeries);
+ if (nextHiddenSeries.has(series.alias)) {
+ nextHiddenSeries.delete(series.alias);
+ } else {
+ nextHiddenSeries.add(series.alias);
+ }
+ return {
+ hiddenSeries: nextHiddenSeries,
+ };
+ }, this.draw);
+ };
+
+ draw() {
+ const { userOptions = {} } = this.props;
+ const { hiddenSeries } = this.state;
+ const data = this.getGraphData();
+
+ const $el = $(`#${this.props.id}`);
+ let series = [{ data: [[0, 0]] }];
+
+ if (data && data.length > 0) {
+ series = data.filter((ts: TimeSeries) => !hiddenSeries.has(ts.alias)).map((ts: TimeSeries) => ({
+ color: ts.color,
+ label: ts.label,
+ data: ts.getFlotPairs('null'),
+ }));
+ }
+
+ this.dynamicOptions = this.getDynamicOptions();
+
const options = {
...FLOT_OPTIONS,
- ...dynamicOptions,
+ ...this.dynamicOptions,
...userOptions,
};
+
$.plot($el, series, options);
}
render() {
const { height = '100px', id = 'graph' } = this.props;
+ const { hiddenSeries } = this.state;
const data = this.getGraphData();
return (
@@ -204,7 +251,7 @@ export class Graph extends PureComponent {
)}
-
+
>
);
}
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 }) => (
-
-);
+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 @@
- {{variable.query}}
+ {{variable.definition ? variable.definition : variable.query}}
@@ -77,7 +79,8 @@
- 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
@@ -143,7 +151,8 @@
Custom Options
Values separated by comma
-
+
@@ -168,15 +177,17 @@
+
+
+
+
+
+
- Query
-
-
-
-
+
Regex
Optional, if you want to extract part of a series name or metric node segment.
-
+
@@ -219,7 +234,8 @@
@@ -234,7 +250,8 @@
-
+
@@ -243,7 +260,8 @@
@@ -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}
+
+
+ {options.map(({ value, name }, i) => (
+
+ {name}
+
+ ))}
+
+
+
+ );
+};
+
+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
+
+
+
+
+ Services
+
+
+ Metric Types
+
+
+ Label Keys
+
+
+ Label Values
+
+
+ Resource Types
+
+
+ Aggregations
+
+
+ Aligners
+
+
+ Alignment Periods
+
+
+
+
,
+ "",
+]
+`;
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 @@