Merge branch 'master' into develop

This commit is contained in:
Erik Sundell
2018-11-22 13:43:25 +01:00
79 changed files with 2006 additions and 579 deletions

View File

@@ -510,6 +510,7 @@ workflows:
- grafana-docker-release: - grafana-docker-release:
requires: requires:
- build-all - build-all
- build-all-enterprise
- test-backend - test-backend
- test-frontend - test-frontend
- codespell - codespell

View File

@@ -1,5 +1,9 @@
# 5.4.0 (unreleased) # 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 ### 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) * **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) * **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) * **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) * **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 ### Minor
* **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043) * **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**: 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**: 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) * **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) * **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) * **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) * **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) * **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) * **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 ### 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) # 5.3.4 (2018-11-13)

View File

@@ -490,6 +490,10 @@ enabled = false
enabled = true enabled = true
interval_seconds = 10 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 # Send internal Grafana metrics to graphite
[metrics.graphite] [metrics.graphite]
# Enable by setting the address setting (ex localhost:2003) # Enable by setting the address setting (ex localhost:2003)

View File

@@ -104,6 +104,7 @@
} }
], ],
"timeFrom": null, "timeFrom": null,
"timeRegions": [],
"timeShift": null, "timeShift": null,
"title": "Always OK", "title": "Always OK",
"tooltip": { "tooltip": {
@@ -232,6 +233,7 @@
} }
], ],
"timeFrom": null, "timeFrom": null,
"timeRegions": [],
"timeShift": null, "timeShift": null,
"title": "Always Alerting", "title": "Always Alerting",
"tooltip": { "tooltip": {
@@ -362,6 +364,7 @@
} }
], ],
"timeFrom": null, "timeFrom": null,
"timeRegions": [],
"timeShift": null, "timeShift": null,
"title": "No data", "title": "No data",
"tooltip": { "tooltip": {
@@ -432,7 +435,7 @@
"for": "1m", "for": "1m",
"frequency": "1m", "frequency": "1m",
"handler": 1, "handler": 1,
"name": "TestData - Always Alerting with For", "name": "TestData - Always Pending",
"noDataState": "no_data", "noDataState": "no_data",
"notifications": [] "notifications": []
}, },
@@ -492,6 +495,138 @@
} }
], ],
"timeFrom": null, "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, "timeShift": null,
"title": "Always Alerting with For", "title": "Always Alerting with For",
"tooltip": { "tooltip": {
@@ -573,5 +708,5 @@
"timezone": "browser", "timezone": "browser",
"title": "Alerting with TestData", "title": "Alerting with TestData",
"uid": "7MeksYbmk", "uid": "7MeksYbmk",
"version": 1 "version": 7
} }

View File

@@ -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. 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" >}} {{< 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. - `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. - `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. 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.

View File

@@ -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. It is also possible to resolve the name of the Monitored Resource Type.
| Alias Pattern Format | Description | Example Result | | Alias Pattern Format | Description | Example Result |
| ------------------------ | ------------------------------------------------| ---------------- | | -------------------- | ----------------------------------------------- | -------------- |
| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` | | `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` |
Example Alias By: `{{resource.type}} - {{metric.type}}` Example Alias By: `{{resource.type}} - {{metric.type}}`
@@ -177,7 +177,17 @@ types of template variables.
### Query Variable ### 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 ### Using variables in queries

View File

@@ -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.

View File

@@ -226,6 +226,40 @@ Content-Type: application/json
**Example Response**: **Example Response**:
```http ```http
HTTP/1.1 200
Content-Type: application/json
```
## Switch user context for a specified user
`POST /api/users/:userId/using/:organizationId`
Switch user context to the given organization. Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Request**:
```http
POST /api/users/7/using/2 HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
```
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```
## Switch user context for signed in user
`POST /api/user/using/:organizationId`
Switch user context to the given organization.
**Example Request**:
```http ```http
POST /api/user/using/2 HTTP/1.1 POST /api/user/using/2 HTTP/1.1
Accept: application/json Accept: application/json

View File

@@ -454,6 +454,12 @@ Ex `filters = sqlstore:debug`
### enabled ### enabled
Enable metrics reporting. defaults true. Available via HTTP API `/metrics`. 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 ### interval_seconds
Flush/Write interval when sending metrics to external TSDB. Defaults to 10s. Flush/Write interval when sending metrics to external TSDB. Defaults to 10s.

View File

@@ -1,9 +1,17 @@
#!/bin/sh #!/bin/sh
set -e set -e
_grafana_tag=$1 _raw_grafana_tag=$1
_docker_repo=${2:-grafana/grafana-enterprise} _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 \ docker build \
--tag "${_docker_repo}:${_grafana_tag}"\ --tag "${_docker_repo}:${_grafana_tag}"\
--no-cache=true \ --no-cache=true \

View File

@@ -140,6 +140,7 @@ func (hs *HTTPServer) registerRoutes() {
usersRoute.Get("/", Wrap(SearchUsers)) usersRoute.Get("/", Wrap(SearchUsers))
usersRoute.Get("/search", Wrap(SearchUsersWithPaging)) usersRoute.Get("/search", Wrap(SearchUsersWithPaging))
usersRoute.Get("/:id", Wrap(GetUserByID)) usersRoute.Get("/:id", Wrap(GetUserByID))
usersRoute.Get("/:id/teams", Wrap(GetUserTeams))
usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList)) usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList))
// query parameters /users/lookup?loginOrEmail=admin@example.com // query parameters /users/lookup?loginOrEmail=admin@example.com
usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail)) usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail))

19
pkg/api/basic_auth.go Normal file
View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -245,6 +245,11 @@ func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) {
return 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{}). promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}).
ServeHTTP(ctx.Resp, ctx.Req.Request) 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 != ""
}

View File

@@ -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)
})
})
}

View File

@@ -113,7 +113,16 @@ func GetSignedInUserOrgList(c *m.ReqContext) Response {
// GET /api/user/teams // GET /api/user/teams
func GetSignedInUserTeamList(c *m.ReqContext) Response { 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 { if err := bus.Dispatch(&query); err != nil {
return Error(500, "Failed to get user teams", err) return Error(500, "Failed to get user teams", err)
@@ -122,11 +131,10 @@ func GetSignedInUserTeamList(c *m.ReqContext) Response {
for _, team := range query.Result { for _, team := range query.Result {
team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name) team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name)
} }
return JSON(200, query.Result) return JSON(200, query.Result)
} }
// GET /api/user/:id/orgs // GET /api/users/:id/orgs
func GetUserOrgList(c *m.ReqContext) Response { func GetUserOrgList(c *m.ReqContext) Response {
return getUserOrgList(c.ParamsInt64(":id")) return getUserOrgList(c.ParamsInt64(":id"))
} }

View File

@@ -54,7 +54,10 @@ func main() {
if *profile { if *profile {
runtime.SetBlockProfileRate(1) runtime.SetBlockProfileRate(1)
go func() { 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") f, err := os.Create("trace.out")

View File

@@ -67,6 +67,7 @@ type GrafanaServerImpl struct {
} }
func (g *GrafanaServerImpl) Run() error { func (g *GrafanaServerImpl) Run() error {
var err error
g.loadConfiguration() g.loadConfiguration()
g.writePIDFile() g.writePIDFile()
@@ -74,20 +75,38 @@ func (g *GrafanaServerImpl) Run() error {
social.NewOAuthService() social.NewOAuthService()
serviceGraph := inject.Graph{} serviceGraph := inject.Graph{}
serviceGraph.Provide(&inject.Object{Value: bus.GetBus()}) err = serviceGraph.Provide(&inject.Object{Value: bus.GetBus()})
serviceGraph.Provide(&inject.Object{Value: g.cfg}) if err != nil {
serviceGraph.Provide(&inject.Object{Value: routing.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)}) return fmt.Errorf("Failed to provide object to the graph: %v", err)
serviceGraph.Provide(&inject.Object{Value: cache.New(5*time.Minute, 10*time.Minute)}) }
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 // self registered services
services := registry.GetServices() services := registry.GetServices()
// Add all services to dependency graph // Add all services to dependency graph
for _, service := range services { 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 // Inject dependencies to services
if err := serviceGraph.Populate(); err != nil { if err := serviceGraph.Populate(); err != nil {
@@ -144,6 +163,7 @@ func (g *GrafanaServerImpl) Run() error {
} }
sendSystemdNotification("READY=1") sendSystemdNotification("READY=1")
return g.childRoutines.Wait() return g.childRoutines.Wait()
} }

View File

@@ -115,6 +115,7 @@ func Recovery() macaron.Handler {
c.Data["Title"] = "Server Error" c.Data["Title"] = "Server Error"
c.Data["AppSubUrl"] = setting.AppSubUrl c.Data["AppSubUrl"] = setting.AppSubUrl
c.Data["Theme"] = setting.DefaultTheme
if setting.Env == setting.DEV { if setting.Env == setting.DEV {
if theErr, ok := err.(error); ok { if theErr, ok := err.(error); ok {

View File

@@ -219,6 +219,8 @@ type Cfg struct {
DisableBruteForceLoginProtection bool DisableBruteForceLoginProtection bool
TempDataLifetime time.Duration TempDataLifetime time.Duration
MetricsEndpointEnabled bool MetricsEndpointEnabled bool
MetricsEndpointBasicAuthUsername string
MetricsEndpointBasicAuthPassword string
EnableAlphaPanels bool EnableAlphaPanels bool
EnterpriseLicensePath string EnterpriseLicensePath string
} }
@@ -681,6 +683,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs") cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)
cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true) 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") analytics := iniFile.Section("analytics")
ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true)

View File

@@ -126,6 +126,18 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
} }
eg.Go(func() error { 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) queryRes, err := e.executeQuery(ectx, query, queryContext)
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
return err return err
@@ -146,6 +158,17 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
for region, getMetricDataQuery := range getMetricDataQueries { for region, getMetricDataQuery := range getMetricDataQueries {
q := getMetricDataQuery q := getMetricDataQuery
eg.Go(func() error { 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) queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext)
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" { if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
return err return err
@@ -188,8 +211,8 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatch
return nil, err return nil, err
} }
if endTime.Before(startTime) { if !startTime.Before(endTime) {
return nil, fmt.Errorf("Invalid time range: End time can't be before start time") return nil, fmt.Errorf("Invalid time range: Start time must be before end time")
} }
params := &cloudwatch.GetMetricStatisticsInput{ params := &cloudwatch.GetMetricStatisticsInput{

View File

@@ -1,9 +1,13 @@
package cloudwatch package cloudwatch
import ( import (
"context"
"testing" "testing"
"time" "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/aws"
"github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/null"
@@ -14,6 +18,24 @@ import (
func TestCloudWatch(t *testing.T) { func TestCloudWatch(t *testing.T) {
Convey("CloudWatch", t, func() { 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() { Convey("can parse cloudwatch json model", func() {
json := ` json := `
{ {

View File

@@ -14,3 +14,4 @@ exports[`PickerOption renders correctly 1`] = `
</div> </div>
</div> </div>
`; `;

View File

@@ -26,7 +26,7 @@ export class Analytics {
init() { init() {
this.$rootScope.$on('$viewContentLoaded', () => { this.$rootScope.$on('$viewContentLoaded', () => {
const track = { location: this.$location.url() }; const track = { page: this.$location.url() };
const ga = (window as any).ga || this.gaInit(); const ga = (window as any).ga || this.gaInit();
ga('set', track); ga('set', track);
ga('send', 'pageview'); ga('send', 'pageview');

View File

@@ -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 { ExploreState } from 'app/types/explore';
import store from 'app/core/store';
const DEFAULT_EXPLORE_STATE: ExploreState = { const DEFAULT_EXPLORE_STATE: ExploreState = {
datasource: null, datasource: null,
@@ -10,7 +18,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
exploreDatasources: [], exploreDatasources: [],
graphRange: DEFAULT_RANGE, graphRange: DEFAULT_RANGE,
history: [], history: [],
queries: [], initialQueries: [],
queryTransactions: [], queryTransactions: [],
range: DEFAULT_RANGE, range: DEFAULT_RANGE,
showingGraph: true, showingGraph: true,
@@ -33,10 +41,10 @@ describe('state functions', () => {
it('returns a valid Explore state from URL parameter', () => { it('returns a valid Explore state from URL parameter', () => {
const paramValue = 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({ expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'Local', datasource: 'Local',
queries: [{ query: 'metric' }], queries: [{ expr: 'metric' }],
range: { range: {
from: 'now-1h', from: 'now-1h',
to: 'now', to: 'now',
@@ -45,10 +53,10 @@ describe('state functions', () => {
}); });
it('returns a valid Explore state from a compact URL parameter', () => { 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({ expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'Local', datasource: 'Local',
queries: [{ query: 'metric' }], queries: [{ expr: 'metric' }],
range: { range: {
from: 'now-1h', from: 'now-1h',
to: 'now', to: 'now',
@@ -66,18 +74,20 @@ describe('state functions', () => {
from: 'now-5h', from: 'now-5h',
to: 'now', 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( expect(serializeStateToUrlParam(state)).toBe(
'{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' + '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}' '{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
); );
}); });
@@ -89,17 +99,19 @@ describe('state functions', () => {
from: 'now-5h', from: 'now-5h',
to: 'now', 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( 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', from: 'now - 5h',
to: 'now', 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); const parsed = parseUrlState(serialized);
// Account for datasource vs datasourceName // Account for datasource vs datasourceName
const { datasource, ...rest } = parsed; const { datasource, queries, ...rest } = parsed;
const sameState = { const resultState = {
...rest, ...rest,
datasource: DEFAULT_EXPLORE_STATE.datasource, datasource: DEFAULT_EXPLORE_STATE.datasource,
datasourceName: 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();
});
});

View File

@@ -1,11 +1,20 @@
import { renderUrl } from 'app/core/utils/url'; 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 = { export const DEFAULT_RANGE = {
from: 'now-6h', from: 'now-6h',
to: 'now', to: 'now',
}; };
const MAX_HISTORY_ITEMS = 100;
/** /**
* Returns an Explore-URL that contains a panel's queries and the dashboard time range. * 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 timeSrv: any
) { ) {
let exploreDatasource = panelDatasource; let exploreDatasource = panelDatasource;
let exploreTargets = panelTargets; let exploreTargets: DataQuery[] = panelTargets;
let url; let url;
// Mixed datasources need to choose only one datasource // Mixed datasources need to choose only one datasource
@@ -57,6 +66,8 @@ export async function getExploreUrl(
return url; return url;
} }
const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
export function parseUrlState(initial: string | undefined): ExploreUrlState { export function parseUrlState(initial: string | undefined): ExploreUrlState {
if (initial) { if (initial) {
try { try {
@@ -70,7 +81,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
to: parsed[1], to: parsed[1],
}; };
const datasource = parsed[2]; const datasource = parsed[2];
const queries = parsed.slice(3).map(query => ({ query })); const queries = parsed.slice(3);
return { datasource, queries, range }; return { datasource, queries, range };
} }
return parsed; return parsed;
@@ -84,16 +95,97 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string { export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
const urlState: ExploreUrlState = { const urlState: ExploreUrlState = {
datasource: state.datasourceName, datasource: state.datasourceName,
queries: state.queries.map(q => ({ query: q.query })), queries: state.initialQueries.map(clearQueryKeys),
range: state.range, range: state.range,
}; };
if (compact) { if (compact) {
return JSON.stringify([ return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
urlState.range.from,
urlState.range.to,
urlState.datasource,
...urlState.queries.map(q => q.query),
]);
} }
return JSON.stringify(urlState); 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);
}

View File

@@ -64,9 +64,9 @@
</div> </div>
<div class="gf-form"> <div class="gf-form">
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model> <metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()"></input> <input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()">
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label> <label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()"></input> <input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()">
</div> </div>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label"> <label class="gf-form-label">

View File

@@ -8,9 +8,9 @@ const alertQueryDef = new QueryPartDef({
{ {
name: 'from', name: 'from',
type: 'string', 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'], defaultParams: ['#A', '15m', 'now', 'avg'],
}); });

View File

@@ -4,14 +4,26 @@ import Select from 'react-select';
import _ from 'lodash'; import _ from 'lodash';
import { DataSource } from 'app/types/datasources'; 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 { 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 store from 'app/core/store';
import TimeSeries from 'app/core/time_series2'; import {
import { parse as parseDate } from 'app/core/utils/datemath'; DEFAULT_RANGE,
import { DEFAULT_RANGE } from 'app/core/utils/explore'; ensureQueries,
getIntervals,
generateKey,
generateQueryKeys,
hasNonEmptyQuery,
makeTimeSeriesList,
updateHistory,
} from 'app/core/utils/explore';
import ResetStyles from 'app/core/components/Picker/ResetStyles'; import ResetStyles from 'app/core/components/Picker/ResetStyles';
import PickerOption from 'app/core/components/Picker/PickerOption'; import PickerOption from 'app/core/components/Picker/PickerOption';
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'; import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
@@ -26,57 +38,6 @@ import Logs from './Logs';
import Table from './Table'; import Table from './Table';
import ErrorBoundary from './ErrorBoundary'; import ErrorBoundary from './ErrorBoundary';
import TimePicker from './TimePicker'; 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 { interface ExploreProps {
datasourceSrv: DatasourceSrv; datasourceSrv: DatasourceSrv;
@@ -89,14 +50,49 @@ interface ExploreProps {
urlState: ExploreUrlState; 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<ExploreProps, ExploreState> { export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
el: any; el: any;
/** /**
* Current query expressions of the rows including their modifications, used for running queries. * Current query expressions of the rows including their modifications, used for running queries.
* Not kept in component state to prevent edit-render roundtrips. * 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 * Local ID cache to compare requested vs selected datasource
*/ */
@@ -105,11 +101,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
constructor(props) { constructor(props) {
super(props); super(props);
const splitState: ExploreState = props.splitState; const splitState: ExploreState = props.splitState;
let initialQueries: Query[]; let initialQueries: DataQuery[];
if (splitState) { if (splitState) {
// Split state overrides everything // Split state overrides everything
this.state = splitState; this.state = splitState;
initialQueries = splitState.queries; initialQueries = splitState.initialQueries;
} else { } else {
const { datasource, queries, range } = props.urlState as ExploreUrlState; const { datasource, queries, range } = props.urlState as ExploreUrlState;
initialQueries = ensureQueries(queries); initialQueries = ensureQueries(queries);
@@ -122,8 +118,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasourceName: datasource, datasourceName: datasource,
exploreDatasources: [], exploreDatasources: [],
graphRange: initialRange, graphRange: initialRange,
initialQueries,
history: [], history: [],
queries: initialQueries,
queryTransactions: [], queryTransactions: [],
range: initialRange, range: initialRange,
showingGraph: true, showingGraph: true,
@@ -135,7 +131,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
supportsTable: null, supportsTable: null,
}; };
} }
this.queryExpressions = initialQueries.map(q => q.query); this.modifiedQueries = initialQueries.slice();
} }
async componentDidMount() { async componentDidMount() {
@@ -198,32 +194,26 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
// Check if queries can be imported from previously selected datasource // Check if queries can be imported from previously selected datasource
let queryExpressions = this.queryExpressions; let modifiedQueries = this.modifiedQueries;
if (origin) { if (origin) {
if (origin.meta.id === datasource.meta.id) { if (origin.meta.id === datasource.meta.id) {
// Keep same queries if same type of datasource // Keep same queries if same type of datasource
queryExpressions = [...this.queryExpressions]; modifiedQueries = [...this.modifiedQueries];
} else if (datasource.importQueries) { } else if (datasource.importQueries) {
// Datasource-specific importers, wrapping to satisfy interface // Datasource-specific importers
const wrappedQueries: DataQuery[] = this.queryExpressions.map((query, index) => ({ modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta);
refId: String(index),
expr: query,
}));
const modifiedQueries: DataQuery[] = await datasource.importQueries(wrappedQueries, origin.meta);
queryExpressions = modifiedQueries.map(({ expr }) => expr);
} else { } else {
// Default is blank queries // Default is blank queries
queryExpressions = this.queryExpressions.map(() => ''); modifiedQueries = ensureQueries();
} }
} }
// Reset edit state with new queries // Reset edit state with new queries
const nextQueries = this.state.queries.map((q, i) => ({ const nextQueries = this.state.initialQueries.map((q, i) => ({
...q, ...modifiedQueries[i],
key: generateQueryKey(i), ...generateQueryKeys(i),
query: queryExpressions[i],
})); }));
this.queryExpressions = queryExpressions; this.modifiedQueries = modifiedQueries;
// Custom components // Custom components
const StartPage = datasource.pluginExports.ExploreStartPage; const StartPage = datasource.pluginExports.ExploreStartPage;
@@ -239,7 +229,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
supportsTable, supportsTable,
datasourceLoading: false, datasourceLoading: false,
datasourceName: datasource.name, datasourceName: datasource.name,
queries: nextQueries, initialQueries: nextQueries,
showingStartPage: Boolean(StartPage), showingStartPage: Boolean(StartPage),
}, },
() => { () => {
@@ -256,16 +246,15 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onAddQueryRow = index => { onAddQueryRow = index => {
// Local cache // Local cache
this.queryExpressions[index + 1] = ''; this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) };
this.setState(state => { this.setState(state => {
const { queries, queryTransactions } = state; const { initialQueries, queryTransactions } = state;
// Add row by generating new react key
const nextQueries = [ const nextQueries = [
...queries.slice(0, index + 1), ...initialQueries.slice(0, index + 1),
{ query: '', key: generateQueryKey() }, { ...this.modifiedQueries[index + 1] },
...queries.slice(index + 1), ...initialQueries.slice(index + 1),
]; ];
// Ongoing transactions need to update their row indices // Ongoing transactions need to update their row indices
@@ -279,7 +268,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return qt; return qt;
}); });
return { queries: nextQueries, queryTransactions: nextQueryTransactions }; return { initialQueries: nextQueries, queryTransactions: nextQueryTransactions };
}); });
}; };
@@ -296,26 +285,32 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.setDatasource(datasource as any, origin); 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 // Keep current value in local cache
this.queryExpressions[index] = value; this.modifiedQueries[index] = value;
if (override) { if (override) {
this.setState(state => { this.setState(state => {
// Replace query row // Replace query row by injecting new key
const { queries, queryTransactions } = state; const { initialQueries, queryTransactions } = state;
const nextQuery: Query = { const query: DataQuery = {
key: generateQueryKey(index), ...value,
query: value, ...generateQueryKeys(index),
}; };
const nextQueries = [...queries]; const nextQueries = [...initialQueries];
nextQueries[index] = nextQuery; nextQueries[index] = query;
this.modifiedQueries = [...nextQueries];
// Discard ongoing transaction related to row query // Discard ongoing transaction related to row query
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
return { return {
queries: nextQueries, initialQueries: nextQueries,
queryTransactions: nextQueryTransactions, queryTransactions: nextQueryTransactions,
}; };
}, this.onSubmit); }, this.onSubmit);
@@ -330,10 +325,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
onClickClear = () => { onClickClear = () => {
this.queryExpressions = ['']; this.modifiedQueries = ensureQueries();
this.setState( this.setState(
prevState => ({ prevState => ({
queries: ensureQueries(), initialQueries: [...this.modifiedQueries],
queryTransactions: [], queryTransactions: [],
showingStartPage: Boolean(prevState.StartPage), showingStartPage: Boolean(prevState.StartPage),
}), }),
@@ -387,10 +382,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}; };
// Use this in help pages to set page to a single query // Use this in help pages to set page to a single query
onClickQuery = query => { onClickExample = (query: DataQuery) => {
const nextQueries = [{ query, key: generateQueryKey() }]; const nextQueries = [{ ...query, ...generateQueryKeys() }];
this.queryExpressions = nextQueries.map(q => q.query); this.modifiedQueries = [...nextQueries];
this.setState({ queries: nextQueries }, this.onSubmit); this.setState({ initialQueries: nextQueries }, this.onSubmit);
}; };
onClickSplit = () => { onClickSplit = () => {
@@ -430,28 +425,28 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const preventSubmit = action.preventSubmit; const preventSubmit = action.preventSubmit;
this.setState( this.setState(
state => { state => {
const { queries, queryTransactions } = state; const { initialQueries, queryTransactions } = state;
let nextQueries; let nextQueries: DataQuery[];
let nextQueryTransactions; let nextQueryTransactions;
if (index === undefined) { if (index === undefined) {
// Modify all queries // Modify all queries
nextQueries = queries.map((q, i) => ({ nextQueries = initialQueries.map((query, i) => ({
key: generateQueryKey(i), ...datasource.modifyQuery(this.modifiedQueries[i], action),
query: datasource.modifyQuery(this.queryExpressions[i], action), ...generateQueryKeys(i),
})); }));
// Discard all ongoing transactions // Discard all ongoing transactions
nextQueryTransactions = []; nextQueryTransactions = [];
} else { } else {
// Modify query only at index // 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 // Synchronise all queries with local query cache to ensure consistency
q.query = this.queryExpressions[i]; // TODO still needed?
return i === index return i === index
? { ? {
key: generateQueryKey(index), ...datasource.modifyQuery(this.modifiedQueries[i], action),
query: datasource.modifyQuery(q.query, action), ...generateQueryKeys(i),
} }
: q; : query;
}); });
nextQueryTransactions = queryTransactions nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action // Consume the hint corresponding to the action
@@ -464,9 +459,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
// Preserve previous row query transaction to keep results visible if next query is incomplete // Preserve previous row query transaction to keep results visible if next query is incomplete
.filter(qt => preventSubmit || qt.rowIndex !== index); .filter(qt => preventSubmit || qt.rowIndex !== index);
} }
this.queryExpressions = nextQueries.map(q => q.query); this.modifiedQueries = [...nextQueries];
return { return {
queries: nextQueries, initialQueries: nextQueries,
queryTransactions: nextQueryTransactions, queryTransactions: nextQueryTransactions,
}; };
}, },
@@ -478,22 +473,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onRemoveQueryRow = index => { onRemoveQueryRow = index => {
// Remove from local cache // 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( this.setState(
state => { state => {
const { queries, queryTransactions } = state; const { initialQueries, queryTransactions } = state;
if (queries.length <= 1) { if (initialQueries.length <= 1) {
return null; return null;
} }
// Remove row from react state // 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 // Discard transactions related to row query
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
return { return {
queries: nextQueries, initialQueries: nextQueries,
queryTransactions: nextQueryTransactions, queryTransactions: nextQueryTransactions,
}; };
}, },
@@ -503,52 +498,68 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onSubmit = () => { onSubmit = () => {
const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state; const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
// Keep table queries first since they need to return quickly
if (showingTable && supportsTable) { if (showingTable && supportsTable) {
this.runTableQuery(); this.runQueries(
'Table',
{
format: 'table',
instant: true,
valueWithRefId: true,
},
data => data[0]
);
} }
if (showingGraph && supportsGraph) { if (showingGraph && supportsGraph) {
this.runGraphQueries(); this.runQueries(
'Graph',
{
format: 'time_series',
instant: false,
},
makeTimeSeriesList
);
} }
if (showingLogs && supportsLogs) { if (showingLogs && supportsLogs) {
this.runLogsQuery(); this.runQueries('Logs', { format: 'logs' });
} }
this.saveState(); this.saveState();
}; };
buildQueryOptions( buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) {
query: string,
rowIndex: number,
targetOptions: { format: string; hinting?: boolean; instant?: boolean }
) {
const { datasource, range } = this.state; const { datasource, range } = this.state;
const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth); const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth);
const targets = [
const configuredQueries = [
{ {
...targetOptions, ...queryOptions,
// Target identifier is needed for table transformations ...query,
refId: rowIndex + 1,
expr: query,
}, },
]; ];
// Clone range for query request // Clone range for query request
const queryRange: RawTimeRange = { ...range }; 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 { return {
interval, interval,
intervalMs, intervalMs,
targets, panelId,
targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
range: queryRange, range: queryRange,
}; };
} }
startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction { startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
const queryOptions = this.buildQueryOptions(query, rowIndex, options); const queryOptions = this.buildQueryOptions(query, options);
const transaction: QueryTransaction = { const transaction: QueryTransaction = {
query, query,
resultType, resultType,
rowIndex, rowIndex,
id: generateQueryKey(), id: generateKey(), // reusing for unique ID
done: false, done: false,
latency: 0, latency: 0,
options: queryOptions, options: queryOptions,
@@ -578,7 +589,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
transactionId: string, transactionId: string,
result: any, result: any,
latency: number, latency: number,
queries: string[], queries: DataQuery[],
datasourceId: string datasourceId: string
) { ) {
const { datasource } = this.state; const { datasource } = this.state;
@@ -597,8 +608,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} }
// Get query hints // Get query hints
let hints; let hints: QueryHint[];
if (datasource.getQueryHints) { if (datasource.getQueryHints as QueryHintGetter) {
hints = datasource.getQueryHints(transaction.query, result); hints = datasource.getQueryHints(transaction.query, result);
} }
@@ -634,7 +645,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
failQueryTransaction(transactionId: string, response: any, datasourceId: string) { failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
const { datasource } = this.state; const { datasource } = this.state;
if (datasource.meta.id !== datasourceId) { if (datasource.meta.id !== datasourceId || response.cancelled) {
// Navigated away, queries did not matter // Navigated away, queries did not matter
return; return;
} }
@@ -678,88 +689,25 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}); });
} }
async runGraphQueries() { async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) {
const queries = [...this.queryExpressions]; const queries = [...this.modifiedQueries];
if (!hasQuery(queries)) { if (!hasNonEmptyQuery(queries)) {
return; return;
} }
const { datasource } = this.state; const { datasource } = this.state;
const datasourceId = datasource.meta.id; const datasourceId = datasource.meta.id;
// Run all queries concurrently // Run all queries concurrently
queries.forEach(async (query, rowIndex) => { queries.forEach(async (query, rowIndex) => {
if (query) { const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions);
const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', { try {
format: 'time_series', const now = Date.now();
instant: false, const res = await datasource.query(transaction.options);
}); const latency = Date.now() - now;
try { const results = resultGetter ? resultGetter(res.data) : res.data;
const now = Date.now(); this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
const res = await datasource.query(transaction.options); this.setState({ graphRange: transaction.options.range });
const latency = Date.now() - now; } catch (response) {
const results = makeTimeSeriesList(res.data, transaction.options); this.failQueryTransaction(transaction.id, response, datasourceId);
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);
} }
}); });
} }
@@ -769,7 +717,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return { return {
...this.state, ...this.state,
queryTransactions: [], queryTransactions: [],
queries: ensureQueries(this.queryExpressions.map(query => ({ query }))), initialQueries: [...this.modifiedQueries],
}; };
} }
@@ -789,7 +737,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
exploreDatasources, exploreDatasources,
graphRange, graphRange,
history, history,
queries, initialQueries,
queryTransactions, queryTransactions,
range, range,
showingGraph, showingGraph,
@@ -903,7 +851,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
<QueryRows <QueryRows
datasource={datasource} datasource={datasource}
history={history} history={history}
queries={queries} initialQueries={initialQueries}
onAddQueryRow={this.onAddQueryRow} onAddQueryRow={this.onAddQueryRow}
onChangeQuery={this.onChangeQuery} onChangeQuery={this.onChangeQuery}
onClickHintFix={this.onModifyQueries} onClickHintFix={this.onModifyQueries}
@@ -913,7 +861,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/> />
<main className="m-t-2"> <main className="m-t-2">
<ErrorBoundary> <ErrorBoundary>
{showingStartPage && <StartPage onClickQuery={this.onClickQuery} />} {showingStartPage && <StartPage onClickExample={this.onClickExample} />}
{!showingStartPage && ( {!showingStartPage && (
<> <>
{supportsGraph && ( {supportsGraph && (

View File

@@ -13,6 +13,7 @@ import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2'; import TimeSeries from 'app/core/time_series2';
import Legend from './Legend'; import Legend from './Legend';
import { equal, intersect } from './utils/set';
const MAX_NUMBER_OF_TIME_SERIES = 20; const MAX_NUMBER_OF_TIME_SERIES = 20;
@@ -85,13 +86,20 @@ interface GraphProps {
} }
interface GraphState { 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<string>;
showAllTimeSeries: boolean; showAllTimeSeries: boolean;
} }
export class Graph extends PureComponent<GraphProps, GraphState> { export class Graph extends PureComponent<GraphProps, GraphState> {
$el: any; $el: any;
dynamicOptions = null;
state = { state = {
hiddenSeries: new Set(),
showAllTimeSeries: false, showAllTimeSeries: false,
}; };
@@ -107,13 +115,14 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
this.$el.bind('plotselected', this.onPlotSelected); this.$el.bind('plotselected', this.onPlotSelected);
} }
componentDidUpdate(prevProps: GraphProps) { componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
if ( if (
prevProps.data !== this.props.data || prevProps.data !== this.props.data ||
prevProps.range !== this.props.range || prevProps.range !== this.props.range ||
prevProps.split !== this.props.split || prevProps.split !== this.props.split ||
prevProps.height !== this.props.height || 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(); this.draw();
} }
@@ -133,30 +142,8 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
} }
}; };
onShowAllTimeSeries = () => { getDynamicOptions() {
this.setState( const { range, size } = this.props;
{
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'),
}));
}
const ticks = (size.width || 0) / 100; const ticks = (size.width || 0) / 100;
let { from, to } = range; let { from, to } = range;
if (!moment.isMoment(from)) { if (!moment.isMoment(from)) {
@@ -167,7 +154,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
} }
const min = from.valueOf(); const min = from.valueOf();
const max = to.valueOf(); const max = to.valueOf();
const dynamicOptions = { return {
xaxis: { xaxis: {
mode: 'time', mode: 'time',
min: min, min: min,
@@ -178,16 +165,76 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
timeformat: time_format(ticks, min, max), 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 = { const options = {
...FLOT_OPTIONS, ...FLOT_OPTIONS,
...dynamicOptions, ...this.dynamicOptions,
...userOptions, ...userOptions,
}; };
$.plot($el, series, options); $.plot($el, series, options);
} }
render() { render() {
const { height = '100px', id = 'graph' } = this.props; const { height = '100px', id = 'graph' } = this.props;
const { hiddenSeries } = this.state;
const data = this.getGraphData(); const data = this.getGraphData();
return ( return (
@@ -204,7 +251,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
</div> </div>
)} )}
<div id={id} className="explore-graph" style={{ height }} /> <div id={id} className="explore-graph" style={{ height }} />
<Legend data={data} /> <Legend data={data} hiddenSeries={hiddenSeries} onToggleSeries={this.onToggleSeries} />
</> </>
); );
} }

View File

@@ -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 {
<div className="graph-legend-series"> data: TimeSeries[];
<div className="graph-legend-icon"> hiddenSeries: Set<string>;
<i className="fa fa-minus pointer" style={{ color: series.color }} /> onToggleSeries?: (series: TimeSeries, exclusive: boolean) => void;
</div> }
<a className="graph-legend-alias pointer" title={series.alias}>
{series.alias} interface LegendItemProps {
</a> hidden: boolean;
</div> onClickLabel?: (series: TimeSeries, event: MouseEvent) => void;
); series: TimeSeries;
}
class LegendItem extends PureComponent<LegendItemProps> {
onClickLabel = e => this.props.onClickLabel(this.props.series, e);
export default class Legend extends PureComponent<any, any> {
render() { render() {
const { className = '', data } = this.props; const { hidden, series } = this.props;
const items = data || []; const seriesClasses = classNames({
'graph-legend-series-hidden': hidden,
});
return ( return (
<div className={`${className} graph-legend ps`}> <div className={`graph-legend-series ${seriesClasses}`}>
{items.map(series => <LegendItem key={series.id} series={series} />)} <div className="graph-legend-icon">
<i className="fa fa-minus pointer" style={{ color: series.color }} />
</div>
<a className="graph-legend-alias pointer" title={series.alias} onClick={this.onClickLabel}>
{series.alias}
</a>
</div>
);
}
}
export default class Legend extends PureComponent<LegendProps> {
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 (
<div className="graph-legend ps">
{items.map((series, i) => (
<LegendItem
hidden={hiddenSeries.has(series.alias)}
// Workaround to resolve conflicts since series visibility tracks the alias property
key={`${series.id}-${i}`}
onClickLabel={this.onClickLabel}
series={series}
/>
))}
</div> </div>
); );
} }

View File

@@ -27,14 +27,14 @@ function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
return suggestions && suggestions.length > 0; return suggestions && suggestions.length > 0;
} }
interface QueryFieldProps { export interface QueryFieldProps {
additionalPlugins?: any[]; additionalPlugins?: any[];
cleanText?: (text: string) => string; cleanText?: (text: string) => string;
initialValue: string | null; initialQuery: string | null;
onBlur?: () => void; onBlur?: () => void;
onFocus?: () => void; onFocus?: () => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput; onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
onValueChanged?: (value: Value) => void; onValueChanged?: (value: string) => void;
onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string; onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
placeholder?: string; placeholder?: string;
portalOrigin?: string; portalOrigin?: string;
@@ -60,16 +60,22 @@ export interface TypeaheadInput {
wrapperNode: Element; 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<QueryFieldProps, QueryFieldState> { export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
menuEl: HTMLElement | null; menuEl: HTMLElement | null;
placeholdersBuffer: PlaceholdersBuffer; placeholdersBuffer: PlaceholdersBuffer;
plugins: any[]; plugins: any[];
resetTimer: any; resetTimer: any;
constructor(props, context) { constructor(props: QueryFieldProps, context) {
super(props, context); super(props, context);
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || ''); this.placeholdersBuffer = new PlaceholdersBuffer(props.initialQuery || '');
// Base plugins // Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p); this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p);
@@ -92,7 +98,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
clearTimeout(this.resetTimer); clearTimeout(this.resetTimer);
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
// Only update menu location when suggestion existence or text/selection changed // Only update menu location when suggestion existence or text/selection changed
if ( if (
this.state.value !== prevState.value || this.state.value !== prevState.value ||

View File

@@ -1,10 +1,10 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore'; import { QueryTransaction, HistoryItem, QueryHint } from 'app/types/explore';
import DefaultQueryField from './QueryField'; import DefaultQueryField from './QueryField';
import QueryTransactionStatus from './QueryTransactionStatus'; import QueryTransactionStatus from './QueryTransactionStatus';
import { DataSource } from 'app/types'; import { DataSource, DataQuery } from 'app/types';
function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
@@ -16,7 +16,7 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHi
interface QueryRowEventHandlers { interface QueryRowEventHandlers {
onAddQueryRow: (index: number) => void; 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; onClickHintFix: (action: object, index?: number) => void;
onExecuteQuery: () => void; onExecuteQuery: () => void;
onRemoveQueryRow: (index: number) => void; onRemoveQueryRow: (index: number) => void;
@@ -32,11 +32,11 @@ interface QueryRowCommonProps {
type QueryRowProps = QueryRowCommonProps & type QueryRowProps = QueryRowCommonProps &
QueryRowEventHandlers & { QueryRowEventHandlers & {
index: number; index: number;
query: string; initialQuery: DataQuery;
}; };
class QueryRow extends PureComponent<QueryRowProps> { class QueryRow extends PureComponent<QueryRowProps> {
onChangeQuery = (value, override?: boolean) => { onChangeQuery = (value: DataQuery, override?: boolean) => {
const { index, onChangeQuery } = this.props; const { index, onChangeQuery } = this.props;
if (onChangeQuery) { if (onChangeQuery) {
onChangeQuery(value, index, override); onChangeQuery(value, index, override);
@@ -51,7 +51,7 @@ class QueryRow extends PureComponent<QueryRowProps> {
}; };
onClickClearButton = () => { onClickClearButton = () => {
this.onChangeQuery('', true); this.onChangeQuery(null, true);
}; };
onClickHintFix = action => { onClickHintFix = action => {
@@ -76,7 +76,7 @@ class QueryRow extends PureComponent<QueryRowProps> {
}; };
render() { render() {
const { datasource, history, query, transactions } = this.props; const { datasource, history, initialQuery, transactions } = this.props;
const transactionWithError = transactions.find(t => t.error !== undefined); const transactionWithError = transactions.find(t => t.error !== undefined);
const hint = getFirstHintFromTransactions(transactions); const hint = getFirstHintFromTransactions(transactions);
const queryError = transactionWithError ? transactionWithError.error : null; const queryError = transactionWithError ? transactionWithError.error : null;
@@ -91,7 +91,7 @@ class QueryRow extends PureComponent<QueryRowProps> {
datasource={datasource} datasource={datasource}
error={queryError} error={queryError}
hint={hint} hint={hint}
initialQuery={query} initialQuery={initialQuery}
history={history} history={history}
onClickHintFix={this.onClickHintFix} onClickHintFix={this.onClickHintFix}
onPressEnter={this.onPressEnter} onPressEnter={this.onPressEnter}
@@ -116,19 +116,19 @@ class QueryRow extends PureComponent<QueryRowProps> {
type QueryRowsProps = QueryRowCommonProps & type QueryRowsProps = QueryRowCommonProps &
QueryRowEventHandlers & { QueryRowEventHandlers & {
queries: Query[]; initialQueries: DataQuery[];
}; };
export default class QueryRows extends PureComponent<QueryRowsProps> { export default class QueryRows extends PureComponent<QueryRowsProps> {
render() { render() {
const { className = '', queries, transactions, ...handlers } = this.props; const { className = '', initialQueries, transactions, ...handlers } = this.props;
return ( return (
<div className={className}> <div className={className}>
{queries.map((q, index) => ( {initialQueries.map((query, index) => (
<QueryRow <QueryRow
key={q.key} key={query.key}
index={index} index={index}
query={q.query} initialQuery={query}
transactions={transactions.filter(t => t.rowIndex === index)} transactions={transactions.filter(t => t.rowIndex === index)}
{...handlers} {...handlers}
/> />

View File

@@ -35,7 +35,9 @@ export default class QueryTransactionStatus extends PureComponent<QueryTransacti
const { transactions } = this.props; const { transactions } = this.props;
return ( return (
<div className="query-transactions"> <div className="query-transactions">
{transactions.map((t, i) => <QueryTransactionStatusItem key={`${t.query}:${t.resultType}`} transaction={t} />)} {transactions.map((t, i) => (
<QueryTransactionStatusItem key={`${t.rowIndex}:${t.resultType}`} transaction={t} />
))}
</div> </div>
); );
} }

View File

@@ -453,6 +453,8 @@ exports[`Render should render component 1`] = `
}, },
] ]
} }
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/> />
</Fragment> </Fragment>
`; `;
@@ -947,6 +949,8 @@ exports[`Render should render component with disclaimer 1`] = `
}, },
] ]
} }
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/> />
</Fragment> </Fragment>
`; `;
@@ -964,6 +968,8 @@ exports[`Render should show query return no time series 1`] = `
/> />
<Legend <Legend
data={Array []} data={Array []}
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/> />
</Fragment> </Fragment>
`; `;

View File

@@ -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));
}

View File

@@ -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]));
});
});

View File

@@ -0,0 +1,35 @@
/**
* Performs a shallow comparison of two sets with the same item type.
*/
export function equal<T>(a: Set<T>, b: Set<T>): 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<T>(a: Set<T>, b: Set<T>): Set<T> {
const result = new Set<T>();
const it = b.values();
while (true) {
const { value, done } = it.next();
if (done) {
return result;
}
if (a.has(value)) {
result.add(value);
}
}
}

View File

@@ -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(<Component {...props} />, elem[0]);
scope.$on('$destroy', () => {
ReactDOM.unmountComponentAtNode(elem[0]);
});
},
};
}
coreModule.directive('variableQueryEditorLoader', variableQueryEditorLoader);

View File

@@ -3,3 +3,4 @@ import './import_list/import_list';
import './ds_edit_ctrl'; import './ds_edit_ctrl';
import './datasource_srv'; import './datasource_srv';
import './plugin_component'; import './plugin_component';
import './VariableQueryComponentLoader';

View File

@@ -0,0 +1,34 @@
import React, { PureComponent } from 'react';
import { VariableQueryProps } from 'app/types/plugins';
export default class DefaultVariableQueryEditor extends PureComponent<VariableQueryProps, any> {
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 (
<div className="gf-form">
<span className="gf-form-label width-10">Query</span>
<input
type="text"
className="gf-form-input"
value={this.state.value}
onChange={e => this.handleChange(e)}
onBlur={e => this.handleBlur(e)}
placeholder="metric name or tags query"
required
/>
</div>
);
}
}

View File

@@ -72,6 +72,7 @@ export class VariableEditorCtrl {
if ( if (
$scope.current.type === 'query' && $scope.current.type === 'query' &&
_.isString($scope.current.query) &&
$scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)')) $scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)'))
) { ) {
appEvents.emit('alert-warning', [ 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.edit = variable => {
$scope.current = variable; $scope.current = variable;
$scope.currentIsNew = false; $scope.currentIsNew = false;
$scope.mode = 'edit'; $scope.mode = 'edit';
$scope.validate(); $scope.validate();
datasourceSrv.get($scope.current.datasource).then(ds => {
$scope.currentDatasource = ds;
});
}; };
$scope.duplicate = variable => { $scope.duplicate = variable => {
@@ -171,6 +181,13 @@ export class VariableEditorCtrl {
$scope.showMoreOptions = () => { $scope.showMoreOptions = () => {
$scope.optionsLimit += 20; $scope.optionsLimit += 20;
}; };
$scope.datasourceChanged = async () => {
datasourceSrv.get($scope.current.datasource).then(ds => {
$scope.current.query = '';
$scope.currentDatasource = ds;
});
};
} }
} }

View File

@@ -17,14 +17,16 @@
</a> </a>
<div class="grafana-info-box"> <div class="grafana-info-box">
<h5>What do variables do?</h5> <h5>What do variables do?</h5>
<p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names <p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor
in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of names
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard. 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 Check out the
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank"> <a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
Templating documentation Templating documentation
</a> for more information. </a> for more information.
</div> </div>
</div> </div>
</div> </div>
@@ -32,7 +34,7 @@
<div ng-if="variables.length"> <div ng-if="variables.length">
<div class="page-action-bar"> <div class="page-action-bar">
<div class="page-action-bar__spacer"></div> <div class="page-action-bar__spacer"></div>
<a type="button" class="btn btn-success" ng-click="setMode('new');"><i class="fa fa-plus" ></i> New</a> <a type="button" class="btn btn-success" ng-click="setMode('new');"><i class="fa fa-plus"></i> New</a>
</div> </div>
<table class="filter-table filter-table--hover"> <table class="filter-table filter-table--hover">
@@ -51,7 +53,7 @@
</span> </span>
</td> </td>
<td style="max-width: 200px;" ng-click="edit(variable)" class="pointer max-width"> <td style="max-width: 200px;" ng-click="edit(variable)" class="pointer max-width">
{{variable.query}} {{variable.definition ? variable.definition : variable.query}}
</td> </td>
<td style="width: 1%"><i ng-click="_.move(variables,$index,$index-1)" ng-hide="$first" class="pointer fa fa-arrow-up"></i></td> <td style="width: 1%"><i ng-click="_.move(variables,$index,$index-1)" ng-hide="$first" class="pointer fa fa-arrow-up"></i></td>
<td style="width: 1%"><i ng-click="_.move(variables,$index,$index+1)" ng-hide="$last" class="pointer fa fa-arrow-down"></i></td> <td style="width: 1%"><i ng-click="_.move(variables,$index,$index+1)" ng-hide="$last" class="pointer fa fa-arrow-down"></i></td>
@@ -77,7 +79,8 @@
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form max-width-19"> <div class="gf-form max-width-19">
<span class="gf-form-label width-6">Name</span> <span class="gf-form-label width-6">Name</span>
<input type="text" class="gf-form-input" name="name" placeholder="name" ng-model='current.name' required ng-pattern="namePattern"></input> <input type="text" class="gf-form-input" name="name" placeholder="name" ng-model='current.name' required
ng-pattern="namePattern"></input>
</div> </div>
<div class="gf-form max-width-19"> <div class="gf-form max-width-19">
<span class="gf-form-label width-6"> <span class="gf-form-label width-6">
@@ -87,13 +90,15 @@
</info-popover> </info-popover>
</span> </span>
<div class="gf-form-select-wrapper max-width-17"> <div class="gf-form-select-wrapper max-width-17">
<select class="gf-form-input" ng-model="current.type" ng-options="k as v.name for (k, v) in variableTypes" ng-change="typeChanged()"></select> <select class="gf-form-input" ng-model="current.type" ng-options="k as v.name for (k, v) in variableTypes"
ng-change="typeChanged()"></select>
</div> </div>
</div> </div>
</div> </div>
<div class="gf-form" ng-show="ctrl.form.name.$error.pattern"> <div class="gf-form" ng-show="ctrl.form.name.$error.pattern">
<span class="gf-form-label gf-form-label--error">Template names cannot begin with '__', that's reserved for Grafana's global variables</span> <span class="gf-form-label gf-form-label--error">Template names cannot begin with '__', that's reserved for
Grafana's global variables</span>
</div> </div>
<div class="gf-form-inline"> <div class="gf-form-inline">
@@ -115,7 +120,8 @@
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-9">Values</span> <span class="gf-form-label width-9">Values</span>
<input type="text" class="gf-form-input" ng-model='current.query' placeholder="1m,10m,1h,6h,1d,7d" ng-model-onblur ng-change="runQuery()" required></input> <input type="text" class="gf-form-input" ng-model='current.query' placeholder="1m,10m,1h,6h,1d,7d" ng-model-onblur
ng-change="runQuery()" required></input>
</div> </div>
<div class="gf-form-inline"> <div class="gf-form-inline">
@@ -127,14 +133,16 @@
Step count <tip>How many times should the current time range be divided to calculate the value</tip> Step count <tip>How many times should the current time range be divided to calculate the value</tip>
</span> </span>
<div class="gf-form-select-wrapper max-width-10" ng-show="current.auto"> <div class="gf-form-select-wrapper max-width-10" ng-show="current.auto">
<select class="gf-form-input" ng-model="current.auto_count" ng-options="f for f in [1,2,3,4,5,10,20,30,40,50,100,200,300,400,500]" ng-change="runQuery()"></select> <select class="gf-form-input" ng-model="current.auto_count" ng-options="f for f in [1,2,3,4,5,10,20,30,40,50,100,200,300,400,500]"
ng-change="runQuery()"></select>
</div> </div>
</div> </div>
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label" ng-show="current.auto"> <span class="gf-form-label" ng-show="current.auto">
Min interval <tip>The calculated value will not go below this threshold</tip> Min interval <tip>The calculated value will not go below this threshold</tip>
</span> </span>
<input type="text" class="gf-form-input max-width-10" ng-show="current.auto" ng-model="current.auto_min" ng-change="runQuery()" placeholder="10s"></input> <input type="text" class="gf-form-input max-width-10" ng-show="current.auto" ng-model="current.auto_min" ng-change="runQuery()"
placeholder="10s"></input>
</div> </div>
</div> </div>
</div> </div>
@@ -143,7 +151,8 @@
<h5 class="section-heading">Custom Options</h5> <h5 class="section-heading">Custom Options</h5>
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-14">Values separated by comma</span> <span class="gf-form-label width-14">Values separated by comma</span>
<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue" required></input> <input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue"
required></input>
</div> </div>
</div> </div>
@@ -168,15 +177,17 @@
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form max-width-21"> <div class="gf-form max-width-21">
<span class="gf-form-label width-7">Data source</span> <span class="gf-form-label width-10">Data source</span>
<div class="gf-form-select-wrapper max-width-14"> <div class="gf-form-select-wrapper max-width-14">
<select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources" required> <select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources"
ng-change="datasourceChanged()" required>
<option value="" ng-if="false"></option> <option value="" ng-if="false"></option>
</select> </select>
</div> </div>
</div> </div>
<div class="gf-form max-width-22"> <div class="gf-form max-width-22">
<span class="gf-form-label width-7"> <span class="gf-form-label width-10">
Refresh Refresh
<info-popover mode="right-normal"> <info-popover mode="right-normal">
When to update the values of this variable. When to update the values of this variable.
@@ -187,28 +198,32 @@
</div> </div>
</div> </div>
</div> </div>
<rebuild-on-change property="currentDatasource">
<variable-query-editor-loader>
</variable-query-editor-loader>
</rebuild-on-change>
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-7">Query</span> <span class="gf-form-label width-10">
<input type="text" class="gf-form-input" ng-model='current.query' placeholder="metric name or tags query" ng-model-onblur ng-change="runQuery()" required></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-7">
Regex Regex
<info-popover mode="right-normal"> <info-popover mode="right-normal">
Optional, if you want to extract part of a series name or metric node segment. Optional, if you want to extract part of a series name or metric node segment.
</info-popover> </info-popover>
</span> </span>
<input type="text" class="gf-form-input" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur ng-change="runQuery()"></input> <input type="text" class="gf-form-input" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur
ng-change="runQuery()"></input>
</div> </div>
<div class="gf-form max-width-21"> <div class="gf-form max-width-21">
<span class="gf-form-label width-7"> <span class="gf-form-label width-10">
Sort Sort
<info-popover mode="right-normal"> <info-popover mode="right-normal">
How to sort the values of this variable. How to sort the values of this variable.
</info-popover> </info-popover>
</span> </span>
<div class="gf-form-select-wrapper max-width-14"> <div class="gf-form-select-wrapper max-width-14">
<select class="gf-form-input" ng-model="current.sort" ng-options="f.value as f.text for f in sortOptions" ng-change="runQuery()"></select> <select class="gf-form-input" ng-model="current.sort" ng-options="f.value as f.text for f in sortOptions"
ng-change="runQuery()"></select>
</div> </div>
</div> </div>
</div> </div>
@@ -219,7 +234,8 @@
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-12">Type</label> <label class="gf-form-label width-12">Type</label>
<div class="gf-form-select-wrapper max-width-18"> <div class="gf-form-select-wrapper max-width-18">
<select class="gf-form-input" ng-model="current.query" ng-options="f.value as f.text for f in datasourceTypes" ng-change="runQuery()"></select> <select class="gf-form-input" ng-model="current.query" ng-options="f.value as f.text for f in datasourceTypes"
ng-change="runQuery()"></select>
</div> </div>
</div> </div>
@@ -234,7 +250,8 @@
</info-popover> </info-popover>
</label> </label>
<input type="text" class="gf-form-input max-width-18" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur ng-change="runQuery()"></input> <input type="text" class="gf-form-input max-width-18" ng-model='current.regex' placeholder="/.*-(.*)-.*/"
ng-model-onblur ng-change="runQuery()"></input>
</div> </div>
</div> </div>
@@ -243,7 +260,8 @@
<div class="gf-form max-width-21"> <div class="gf-form max-width-21">
<span class="gf-form-label width-8">Data source</span> <span class="gf-form-label width-8">Data source</span>
<div class="gf-form-select-wrapper max-width-14"> <div class="gf-form-select-wrapper max-width-14">
<select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources" required ng-change="validate()"> <select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources"
required ng-change="validate()">
<option value="" ng-if="false"></option> <option value="" ng-if="false"></option>
</select> </select>
</div> </div>
@@ -253,18 +271,11 @@
<div class="section gf-form-group" ng-show="variableTypes[current.type].supportsMulti"> <div class="section gf-form-group" ng-show="variableTypes[current.type].supportsMulti">
<h5 class="section-heading">Selection Options</h5> <h5 class="section-heading">Selection Options</h5>
<div class="section"> <div class="section">
<gf-form-switch class="gf-form" <gf-form-switch class="gf-form" label="Multi-value" label-class="width-10" tooltip="Enables multiple values to be selected at the same time"
label="Multi-value" checked="current.multi" on-change="runQuery()">
label-class="width-10"
tooltip="Enables multiple values to be selected at the same time"
checked="current.multi"
on-change="runQuery()">
</gf-form-switch> </gf-form-switch>
<gf-form-switch class="gf-form" <gf-form-switch class="gf-form" label="Include All option" label-class="width-10" checked="current.includeAll"
label="Include All option" on-change="runQuery()">
label-class="width-10"
checked="current.includeAll"
on-change="runQuery()">
</gf-form-switch> </gf-form-switch>
</div> </div>
<div class="gf-form" ng-if="current.includeAll"> <div class="gf-form" ng-if="current.includeAll">
@@ -279,11 +290,13 @@
</gf-form-switch> </gf-form-switch>
<div class="gf-form last" ng-if="current.useTags"> <div class="gf-form last" ng-if="current.useTags">
<span class="gf-form-label width-10">Tags query</span> <span class="gf-form-label width-10">Tags query</span>
<input type="text" class="gf-form-input" ng-model='current.tagsQuery' placeholder="metric name or tags query" ng-model-onblur></input> <input type="text" class="gf-form-input" ng-model='current.tagsQuery' placeholder="metric name or tags query"
ng-model-onblur></input>
</div> </div>
<div class="gf-form" ng-if="current.useTags"> <div class="gf-form" ng-if="current.useTags">
<li class="gf-form-label width-10">Tag values query</li> <li class="gf-form-label width-10">Tag values query</li>
<input type="text" class="gf-form-input" ng-model='current.tagValuesQuery' placeholder="apps.$tag.*" ng-model-onblur></input> <input type="text" class="gf-form-input" ng-model='current.tagValuesQuery' placeholder="apps.$tag.*"
ng-model-onblur></input>
</div> </div>
</div> </div>
@@ -291,11 +304,11 @@
<h5>Preview of values</h5> <h5>Preview of values</h5>
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form" ng-repeat="option in current.options | limitTo: optionsLimit"> <div class="gf-form" ng-repeat="option in current.options | limitTo: optionsLimit">
<span class="gf-form-label">{{option.text}}</span> <span class="gf-form-label">{{option.text}}</span>
</div> </div>
<div class="gf-form" ng-if= "current.options.length > optionsLimit"> <div class="gf-form" ng-if="current.options.length > optionsLimit">
<a class="gf-form-label btn-secondary" ng-click="showMoreOptions()">Show more</a> <a class="gf-form-label btn-secondary" ng-click="showMoreOptions()">Show more</a>
</div> </div>
</div> </div>
</div> </div>
@@ -309,5 +322,4 @@
</div> </div>
</form> </form>
</div> </div>

View File

@@ -23,6 +23,7 @@ export class QueryVariable implements Variable {
tagValuesQuery: string; tagValuesQuery: string;
tags: any[]; tags: any[];
skipUrlSync: boolean; skipUrlSync: boolean;
definition: string;
defaults = { defaults = {
type: 'query', type: 'query',
@@ -44,6 +45,7 @@ export class QueryVariable implements Variable {
tagsQuery: '', tagsQuery: '',
tagValuesQuery: '', tagValuesQuery: '',
skipUrlSync: false, skipUrlSync: false,
definition: '',
}; };
/** @ngInject */ /** @ngInject */

View File

@@ -1,3 +1,4 @@
import _ from 'lodash';
import { assignModelProperties } from 'app/core/utils/model_utils'; import { assignModelProperties } from 'app/core/utils/model_utils';
/* /*
@@ -28,6 +29,7 @@ export { assignModelProperties };
export function containsVariable(...args: any[]) { export function containsVariable(...args: any[]) {
const variableName = args[args.length - 1]; 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 variableString = args.slice(0, -1).join(' ');
const matches = variableString.match(variableRegex); const matches = variableString.match(variableRegex);
const isMatchingVariable = const isMatchingVariable =

View File

@@ -19,7 +19,10 @@ export default (props: any) => (
{CHEAT_SHEET_ITEMS.map(item => ( {CHEAT_SHEET_ITEMS.map(item => (
<div className="cheat-sheet-item" key={item.expression}> <div className="cheat-sheet-item" key={item.expression}>
<div className="cheat-sheet-item__title">{item.title}</div> <div className="cheat-sheet-item__title">{item.title}</div>
<div className="cheat-sheet-item__expression" onClick={e => props.onClickQuery(item.expression)}> <div
className="cheat-sheet-item__expression"
onClick={e => props.onClickExample({ refId: '1', expr: item.expression })}
>
<code>{item.expression}</code> <code>{item.expression}</code>
</div> </div>
<div className="cheat-sheet-item__label">{item.label}</div> <div className="cheat-sheet-item__label">{item.label}</div>

View File

@@ -10,7 +10,8 @@ import { TypeaheadOutput } from 'app/types/explore';
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
import BracesPlugin from 'app/features/explore/slate-plugins/braces'; import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; 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'; const PRISM_SYNTAX = 'promql';
@@ -53,10 +54,10 @@ interface LoggingQueryFieldProps {
error?: string | JSX.Element; error?: string | JSX.Element;
hint?: any; hint?: any;
history?: any[]; history?: any[];
initialQuery?: string | null; initialQuery?: DataQuery;
onClickHintFix?: (action: any) => void; onClickHintFix?: (action: any) => void;
onPressEnter?: () => void; onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void; onQueryChange?: (value: DataQuery, override?: boolean) => void;
} }
interface LoggingQueryFieldState { interface LoggingQueryFieldState {
@@ -134,9 +135,13 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
onChangeQuery = (value: string, override?: boolean) => { onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent // Send text change to parent
const { onQueryChange } = this.props; const { initialQuery, onQueryChange } = this.props;
if (onQueryChange) { if (onQueryChange) {
onQueryChange(value, override); const query = {
...initialQuery,
expr: value,
};
onQueryChange(query, override);
} }
}; };
@@ -196,15 +201,15 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
</Cascader> </Cascader>
</div> </div>
<div className="prom-query-field-wrapper"> <div className="prom-query-field-wrapper">
<TypeaheadField <QueryField
additionalPlugins={this.plugins} additionalPlugins={this.plugins}
cleanText={cleanText} cleanText={cleanText}
initialValue={initialQuery} initialQuery={initialQuery.expr}
onTypeahead={this.onTypeahead} onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion} onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery} onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query" placeholder="Enter a Logging query"
portalOrigin="prometheus" portalOrigin="logging"
syntaxLoaded={syntaxLoaded} syntaxLoaded={syntaxLoaded}
/> />
{error ? <div className="prom-query-field-info text-error">{error}</div> : null} {error ? <div className="prom-query-field-info text-error">{error}</div> : null}

View File

@@ -52,7 +52,7 @@ export default class LoggingStartPage extends PureComponent<any, { active: strin
</div> </div>
</div> </div>
<div className="page-container page-body"> <div className="page-container page-body">
{active === 'start' && <LoggingCheatSheet onClickQuery={this.props.onClickQuery} />} {active === 'start' && <LoggingCheatSheet onClickExample={this.props.onClickExample} />}
</div> </div>
</div> </div>
); );

View File

@@ -7,12 +7,37 @@ describe('Language completion provider', () => {
metadataRequest: () => ({ data: { data: [] } }), metadataRequest: () => ({ data: { data: [] } }),
}; };
it('returns default suggestions on emtpty context', () => { describe('empty query suggestions', () => {
const instance = new LanguageProvider(datasource); it('returns default suggestions on emtpty context', () => {
const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] }); const instance = new LanguageProvider(datasource);
expect(result.context).toBeUndefined(); const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
expect(result.refresher).toBeUndefined(); expect(result.context).toBeUndefined();
expect(result.suggestions.length).toEqual(0); 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', () => { describe('label suggestions', () => {

View File

@@ -7,6 +7,7 @@ import {
LanguageProvider, LanguageProvider,
TypeaheadInput, TypeaheadInput,
TypeaheadOutput, TypeaheadOutput,
HistoryItem,
} from 'app/types/explore'; } from 'app/types/explore';
import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasource/prometheus/language_utils'; import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasource/prometheus/language_utils';
import PromqlSyntax from 'app/plugins/datasource/prometheus/promql'; 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 }); 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 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 count = historyForItem.length;
const recent = historyForItem[0]; const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`; let hint = `Queried ${count} times in the last 24h.`;
@@ -96,9 +97,9 @@ export default class LoggingLanguageProvider extends LanguageProvider {
if (history && history.length > 0) { if (history && history.length > 0) {
const historyItems = _.chain(history) const historyItems = _.chain(history)
.uniqBy('query') .uniqBy('query.expr')
.take(HISTORY_ITEM_COUNT) .take(HISTORY_ITEM_COUNT)
.map(h => h.query) .map(h => h.query.expr)
.map(wrapLabel) .map(wrapLabel)
.map(item => addHistoryMetadata(item, history)) .map(item => addHistoryMetadata(item, history))
.value(); .value();
@@ -177,6 +178,10 @@ export default class LoggingLanguageProvider extends LanguageProvider {
} }
async importPrometheusQuery(query: string): Promise<string> { async importPrometheusQuery(query: string): Promise<string> {
if (!query) {
return '';
}
// Consider only first selector in query // Consider only first selector in query
const selectorMatch = query.match(selectorRegexp); const selectorMatch = query.match(selectorRegexp);
if (selectorMatch) { if (selectorMatch) {
@@ -192,7 +197,7 @@ export default class LoggingLanguageProvider extends LanguageProvider {
const commonLabels = {}; const commonLabels = {};
for (const key in labels) { for (const key in labels) {
const existingKeys = this.labelKeys[EMPTY_SELECTOR]; 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? // Should we check for label value equality here?
commonLabels[key] = labels[key]; commonLabels[key] = labels[key];
} }

View File

@@ -25,7 +25,10 @@ export default (props: any) => (
{CHEAT_SHEET_ITEMS.map(item => ( {CHEAT_SHEET_ITEMS.map(item => (
<div className="cheat-sheet-item" key={item.expression}> <div className="cheat-sheet-item" key={item.expression}>
<div className="cheat-sheet-item__title">{item.title}</div> <div className="cheat-sheet-item__title">{item.title}</div>
<div className="cheat-sheet-item__expression" onClick={e => props.onClickQuery(item.expression)}> <div
className="cheat-sheet-item__expression"
onClick={e => props.onClickExample({ refId: '1', expr: item.expression })}
>
<code>{item.expression}</code> <code>{item.expression}</code>
</div> </div>
<div className="cheat-sheet-item__label">{item.label}</div> <div className="cheat-sheet-item__label">{item.label}</div>

View File

@@ -10,7 +10,8 @@ import { TypeaheadOutput } from 'app/types/explore';
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
import BracesPlugin from 'app/features/explore/slate-plugins/braces'; import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; 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 HISTOGRAM_GROUP = '__histograms__';
const METRIC_MARK = 'metric'; const METRIC_MARK = 'metric';
@@ -87,13 +88,13 @@ interface CascaderOption {
interface PromQueryFieldProps { interface PromQueryFieldProps {
datasource: any; datasource: any;
error?: string | JSX.Element; error?: string | JSX.Element;
initialQuery: DataQuery;
hint?: any; hint?: any;
history?: any[]; history?: any[];
initialQuery?: string | null;
metricsByPrefix?: CascaderOption[]; metricsByPrefix?: CascaderOption[];
onClickHintFix?: (action: any) => void; onClickHintFix?: (action: any) => void;
onPressEnter?: () => void; onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void; onQueryChange?: (value: DataQuery, override?: boolean) => void;
} }
interface PromQueryFieldState { interface PromQueryFieldState {
@@ -163,9 +164,13 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
onChangeQuery = (value: string, override?: boolean) => { onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent // Send text change to parent
const { onQueryChange } = this.props; const { initialQuery, onQueryChange } = this.props;
if (onQueryChange) { if (onQueryChange) {
onQueryChange(value, override); const query: DataQuery = {
...initialQuery,
expr: value,
};
onQueryChange(query, override);
} }
}; };
@@ -230,7 +235,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const { error, hint, initialQuery } = this.props; const { error, hint, initialQuery } = this.props;
const { metricsOptions, syntaxLoaded } = this.state; const { metricsOptions, syntaxLoaded } = this.state;
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined; const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
const chooserText = syntaxLoaded ? 'Metrics' : 'Loading matrics...'; const chooserText = syntaxLoaded ? 'Metrics' : 'Loading metrics...';
return ( return (
<div className="prom-query-field"> <div className="prom-query-field">
@@ -242,10 +247,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
</Cascader> </Cascader>
</div> </div>
<div className="prom-query-field-wrapper"> <div className="prom-query-field-wrapper">
<TypeaheadField <QueryField
additionalPlugins={this.plugins} additionalPlugins={this.plugins}
cleanText={cleanText} cleanText={cleanText}
initialValue={initialQuery} initialQuery={initialQuery.expr}
onTypeahead={this.onTypeahead} onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion} onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery} onValueChanged={this.onChangeQuery}

View File

@@ -52,7 +52,7 @@ export default class PromStart extends PureComponent<any, { active: string }> {
</div> </div>
</div> </div>
<div className="page-container page-body"> <div className="page-container page-body">
{active === 'start' && <PromCheatSheet onClickQuery={this.props.onClickQuery} />} {active === 'start' && <PromCheatSheet onClickExample={this.props.onClickExample} />}
</div> </div>
</div> </div>
); );

View File

@@ -11,6 +11,8 @@ import { BackendSrv } from 'app/core/services/backend_srv';
import addLabelToQuery from './add_label_to_query'; import addLabelToQuery from './add_label_to_query';
import { getQueryHints } from './query_hints'; import { getQueryHints } from './query_hints';
import { expandRecordingRules } from './language_utils'; import { expandRecordingRules } from './language_utils';
import { DataQuery } from 'app/types';
import { ExploreUrlState } from 'app/types/explore';
export function alignRange(start, end, step) { export function alignRange(start, end, step) {
const alignedEnd = Math.ceil(end / step) * step; const alignedEnd = Math.ceil(end / step) * step;
@@ -419,24 +421,23 @@ export class PrometheusDatasource {
}); });
} }
getExploreState(targets: any[]) { getExploreState(queries: DataQuery[]): Partial<ExploreUrlState> {
let state = {}; let state: Partial<ExploreUrlState> = { datasource: this.name };
if (targets && targets.length > 0) { if (queries && queries.length > 0) {
const queries = targets.map(t => ({ const expandedQueries = queries.map(query => ({
query: this.templateSrv.replace(t.expr, {}, this.interpolateQueryExpr), ...query,
format: t.format, expr: this.templateSrv.replace(query.expr, {}, this.interpolateQueryExpr),
})); }));
state = { state = {
...state, ...state,
queries, queries: expandedQueries,
datasource: this.name,
}; };
} }
return state; return state;
} }
getQueryHints(query: string, result: any[]) { getQueryHints(query: DataQuery, result: any[]) {
return getQueryHints(query, result, this); return getQueryHints(query.expr, result, this);
} }
loadRules() { 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) { switch (action.type) {
case 'ADD_FILTER': { case 'ADD_FILTER': {
return addLabelToQuery(query, action.key, action.value); expression = addLabelToQuery(expression, action.key, action.value);
break;
} }
case 'ADD_HISTOGRAM_QUANTILE': { 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': { case 'ADD_RATE': {
return `rate(${query}[5m])`; expression = `rate(${expression}[5m])`;
break;
} }
case 'ADD_SUM': { case 'ADD_SUM': {
return `sum(${query.trim()}) by ($1)`; expression = `sum(${expression.trim()}) by ($1)`;
break;
} }
case 'EXPAND_RULES': { case 'EXPAND_RULES': {
if (action.mapping) { if (action.mapping) {
return expandRecordingRules(query, action.mapping); expression = expandRecordingRules(expression, action.mapping);
} }
break;
} }
default: default:
return query; break;
} }
return { ...query, expr: expression };
} }
getPrometheusTime(date, roundUp) { getPrometheusTime(date, roundUp) {

View File

@@ -125,9 +125,9 @@ export default class PromQlLanguageProvider extends LanguageProvider {
if (history && history.length > 0) { if (history && history.length > 0) {
const historyItems = _.chain(history) const historyItems = _.chain(history)
.uniqBy('query') .uniqBy('query.expr')
.take(HISTORY_ITEM_COUNT) .take(HISTORY_ITEM_COUNT)
.map(h => h.query) .map(h => h.query.expr)
.map(wrapLabel) .map(wrapLabel)
.map(item => addHistoryMetadata(item, history)) .map(item => addHistoryMetadata(item, history))
.value(); .value();

View File

@@ -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', () => { describe('range suggestions', () => {

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,28 @@
import React, { SFC } from 'react';
interface Props {
onValueChange: (e) => void;
options: any[];
value: string;
label: string;
}
const SimpleSelect: SFC<Props> = props => {
const { label, onValueChange, value, options } = props;
return (
<div className="gf-form max-width-21">
<span className="gf-form-label width-10 query-keyword">{label}</span>
<div className="gf-form-select-wrapper max-width-12">
<select className="gf-form-input" required onChange={onValueChange} value={value}>
{options.map(({ value, name }, i) => (
<option key={i} value={value}>
{name}
</option>
))}
</select>
</div>
</div>
);
};
export default SimpleSelect;

View File

@@ -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(<StackdriverVariableQueryEditor {...props} />).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(<StackdriverVariableQueryEditor {...props} />).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(<StackdriverVariableQueryEditor {...props} />).toJSON();
});
});
});

View File

@@ -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<VariableQueryProps, VariableQueryData> {
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 (
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
onValueChange={e => this.onServiceChange(e)}
label="Service"
/>
);
case MetricFindQueryTypes.LabelKeys:
case MetricFindQueryTypes.LabelValues:
case MetricFindQueryTypes.ResourceTypes:
return (
<React.Fragment>
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
onValueChange={e => this.onServiceChange(e)}
label="Service"
/>
<SimpleSelect
value={this.state.selectedMetricType}
options={this.insertTemplateVariables(this.state.metricTypes)}
onValueChange={e => this.onMetricTypeChange(e)}
label="Metric Type"
/>
{queryType === MetricFindQueryTypes.LabelValues && (
<SimpleSelect
value={this.state.labelKey}
options={this.insertTemplateVariables(this.state.labels.map(l => ({ value: l, name: l })))}
onValueChange={e => this.onLabelKeyChange(e)}
label="Label Key"
/>
)}
</React.Fragment>
);
case MetricFindQueryTypes.Aligners:
case MetricFindQueryTypes.Aggregations:
return (
<React.Fragment>
<SimpleSelect
value={this.state.selectedService}
options={this.insertTemplateVariables(this.state.services)}
onValueChange={e => this.onServiceChange(e)}
label="Service"
/>
<SimpleSelect
value={this.state.selectedMetricType}
options={this.insertTemplateVariables(this.state.metricTypes)}
onValueChange={e => this.onMetricTypeChange(e)}
label="Metric Type"
/>
</React.Fragment>
);
default:
return '';
}
}
render() {
return (
<React.Fragment>
<SimpleSelect
value={this.state.selectedQueryType}
options={this.queryTypes}
onValueChange={e => this.handleQueryTypeChange(e)}
label="Query Type"
/>
{this.renderQueryTypeSwitch(this.state.selectedQueryType)}
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VariableQueryEditor renders correctly 1`] = `
Array [
<div
className="gf-form max-width-21"
>
<span
className="gf-form-label width-10 query-keyword"
>
Query Type
</span>
<div
className="gf-form-select-wrapper max-width-12"
>
<select
className="gf-form-input"
onChange={[Function]}
required={true}
value="services"
>
<option
value="services"
>
Services
</option>
<option
value="metricTypes"
>
Metric Types
</option>
<option
value="labelKeys"
>
Label Keys
</option>
<option
value="labelValues"
>
Label Values
</option>
<option
value="resourceTypes"
>
Resource Types
</option>
<option
value="aggregations"
>
Aggregations
</option>
<option
value="aligners"
>
Aligners
</option>
<option
value="alignmentPeriods"
>
Alignment Periods
</option>
</select>
</div>
</div>,
"",
]
`;

View File

@@ -1,6 +1,7 @@
import { stackdriverUnitMappings } from './constants'; import { stackdriverUnitMappings } from './constants';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import _ from 'lodash'; import _ from 'lodash';
import StackdriverMetricFindQuery from './StackdriverMetricFindQuery';
export default class StackdriverDatasource { export default class StackdriverDatasource {
id: number; id: number;
@@ -9,6 +10,7 @@ export default class StackdriverDatasource {
projectName: string; projectName: string;
authenticationType: string; authenticationType: string;
queryPromise: Promise<any>; queryPromise: Promise<any>;
metricTypes: any[];
/** @ngInject */ /** @ngInject */
constructor(instanceSettings, private backendSrv, private templateSrv, private timeSrv) { constructor(instanceSettings, private backendSrv, private templateSrv, private timeSrv) {
@@ -18,6 +20,7 @@ export default class StackdriverDatasource {
this.id = instanceSettings.id; this.id = instanceSettings.id;
this.projectName = instanceSettings.jsonData.defaultProject || ''; this.projectName = instanceSettings.jsonData.defaultProject || '';
this.authenticationType = instanceSettings.jsonData.authenticationType || 'jwt'; this.authenticationType = instanceSettings.jsonData.authenticationType || 'jwt';
this.metricTypes = [];
} }
async getTimeSeries(options) { async getTimeSeries(options) {
@@ -67,7 +70,7 @@ export default class StackdriverDatasource {
} }
async getLabels(metricType, refId) { async getLabels(metricType, refId) {
return await this.getTimeSeries({ const response = await this.getTimeSeries({
targets: [ targets: [
{ {
refId: refId, refId: refId,
@@ -81,6 +84,8 @@ export default class StackdriverDatasource {
], ],
range: this.timeSrv.timeRange(), range: this.timeSrv.timeRange(),
}); });
return response.results[refId];
} }
interpolateGroupBys(groupBys: string[], scopedVars): string[] { interpolateGroupBys(groupBys: string[], scopedVars): string[] {
@@ -177,8 +182,9 @@ export default class StackdriverDatasource {
return results; return results;
} }
metricFindQuery(query) { async metricFindQuery(query) {
throw new Error('Template variables support is not yet imlemented'); const stackdriverMetricFindQuery = new StackdriverMetricFindQuery(this);
return stackdriverMetricFindQuery.execute(query);
} }
async testDatasource() { async testDatasource() {
@@ -258,19 +264,21 @@ export default class StackdriverDatasource {
async getMetricTypes(projectName: string) { async getMetricTypes(projectName: string) {
try { try {
const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`; if (this.metricTypes.length === 0) {
const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`); const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`;
const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`);
const metrics = data.metricDescriptors.map(m => { this.metricTypes = data.metricDescriptors.map(m => {
const [service] = m.type.split('/'); const [service] = m.type.split('/');
const [serviceShortName] = service.split('.'); const [serviceShortName] = service.split('.');
m.service = service; m.service = service;
m.serviceShortName = serviceShortName; m.serviceShortName = serviceShortName;
m.displayName = m.displayName || m.type; m.displayName = m.displayName || m.type;
return m; return m;
}); });
}
return metrics; return this.metricTypes;
} catch (error) { } catch (error) {
appEvents.emit('ds-request-error', this.formatStackdriverError(error)); appEvents.emit('ds-request-error', this.formatStackdriverError(error));
return []; return [];

View File

@@ -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;
};

View File

@@ -2,10 +2,12 @@ import StackdriverDatasource from './datasource';
import { StackdriverQueryCtrl } from './query_ctrl'; import { StackdriverQueryCtrl } from './query_ctrl';
import { StackdriverConfigCtrl } from './config_ctrl'; import { StackdriverConfigCtrl } from './config_ctrl';
import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl'; import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl';
import { StackdriverVariableQueryEditor } from './components/VariableQueryEditor';
export { export {
StackdriverDatasource as Datasource, StackdriverDatasource as Datasource,
StackdriverQueryCtrl as QueryCtrl, StackdriverQueryCtrl as QueryCtrl,
StackdriverConfigCtrl as ConfigCtrl, StackdriverConfigCtrl as ConfigCtrl,
StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl, StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl,
StackdriverVariableQueryEditor as VariableQueryEditor,
}; };

View File

@@ -2,8 +2,8 @@
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label query-keyword width-9">Aggregation</label> <label class="gf-form-label query-keyword width-9">Aggregation</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent"> <div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select class="gf-form-input width-12" ng-model="ctrl.target.aggregation.crossSeriesReducer" ng-options="f.value as f.text for f in ctrl.aggOptions" <gf-form-dropdown model="ctrl.target.aggregation.crossSeriesReducer" get-options="ctrl.aggOptions" class="gf-form width-12"
ng-change="refresh()"></select> disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div> </div>
</div> </div>
<div class="gf-form gf-form--grow"> <div class="gf-form gf-form--grow">
@@ -20,8 +20,8 @@
<div class="gf-form offset-width-9"> <div class="gf-form offset-width-9">
<label class="gf-form-label query-keyword width-12">Aligner</label> <label class="gf-form-label query-keyword width-12">Aligner</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent"> <div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select class="gf-form-input width-14" ng-model="ctrl.target.aggregation.perSeriesAligner" ng-options="f.value as f.text for f in ctrl.alignOptions" <gf-form-dropdown model="ctrl.target.aggregation.perSeriesAligner" get-options="ctrl.alignOptions" class="gf-form width-12"
ng-change="refresh()"></select> disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div> </div>
<div class="gf-form gf-form--grow"> <div class="gf-form gf-form--grow">
@@ -33,8 +33,8 @@
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label query-keyword width-9">Alignment Period</label> <label class="gf-form-label query-keyword width-9">Alignment Period</label>
<div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent"> <div class="gf-form-select-wrapper gf-form-select-wrapper--caret-indent">
<select class="gf-form-input width-12" ng-model="ctrl.target.aggregation.alignmentPeriod" ng-options="f.value as f.text for f in ctrl.alignmentPeriods" <gf-form-dropdown model="ctrl.target.aggregation.alignmentPeriod" get-options="ctrl.alignmentPeriods" class="gf-form width-12"
ng-change="refresh()"></select> disabled type="text" allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="refresh()"></gf-form-dropdown>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,7 @@
</div> </div>
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-9">Project</span> <span class="gf-form-label width-9 query-keyword">Project</span>
<input class="gf-form-input" disabled type="text" ng-model='ctrl.target.defaultProject' css-class="min-width-12" /> <input class="gf-form-input" disabled type="text" ng-model='ctrl.target.defaultProject' css-class="min-width-12" />
</div> </div>
<div class="gf-form"> <div class="gf-form">
@@ -70,4 +70,4 @@
<div class="gf-form" ng-show="ctrl.lastQueryError"> <div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre> <pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div> </div>
</query-editor-row> </query-editor-row>

View File

@@ -1,37 +1,52 @@
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-9">Service</span> <span class="gf-form-label width-9 query-keyword">Service</span>
<gf-form-dropdown model="ctrl.service" get-options="ctrl.services" class="min-width-20" disabled type="text" <select
allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onServiceChange(ctrl.service)"></gf-form-dropdown> class="gf-form-input width-12"
ng-model="ctrl.service"
ng-options="f.value as f.text for f in ctrl.services"
ng-change="ctrl.onServiceChange(ctrl.service)"
></select>
</div> </div>
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label width-9">Metric</span> <span class="gf-form-label width-9 query-keyword">Metric</span>
<gf-form-dropdown model="ctrl.metricType" get-options="ctrl.metrics" class="min-width-20" disabled type="text" <gf-form-dropdown
allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onMetricTypeChange()"></gf-form-dropdown> model="ctrl.metricType"
</div> get-options="ctrl.metrics"
<div class="gf-form gf-form--grow"> class="min-width-20"
<div class="gf-form-label gf-form-label--grow"></div> disabled
type="text"
allow-custom="true"
lookup-text="true"
css-class="min-width-12"
on-change="ctrl.onMetricTypeChange()"
></gf-form-dropdown>
</div> </div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div> </div>
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label query-keyword width-9">Filter</span> <span class="gf-form-label query-keyword width-9">Filter</span>
<div class="gf-form" ng-repeat="segment in ctrl.filterSegments.filterSegments"> <div class="gf-form" ng-repeat="segment in ctrl.filterSegments.filterSegments">
<metric-segment segment="segment" get-options="ctrl.getFilters(segment, $index)" on-change="ctrl.filterSegmentUpdated(segment, $index)"></metric-segment> <metric-segment
segment="segment"
get-options="ctrl.getFilters(segment, $index)"
on-change="ctrl.filterSegmentUpdated(segment, $index)"
></metric-segment>
</div> </div>
</div> </div>
<div class="gf-form gf-form--grow"> <div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div> </div>
<div class="gf-form-inline" ng-hide="ctrl.$scope.hideGroupBys"> <div class="gf-form-inline" ng-hide="ctrl.$scope.hideGroupBys">
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label query-keyword width-9">Group By</span> <span class="gf-form-label query-keyword width-9">Group By</span>
<div class="gf-form" ng-repeat="segment in ctrl.groupBySegments"> <div class="gf-form" ng-repeat="segment in ctrl.groupBySegments">
<metric-segment segment="segment" get-options="ctrl.getGroupBys(segment)" on-change="ctrl.groupByChanged(segment, $index)"></metric-segment> <metric-segment
segment="segment"
get-options="ctrl.getGroupBys(segment)"
on-change="ctrl.groupByChanged(segment, $index)"
></metric-segment>
</div> </div>
</div> </div>
<div class="gf-form gf-form--grow"> <div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import _ from 'lodash'; import _ from 'lodash';
import * as options from './constants'; import * as options from './constants';
import { getAlignmentOptionsByMetric, getAggregationOptionsByMetric } from './functions';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
export class StackdriverAggregation { export class StackdriverAggregation {
@@ -25,7 +26,7 @@ export class StackdriverAggregationCtrl {
target: any; target: any;
/** @ngInject */ /** @ngInject */
constructor(private $scope) { constructor(private $scope, private templateSrv) {
this.$scope.ctrl = this; this.$scope.ctrl = this;
this.target = $scope.target; this.target = $scope.target;
this.alignmentPeriods = options.alignmentPeriods; this.alignmentPeriods = options.alignmentPeriods;
@@ -41,28 +42,16 @@ export class StackdriverAggregationCtrl {
} }
setAlignOptions() { setAlignOptions() {
this.alignOptions = !this.target.valueType this.alignOptions = getAlignmentOptionsByMetric(this.target.valueType, this.target.metricKind);
? [] if (!this.alignOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner))) {
: options.alignOptions.filter(i => {
return (
i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
);
});
if (!this.alignOptions.find(o => o.value === this.target.aggregation.perSeriesAligner)) {
this.target.aggregation.perSeriesAligner = this.alignOptions.length > 0 ? this.alignOptions[0].value : ''; this.target.aggregation.perSeriesAligner = this.alignOptions.length > 0 ? this.alignOptions[0].value : '';
} }
} }
setAggOptions() { setAggOptions() {
this.aggOptions = !this.target.metricKind this.aggOptions = getAggregationOptionsByMetric(this.target.valueType, this.target.metricKind);
? []
: options.aggOptions.filter(i => {
return (
i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
);
});
if (!this.aggOptions.find(o => o.value === this.target.aggregation.crossSeriesReducer)) { if (!this.aggOptions.find(o => o.value === this.templateSrv.replace(this.target.aggregation.crossSeriesReducer))) {
this.deselectAggregationOption('REDUCE_NONE'); this.deselectAggregationOption('REDUCE_NONE');
} }
@@ -73,8 +62,12 @@ export class StackdriverAggregationCtrl {
} }
formatAlignmentText() { formatAlignmentText() {
const selectedAlignment = this.alignOptions.find(ap => ap.value === this.target.aggregation.perSeriesAligner); const selectedAlignment = this.alignOptions.find(
return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${selectedAlignment.text})`; ap => ap.value === this.templateSrv.replace(this.target.aggregation.perSeriesAligner)
);
return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${
selectedAlignment ? selectedAlignment.text : ''
})`;
} }
deselectAggregationOption(notValidOptionValue: string) { deselectAggregationOption(notValidOptionValue: string) {

View File

@@ -62,7 +62,6 @@ export class StackdriverQueryCtrl extends QueryCtrl {
constructor($scope, $injector) { constructor($scope, $injector) {
super($scope, $injector); super($scope, $injector);
_.defaultsDeep(this.target, this.defaults); _.defaultsDeep(this.target, this.defaults);
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope); this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope); this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
} }

View File

@@ -139,7 +139,7 @@ export class StackdriverFilterCtrl {
result = metrics.filter(m => m.service === this.target.service); result = metrics.filter(m => m.service === this.target.service);
} }
if (result.find(m => m.value === this.target.metricType)) { if (result.find(m => m.value === this.templateSrv.replace(this.target.metricType))) {
this.metricType = this.target.metricType; this.metricType = this.target.metricType;
} else if (result.length > 0) { } else if (result.length > 0) {
this.metricType = this.target.metricType = result[0].value; this.metricType = this.target.metricType = result[0].value;
@@ -150,10 +150,10 @@ export class StackdriverFilterCtrl {
async getLabels() { async getLabels() {
this.loadLabelsPromise = new Promise(async resolve => { this.loadLabelsPromise = new Promise(async resolve => {
try { try {
const data = await this.datasource.getLabels(this.target.metricType, this.target.refId); const { meta } = await this.datasource.getLabels(this.target.metricType, this.target.refId);
this.metricLabels = data.results[this.target.refId].meta.metricLabels; this.metricLabels = meta.metricLabels;
this.resourceLabels = data.results[this.target.refId].meta.resourceLabels; this.resourceLabels = meta.resourceLabels;
this.resourceTypes = data.results[this.target.refId].meta.resourceTypes; this.resourceTypes = meta.resourceTypes;
resolve(); resolve();
} catch (error) { } catch (error) {
if (error.data && error.data.message) { if (error.data && error.data.message) {
@@ -187,7 +187,9 @@ export class StackdriverFilterCtrl {
setMetricType() { setMetricType() {
this.target.metricType = this.metricType; this.target.metricType = this.metricType;
const { valueType, metricKind, unit } = this.metricDescriptors.find(m => m.type === this.target.metricType); const { valueType, metricKind, unit } = this.metricDescriptors.find(
m => m.type === this.templateSrv.replace(this.metricType)
);
this.target.unit = unit; this.target.unit = unit;
this.target.valueType = valueType; this.target.valueType = valueType;
this.target.metricKind = metricKind; this.target.metricKind = metricKind;

View File

@@ -6,10 +6,19 @@ describe('StackdriverAggregationCtrl', () => {
describe('when new query result is returned from the server', () => { describe('when new query result is returned from the server', () => {
describe('and result is double and gauge and no group by is used', () => { describe('and result is double and gauge and no group by is used', () => {
beforeEach(async () => { beforeEach(async () => {
ctrl = new StackdriverAggregationCtrl({ ctrl = new StackdriverAggregationCtrl(
$on: () => {}, {
target: { valueType: 'DOUBLE', metricKind: 'GAUGE', aggregation: { crossSeriesReducer: '', groupBys: [] } }, $on: () => {},
}); target: {
valueType: 'DOUBLE',
metricKind: 'GAUGE',
aggregation: { crossSeriesReducer: '', groupBys: [] },
},
},
{
replace: s => s,
}
);
}); });
it('should populate all aggregate options except two', () => { it('should populate all aggregate options except two', () => {
@@ -31,14 +40,19 @@ describe('StackdriverAggregationCtrl', () => {
describe('and result is double and gauge and a group by is used', () => { describe('and result is double and gauge and a group by is used', () => {
beforeEach(async () => { beforeEach(async () => {
ctrl = new StackdriverAggregationCtrl({ ctrl = new StackdriverAggregationCtrl(
$on: () => {}, {
target: { $on: () => {},
valueType: 'DOUBLE', target: {
metricKind: 'GAUGE', valueType: 'DOUBLE',
aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] }, metricKind: 'GAUGE',
aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] },
},
}, },
}); {
replace: s => s,
}
);
}); });
it('should populate all aggregate options except three', () => { it('should populate all aggregate options except three', () => {

View File

@@ -0,0 +1,21 @@
export enum MetricFindQueryTypes {
Services = 'services',
MetricTypes = 'metricTypes',
LabelKeys = 'labelKeys',
LabelValues = 'labelValues',
ResourceTypes = 'resourceTypes',
Aggregations = 'aggregations',
Aligners = 'aligners',
AlignmentPeriods = 'alignmentPeriods',
}
export interface VariableQueryData {
selectedQueryType: string;
metricDescriptors: any[];
selectedService: string;
selectedMetricType: string;
labels: string[];
labelKey: string;
metricTypes: Array<{ value: string; name: string }>;
services: Array<{ value: string; name: string }>;
}

View File

@@ -58,15 +58,7 @@ class GraphElement {
// panel events // panel events
this.ctrl.events.on('panel-teardown', this.onPanelTeardown.bind(this)); this.ctrl.events.on('panel-teardown', this.onPanelTeardown.bind(this));
/**
* Split graph rendering into two parts.
* First, calculate series stats in buildFlotPairs() function. Then legend rendering started
* (see ctrl.events.on('render') in legend.ts).
* When legend is rendered it emits 'legend-rendering-complete' and graph rendered.
*/
this.ctrl.events.on('render', this.onRender.bind(this)); this.ctrl.events.on('render', this.onRender.bind(this));
this.ctrl.events.on('legend-rendering-complete', this.onLegendRenderingComplete.bind(this));
// global events // global events
appEvents.on('graph-hover', this.onGraphHover.bind(this), scope); appEvents.on('graph-hover', this.onGraphHover.bind(this), scope);
@@ -85,11 +77,20 @@ class GraphElement {
if (!this.data) { if (!this.data) {
return; return;
} }
this.annotations = this.ctrl.annotations || []; this.annotations = this.ctrl.annotations || [];
this.buildFlotPairs(this.data); this.buildFlotPairs(this.data);
const graphHeight = this.elem.height(); const graphHeight = this.elem.height();
updateLegendValues(this.data, this.panel, graphHeight); updateLegendValues(this.data, this.panel, graphHeight);
if (!this.panel.legend.show) {
if (this.legendElem.hasChildNodes()) {
ReactDOM.unmountComponentAtNode(this.legendElem);
}
this.renderPanel();
return;
}
const { values, min, max, avg, current, total } = this.panel.legend; const { values, min, max, avg, current, total } = this.panel.legend;
const { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero } = this.panel.legend; const { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero } = this.panel.legend;
const legendOptions = { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero }; const legendOptions = { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero };
@@ -104,12 +105,9 @@ class GraphElement {
onColorChange: this.ctrl.onColorChange, onColorChange: this.ctrl.onColorChange,
onToggleAxis: this.ctrl.onToggleAxis, onToggleAxis: this.ctrl.onToggleAxis,
}; };
const legendReactElem = React.createElement(Legend, legendProps);
ReactDOM.render(legendReactElem, this.legendElem, () => this.onLegendRenderingComplete());
}
onLegendRenderingComplete() { const legendReactElem = React.createElement(Legend, legendProps);
this.render_panel(); ReactDOM.render(legendReactElem, this.legendElem, () => this.renderPanel());
} }
onGraphHover(evt) { onGraphHover(evt) {
@@ -281,7 +279,7 @@ class GraphElement {
} }
// Function for rendering panel // Function for rendering panel
render_panel() { renderPanel() {
this.panelWidth = this.elem.width(); this.panelWidth = this.elem.width();
if (this.shouldAbortRender()) { if (this.shouldAbortRender()) {
return; return;

View File

@@ -125,7 +125,7 @@ describe('grafanaGraph', () => {
//Emulate functions called by event listeners //Emulate functions called by event listeners
link.buildFlotPairs(link.data); link.buildFlotPairs(link.data);
link.render_panel(); link.renderPanel();
ctx.plotData = ctrl.plot.mock.calls[0][1]; ctx.plotData = ctrl.plot.mock.calls[0][1];
ctx.plotOptions = ctrl.plot.mock.calls[0][2]; ctx.plotOptions = ctrl.plot.mock.calls[0][2];

View File

@@ -1,6 +1,6 @@
import { Value } from 'slate'; import { Value } from 'slate';
import { RawTimeRange } from './series'; import { DataQuery, RawTimeRange } from './series';
export interface CompletionItem { export interface CompletionItem {
/** /**
@@ -79,7 +79,7 @@ interface ExploreDatasource {
export interface HistoryItem { export interface HistoryItem {
ts: number; ts: number;
query: string; query: DataQuery;
} }
export abstract class LanguageProvider { export abstract class LanguageProvider {
@@ -107,11 +107,6 @@ export interface TypeaheadOutput {
suggestions: CompletionItemGroup[]; suggestions: CompletionItemGroup[];
} }
export interface Query {
query: string;
key?: string;
}
export interface QueryFix { export interface QueryFix {
type: string; type: string;
label: string; label: string;
@@ -130,6 +125,10 @@ export interface QueryHint {
fix?: QueryFix; fix?: QueryFix;
} }
export interface QueryHintGetter {
(query: DataQuery, results: any[], ...rest: any): QueryHint[];
}
export interface QueryTransaction { export interface QueryTransaction {
id: string; id: string;
done: boolean; done: boolean;
@@ -137,7 +136,7 @@ export interface QueryTransaction {
hints?: QueryHint[]; hints?: QueryHint[];
latency: number; latency: number;
options: any; options: any;
query: string; query: DataQuery;
result?: any; // Table model / Timeseries[] / Logs result?: any; // Table model / Timeseries[] / Logs
resultType: ResultType; resultType: ResultType;
rowIndex: number; rowIndex: number;
@@ -160,15 +159,7 @@ export interface ExploreState {
exploreDatasources: ExploreDatasource[]; exploreDatasources: ExploreDatasource[];
graphRange: RawTimeRange; graphRange: RawTimeRange;
history: HistoryItem[]; history: HistoryItem[];
/** initialQueries: DataQuery[];
* Initial rows of queries to push down the tree.
* Modifications do not end up here, but in `this.queryExpressions`.
* The only way to reset a query is to change its `key`.
*/
queries: Query[];
/**
* Hints gathered for the query row.
*/
queryTransactions: QueryTransaction[]; queryTransactions: QueryTransaction[];
range: RawTimeRange; range: RawTimeRange;
showingGraph: boolean; showingGraph: boolean;
@@ -182,7 +173,7 @@ export interface ExploreState {
export interface ExploreUrlState { export interface ExploreUrlState {
datasource: string; datasource: string;
queries: Query[]; queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
range: RawTimeRange; range: RawTimeRange;
} }

View File

@@ -6,6 +6,7 @@ export interface PluginExports {
QueryCtrl?: any; QueryCtrl?: any;
ConfigCtrl?: any; ConfigCtrl?: any;
AnnotationsQueryCtrl?: any; AnnotationsQueryCtrl?: any;
VariableQueryEditor?: any;
ExploreQueryField?: any; ExploreQueryField?: any;
ExploreStartPage?: any; ExploreStartPage?: any;
@@ -98,3 +99,10 @@ export interface PluginsState {
hasFetched: boolean; hasFetched: boolean;
dashboards: PluginDashboard[]; dashboards: PluginDashboard[];
} }
export interface VariableQueryProps {
query: any;
onChange: (query: any, definition: string) => void;
datasource: any;
templateSrv: any;
}

View File

@@ -4,10 +4,10 @@
EXTRA_OPTS="$@" EXTRA_OPTS="$@"
# Right now we hack this in into the publish script. # Right now we hack this in into the publish script.
# Eventually we might want to keep a list of all previous releases somewhere. # Eventually we might want to keep a list of all previous releases somewhere.
_releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-3-x/10244" _releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-4-x/12215"
_whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-3/" _whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-4/"
./scripts/build/release_publisher/release_publisher \ ./scripts/build/release_publisher/release_publisher \
--wn ${_whatsNewUrl} \ --wn ${_whatsNewUrl} \

View File

@@ -85,7 +85,7 @@ module.exports = merge(common, {
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/error.html'), filename: path.resolve(__dirname, '../../public/views/error.html'),
template: path.resolve(__dirname, '../../public/views/error-template.html'), template: path.resolve(__dirname, '../../public/views/error-template.html'),
inject: 'false', inject: false,
}), }),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/index.html'), filename: path.resolve(__dirname, '../../public/views/index.html'),

View File

@@ -74,17 +74,17 @@ module.exports = merge(common, {
filename: "grafana.[name].[hash].css" filename: "grafana.[name].[hash].css"
}), }),
new ngAnnotatePlugin(), new ngAnnotatePlugin(),
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/error.html'),
template: path.resolve(__dirname, '../../public/views/error-template.html'),
inject: false,
}),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/index.html'), filename: path.resolve(__dirname, '../../public/views/index.html'),
template: path.resolve(__dirname, '../../public/views/index-template.html'), template: path.resolve(__dirname, '../../public/views/index-template.html'),
inject: 'body', inject: 'body',
chunks: ['vendor', 'app'], chunks: ['vendor', 'app'],
}), }),
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, '../../public/views/error.html'),
template: path.resolve(__dirname, '../../public/views/error-template.html'),
inject: false,
}),
function () { function () {
this.hooks.done.tap('Done', function (stats) { this.hooks.done.tap('Done', function (stats) {
if (stats.compilation.errors && stats.compilation.errors.length) { if (stats.compilation.errors && stats.compilation.errors.length) {