Merge branch 'develop' into panel-edit-in-react

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

View File

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

View File

@ -1,5 +1,9 @@
# 5.4.0 (unreleased)
* **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150)
# 5.4.0-beta1 (2018-11-20)
### New Features
* **Alerting**: Introduce alert debouncing with the `FOR` setting. [#7886](https://github.com/grafana/grafana/issues/7886) & [#6202](https://github.com/grafana/grafana/issues/6202)
@ -12,12 +16,14 @@
* **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550)
* **Graph**: Time regions support enabling highlight of weekdays and/or certain timespans [#5930](https://github.com/grafana/grafana/issues/5930)
* **OAuth**: Automatic redirect to sign-in with OAuth [#11893](https://github.com/grafana/grafana/issues/11893), thx [@Nick-Triller](https://github.com/Nick-Triller)
* **Stackdriver**: Template query editor [#13561](https://github.com/grafana/grafana/issues/13561)
### Minor
* **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043)
* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
* **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy)
* **Cloudwatch**: CloudHSM metrics and dimensions [#14129](https://github.com/grafana/grafana/pull/14129), thx [@daktari](https://github.com/daktari)
* **Cloudwatch**: Enable using variables in the stats field [#13810](https://github.com/grafana/grafana/issues/13810), thx [@mtanda](https://github.com/mtanda)
* **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm)
* **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367)
@ -37,10 +43,12 @@
* **Dashboard**: Fix render dashboard row drag handle only in edit mode [#13555](https://github.com/grafana/grafana/issues/13555), thx [@praveensastry](https://github.com/praveensastry)
* **Teams**: Fix cannot select team if not included in initial search [#13425](https://github.com/grafana/grafana/issues/13425)
* **Render**: Support full height screenshots using phantomjs render script [#13352](https://github.com/grafana/grafana/pull/13352), thx [@amuraru](https://github.com/amuraru)
* **HTTP API**: Support retrieving teams by user [#14120](https://github.com/grafana/grafana/pull/14120), thx [@supercharlesliu](https://github.com/supercharlesliu)
* **Metrics**: Add basic authentication to metrics endpoint [#13577](https://github.com/grafana/grafana/issues/13577), thx [@bobmshannon](https://github.com/bobmshannon)
### Breaking changes
* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited).
# 5.3.4 (2018-11-13)

View File

@ -490,6 +490,10 @@ enabled = false
enabled = true
interval_seconds = 10
#If both are set, basic auth will be required for the metrics endpoint.
basic_auth_username =
basic_auth_password =
# Send internal Grafana metrics to graphite
[metrics.graphite]
# Enable by setting the address setting (ex localhost:2003)

View File

@ -104,6 +104,7 @@
}
],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Always OK",
"tooltip": {
@ -232,6 +233,7 @@
}
],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Always Alerting",
"tooltip": {
@ -362,6 +364,7 @@
}
],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "No data",
"tooltip": {
@ -432,7 +435,7 @@
"for": "1m",
"frequency": "1m",
"handler": 1,
"name": "TestData - Always Alerting with For",
"name": "TestData - Always Pending",
"noDataState": "no_data",
"notifications": []
},
@ -492,6 +495,138 @@
}
],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Always Alerting with For",
"tooltip": {
"msResolution": false,
"shared": true,
"sort": 0,
"value_type": "cumulative"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": "",
"logBase": 1,
"max": null,
"min": "0",
"show": true
},
{
"format": "short",
"label": "",
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
100
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"executionErrorState": "alerting",
"for": "900000h",
"frequency": "1m",
"handler": 1,
"name": "Always Pending",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "gdev-testdata",
"editable": true,
"error": false,
"fill": 1,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 14
},
"id": 7,
"isNew": true,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 2,
"links": [],
"nullPointMode": "connected",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"refId": "A",
"scenario": "random_walk",
"scenarioId": "csv_metric_values",
"stringInput": "200,445,100,150,200,220,190",
"target": ""
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 100
}
],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Always Alerting with For",
"tooltip": {
@ -573,5 +708,5 @@
"timezone": "browser",
"title": "Alerting with TestData",
"uid": "7MeksYbmk",
"version": 1
"version": 7
}

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.
Typically, it's always a good idea to use this setting since its often worse to get false positive than wait a few minutes before the alert notification triggers.
Typically, it's always a good idea to use this setting since it's often worse to get false positive than wait a few minutes before the alert notification triggers. Looking at the `Alert list` or `Alert list panels` you will be able to see alerts in pending state.
Below you can see an example timeline of an alert using the `For` setting. At ~16:04 the alert state changes to `Pending` and after 4 minutes it changes to `Alerting` which is when alert notifications are sent. Once the series falls back to normal the alert rule goes back to `OK`.
{{< imgbox img="/img/docs/v54/alerting-for-dark-theme.png" caption="Alerting For" >}}
{{< imgbox max-width="40%" img="/img/docs/v4/alerting_conditions.png" caption="Alerting Conditions" >}}
@ -71,7 +74,7 @@ avg() OF query(A, 15m, now) IS BELOW 14
```
- `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function.
- `query(A, 15m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `15m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data.
- `query(A, 15m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `15m, now` means 15 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data.
- `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold.
The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.

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.
| Alias Pattern Format | Description | Example Result |
| ------------------------ | ------------------------------------------------| ---------------- |
| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` |
| Alias Pattern Format | Description | Example Result |
| -------------------- | ----------------------------------------------- | -------------- |
| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` |
Example Alias By: `{{resource.type}} - {{metric.type}}`
@ -177,7 +177,17 @@ types of template variables.
### Query Variable
Writing variable queries is not supported yet.
Variable of the type *Query* allows you to query Stackdriver for various types of data. The Stackdriver data source plugin provides the following `Query Types`.
| Name | Description |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| *Metric Types* | Returns a list of metric type names that are available for the specified service. |
| *Labels Keys* | Returns a list of keys for `metric label` and `resource label` in the specified metric. |
| *Labels Values* | Returns a list of values for the label in the specified metric. |
| *Resource Types* | Returns a list of resource types for the the specified metric. |
| *Aggregations* | Returns a list of aggregations (cross series reducers) for the the specified metric. |
| *Aligners* | Returns a list of aligners (per series aligners) for the the specified metric. |
| *Alignment periods* | Returns a list of all alignment periods that are available in Stackdriver query editor in Grafana |
### Using variables in queries

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
]
```
## Get Teams for user
`GET /api/users/:id/teams`
**Example Request**:
```http
GET /api/users/1/teams HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Basic YWRtaW46YWRtaW4=
```
Requires basic authentication and that the authenticated user is a Grafana Admin.
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
[
{
"id":1,
"orgId":1,
"name":"team1",
"email":"",
"avatarUrl":"/avatar/3fcfe295eae3bcb67a49349377428a66",
"memberCount":1
}
]
```
## User
## Actual User

View File

@ -454,6 +454,12 @@ Ex `filters = sqlstore:debug`
### enabled
Enable metrics reporting. defaults true. Available via HTTP API `/metrics`.
### basic_auth_username
If set configures the username to use for basic authentication on the metrics endpoint.
### basic_auth_password
If set configures the password to use for basic authentication on the metrics endpoint.
### interval_seconds
Flush/Write interval when sending metrics to external TSDB. Defaults to 10s.

View File

@ -1,9 +1,17 @@
#!/bin/sh
set -e
_grafana_tag=$1
_raw_grafana_tag=$1
_docker_repo=${2:-grafana/grafana-enterprise}
if echo "$_raw_grafana_tag" | grep -q "^v"; then
_grafana_tag=$(echo "${_raw_grafana_tag}" | cut -d "v" -f 2)
else
_grafana_tag="${_raw_grafana_tag}"
fi
echo "Building and deploying ${_docker_repo}:${_grafana_tag}"
docker build \
--tag "${_docker_repo}:${_grafana_tag}"\
--no-cache=true \

View File

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

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
}
if hs.metricsEndpointBasicAuthEnabled() && !BasicAuthenticatedRequest(ctx.Req, hs.Cfg.MetricsEndpointBasicAuthUsername, hs.Cfg.MetricsEndpointBasicAuthPassword) {
ctx.Resp.WriteHeader(http.StatusUnauthorized)
return
}
promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{}).
ServeHTTP(ctx.Resp, ctx.Req.Request)
}
@ -299,3 +304,7 @@ func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string,
},
))
}
func (hs *HTTPServer) metricsEndpointBasicAuthEnabled() bool {
return hs.Cfg.MetricsEndpointBasicAuthUsername != "" && hs.Cfg.MetricsEndpointBasicAuthPassword != ""
}

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

View File

@ -54,7 +54,10 @@ func main() {
if *profile {
runtime.SetBlockProfileRate(1)
go func() {
http.ListenAndServe(fmt.Sprintf("localhost:%d", *profilePort), nil)
err := http.ListenAndServe(fmt.Sprintf("localhost:%d", *profilePort), nil)
if err != nil {
panic(err)
}
}()
f, err := os.Create("trace.out")

View File

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

View File

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

View File

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

View File

@ -126,6 +126,18 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
}
eg.Go(func() error {
defer func() {
if err := recover(); err != nil {
plog.Error("Execute Query Panic", "error", err, "stack", log.Stack(1))
if theErr, ok := err.(error); ok {
resultChan <- &tsdb.QueryResult{
RefId: query.RefId,
Error: theErr,
}
}
}
}()
queryRes, err := e.executeQuery(ectx, query, queryContext)
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
return err
@ -146,6 +158,17 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
for region, getMetricDataQuery := range getMetricDataQueries {
q := getMetricDataQuery
eg.Go(func() error {
defer func() {
if err := recover(); err != nil {
plog.Error("Execute Get Metric Data Query Panic", "error", err, "stack", log.Stack(1))
if theErr, ok := err.(error); ok {
resultChan <- &tsdb.QueryResult{
Error: theErr,
}
}
}
}()
queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext)
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
return err
@ -188,8 +211,8 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatch
return nil, err
}
if endTime.Before(startTime) {
return nil, fmt.Errorf("Invalid time range: End time can't be before start time")
if !startTime.Before(endTime) {
return nil, fmt.Errorf("Invalid time range: Start time must be before end time")
}
params := &cloudwatch.GetMetricStatisticsInput{

View File

@ -1,9 +1,13 @@
package cloudwatch
import (
"context"
"testing"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/grafana/grafana/pkg/components/null"
@ -14,6 +18,24 @@ import (
func TestCloudWatch(t *testing.T) {
Convey("CloudWatch", t, func() {
Convey("executeQuery", func() {
e := &CloudWatchExecutor{
DataSource: &models.DataSource{
JsonData: simplejson.New(),
},
}
Convey("End time before start time should result in error", func() {
_, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")})
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time")
})
Convey("End time equals start time should result in error", func() {
_, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")})
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time")
})
})
Convey("can parse cloudwatch json model", func() {
json := `
{

View File

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

View File

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

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 store from 'app/core/store';
const DEFAULT_EXPLORE_STATE: ExploreState = {
datasource: null,
@ -10,7 +18,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
exploreDatasources: [],
graphRange: DEFAULT_RANGE,
history: [],
queries: [],
initialQueries: [],
queryTransactions: [],
range: DEFAULT_RANGE,
showingGraph: true,
@ -33,10 +41,10 @@ describe('state functions', () => {
it('returns a valid Explore state from URL parameter', () => {
const paramValue =
'%7B"datasource":"Local","queries":%5B%7B"query":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D';
'%7B"datasource":"Local","queries":%5B%7B"expr":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D';
expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'Local',
queries: [{ query: 'metric' }],
queries: [{ expr: 'metric' }],
range: {
from: 'now-1h',
to: 'now',
@ -45,10 +53,10 @@ describe('state functions', () => {
});
it('returns a valid Explore state from a compact URL parameter', () => {
const paramValue = '%5B"now-1h","now","Local","metric"%5D';
const paramValue = '%5B"now-1h","now","Local",%7B"expr":"metric"%7D%5D';
expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'Local',
queries: [{ query: 'metric' }],
queries: [{ expr: 'metric' }],
range: {
from: 'now-1h',
to: 'now',
@ -66,18 +74,20 @@ describe('state functions', () => {
from: 'now-5h',
to: 'now',
},
queries: [
initialQueries: [
{
query: 'metric{test="a/b"}',
refId: '1',
expr: 'metric{test="a/b"}',
},
{
query: 'super{foo="x/z"}',
refId: '2',
expr: 'super{foo="x/z"}',
},
],
};
expect(serializeStateToUrlParam(state)).toBe(
'{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' +
'{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
'{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
'{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
);
});
@ -89,17 +99,19 @@ describe('state functions', () => {
from: 'now-5h',
to: 'now',
},
queries: [
initialQueries: [
{
query: 'metric{test="a/b"}',
refId: '1',
expr: 'metric{test="a/b"}',
},
{
query: 'super{foo="x/z"}',
refId: '2',
expr: 'super{foo="x/z"}',
},
],
};
expect(serializeStateToUrlParam(state, true)).toBe(
'["now-5h","now","foo","metric{test=\\"a/b\\"}","super{foo=\\"x/z\\"}"]'
'["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
);
});
});
@ -113,12 +125,14 @@ describe('state functions', () => {
from: 'now - 5h',
to: 'now',
},
queries: [
initialQueries: [
{
query: 'metric{test="a/b"}',
refId: '1',
expr: 'metric{test="a/b"}',
},
{
query: 'super{foo="x/z"}',
refId: '2',
expr: 'super{foo="x/z"}',
},
],
};
@ -126,14 +140,50 @@ describe('state functions', () => {
const parsed = parseUrlState(serialized);
// Account for datasource vs datasourceName
const { datasource, ...rest } = parsed;
const sameState = {
const { datasource, queries, ...rest } = parsed;
const resultState = {
...rest,
datasource: DEFAULT_EXPLORE_STATE.datasource,
datasourceName: datasource,
initialQueries: queries,
};
expect(state).toMatchObject(sameState);
expect(state).toMatchObject(resultState);
});
});
});
describe('updateHistory()', () => {
const datasourceId = 'myDatasource';
const key = `grafana.explore.history.${datasourceId}`;
beforeEach(() => {
clearHistory(datasourceId);
expect(store.exists(key)).toBeFalsy();
});
test('should save history item to localStorage', () => {
const expected = [
{
query: { refId: '1', expr: 'metric' },
},
];
expect(updateHistory([], datasourceId, [{ refId: '1', expr: 'metric' }])).toMatchObject(expected);
expect(store.exists(key)).toBeTruthy();
expect(store.getObject(key)).toMatchObject(expected);
});
});
describe('hasNonEmptyQuery', () => {
test('should return true if one query is non-empty', () => {
expect(hasNonEmptyQuery([{ refId: '1', key: '2', expr: 'foo' }])).toBeTruthy();
});
test('should return false if query is empty', () => {
expect(hasNonEmptyQuery([{ refId: '1', key: '2' }])).toBeFalsy();
});
test('should return false if no queries exist', () => {
expect(hasNonEmptyQuery([])).toBeFalsy();
});
});

View File

@ -1,11 +1,20 @@
import { renderUrl } from 'app/core/utils/url';
import { ExploreState, ExploreUrlState } from 'app/types/explore';
import { ExploreState, ExploreUrlState, HistoryItem } from 'app/types/explore';
import { DataQuery, RawTimeRange } from 'app/types/series';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import TimeSeries from 'app/core/time_series2';
import { parse as parseDate } from 'app/core/utils/datemath';
import store from 'app/core/store';
export const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
const MAX_HISTORY_ITEMS = 100;
/**
* Returns an Explore-URL that contains a panel's queries and the dashboard time range.
*
@ -23,7 +32,7 @@ export async function getExploreUrl(
timeSrv: any
) {
let exploreDatasource = panelDatasource;
let exploreTargets = panelTargets;
let exploreTargets: DataQuery[] = panelTargets;
let url;
// Mixed datasources need to choose only one datasource
@ -57,6 +66,8 @@ export async function getExploreUrl(
return url;
}
const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
export function parseUrlState(initial: string | undefined): ExploreUrlState {
if (initial) {
try {
@ -70,7 +81,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
to: parsed[1],
};
const datasource = parsed[2];
const queries = parsed.slice(3).map(query => ({ query }));
const queries = parsed.slice(3);
return { datasource, queries, range };
}
return parsed;
@ -84,16 +95,97 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
const urlState: ExploreUrlState = {
datasource: state.datasourceName,
queries: state.queries.map(q => ({ query: q.query })),
queries: state.initialQueries.map(clearQueryKeys),
range: state.range,
};
if (compact) {
return JSON.stringify([
urlState.range.from,
urlState.range.to,
urlState.datasource,
...urlState.queries.map(q => q.query),
]);
return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
}
return JSON.stringify(urlState);
}
export function generateKey(index = 0): string {
return `Q-${Date.now()}-${Math.random()}-${index}`;
}
export function generateRefId(index = 0): string {
return `${index + 1}`;
}
export function generateQueryKeys(index = 0): { refId: string; key: string } {
return { refId: generateRefId(index), key: generateKey(index) };
}
/**
* Ensure at least one target exists and that targets have the necessary keys
*/
export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
if (queries && typeof queries === 'object' && queries.length > 0) {
return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) }));
}
return [{ ...generateQueryKeys() }];
}
/**
* A target is non-empty when it has keys other than refId and key.
*/
export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
return queries.some(query => Object.keys(query).length > 2);
}
export function getIntervals(
range: RawTimeRange,
datasource,
resolution: number
): { interval: string; intervalMs: number } {
if (!datasource || !resolution) {
return { interval: '1s', intervalMs: 1000 };
}
const absoluteRange: RawTimeRange = {
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
}
export function makeTimeSeriesList(dataList) {
return dataList.map((seriesData, index) => {
const datapoints = seriesData.datapoints || [];
const alias = seriesData.target;
const colorIndex = index % colors.length;
const color = colors[colorIndex];
const series = new TimeSeries({
datapoints,
alias,
color,
unit: seriesData.unit,
});
return series;
});
}
/**
* Update the query history. Side-effect: store history in local storage
*/
export function updateHistory(history: HistoryItem[], datasourceId: string, queries: DataQuery[]): HistoryItem[] {
const ts = Date.now();
queries.forEach(query => {
history = [{ query, ts }, ...history];
});
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
// Combine all queries of a datasource type into one history
const historyKey = `grafana.explore.history.${datasourceId}`;
store.setObject(historyKey, history);
return history;
}
export function clearHistory(datasourceId: string) {
const historyKey = `grafana.explore.history.${datasourceId}`;
store.delete(historyKey);
}

View File

@ -64,9 +64,9 @@
</div>
<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>
<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>
<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-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>
<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 class="gf-form">
<label class="gf-form-label">

View File

@ -8,9 +8,9 @@ const alertQueryDef = new QueryPartDef({
{
name: 'from',
type: 'string',
options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h'],
options: ['10s', '1m', '5m', '10m', '15m', '1h', '24h', '48h'],
},
{ name: 'to', type: 'string', options: ['now'] },
{ name: 'to', type: 'string', options: ['now', 'now-1m', 'now-5m', 'now-10m', 'now-1h'] },
],
defaultParams: ['#A', '15m', 'now', 'avg'],
});

View File

@ -4,14 +4,26 @@ import Select from 'react-select';
import _ from 'lodash';
import { DataSource } from 'app/types/datasources';
import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
import {
ExploreState,
ExploreUrlState,
QueryTransaction,
ResultType,
QueryHintGetter,
QueryHint,
} from 'app/types/explore';
import { RawTimeRange, DataQuery } from 'app/types/series';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import store from 'app/core/store';
import TimeSeries from 'app/core/time_series2';
import { parse as parseDate } from 'app/core/utils/datemath';
import { DEFAULT_RANGE } from 'app/core/utils/explore';
import {
DEFAULT_RANGE,
ensureQueries,
getIntervals,
generateKey,
generateQueryKeys,
hasNonEmptyQuery,
makeTimeSeriesList,
updateHistory,
} from 'app/core/utils/explore';
import ResetStyles from 'app/core/components/Picker/ResetStyles';
import PickerOption from 'app/core/components/Picker/PickerOption';
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
@ -26,57 +38,6 @@ import Logs from './Logs';
import Table from './Table';
import ErrorBoundary from './ErrorBoundary';
import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
const MAX_HISTORY_ITEMS = 100;
function getIntervals(range: RawTimeRange, datasource, resolution: number): { interval: string; intervalMs: number } {
if (!datasource || !resolution) {
return { interval: '1s', intervalMs: 1000 };
}
const absoluteRange: RawTimeRange = {
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
}
function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => {
const datapoints = seriesData.datapoints || [];
const alias = seriesData.target;
const colorIndex = index % colors.length;
const color = colors[colorIndex];
const series = new TimeSeries({
datapoints,
alias,
color,
unit: seriesData.unit,
});
return series;
});
}
/**
* Update the query history. Side-effect: store history in local storage
*/
function updateHistory(history: HistoryItem[], datasourceId: string, queries: string[]): HistoryItem[] {
const ts = Date.now();
queries.forEach(query => {
history = [{ query, ts }, ...history];
});
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
// Combine all queries of a datasource type into one history
const historyKey = `grafana.explore.history.${datasourceId}`;
store.setObject(historyKey, history);
return history;
}
interface ExploreProps {
datasourceSrv: DatasourceSrv;
@ -89,14 +50,49 @@ interface ExploreProps {
urlState: ExploreUrlState;
}
/**
* Explore provides an area for quick query iteration for a given datasource.
* Once a datasource is selected it populates the query section at the top.
* When queries are run, their results are being displayed in the main section.
* The datasource determines what kind of query editor it brings, and what kind
* of results viewers it supports.
*
* QUERY HANDLING
*
* TLDR: to not re-render Explore during edits, query editing is not "controlled"
* in a React sense: values need to be pushed down via `initialQueries`, while
* edits travel up via `this.modifiedQueries`.
*
* By default the query rows start without prior state: `initialQueries` will
* contain one empty DataQuery. While the user modifies the DataQuery, the
* modifications are being tracked in `this.modifiedQueries`, which need to be
* used whenever a query is sent to the datasource to reflect what the user sees
* on the screen. Query rows can be initialized or reset using `initialQueries`,
* by giving the respective row a new key. This wipes the old row and its state.
* This property is also used to govern how many query rows there are (minimum 1).
*
* This flow makes sure that a query row can be arbitrarily complex without the
* fear of being wiped or re-initialized via props. The query row is free to keep
* its own state while the user edits or builds a query. Valid queries can be sent
* up to Explore via the `onChangeQuery` prop.
*
* DATASOURCE REQUESTS
*
* A click on Run Query creates transactions for all DataQueries for all expanded
* result viewers. New runs are discarding previous runs. Upon completion a transaction
* saves the result. The result viewers construct their data from the currently existing
* transactions.
*
* The result viewers determine some of the query options sent to the datasource, e.g.,
* `format`, to indicate eventual transformations by the datasources' result transformers.
*/
export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
el: any;
/**
* Current query expressions of the rows including their modifications, used for running queries.
* Not kept in component state to prevent edit-render roundtrips.
* TODO: make this generic (other datasources might not have string representations of current query state)
*/
queryExpressions: string[];
modifiedQueries: DataQuery[];
/**
* Local ID cache to compare requested vs selected datasource
*/
@ -105,11 +101,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
constructor(props) {
super(props);
const splitState: ExploreState = props.splitState;
let initialQueries: Query[];
let initialQueries: DataQuery[];
if (splitState) {
// Split state overrides everything
this.state = splitState;
initialQueries = splitState.queries;
initialQueries = splitState.initialQueries;
} else {
const { datasource, queries, range } = props.urlState as ExploreUrlState;
initialQueries = ensureQueries(queries);
@ -122,8 +118,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
datasourceName: datasource,
exploreDatasources: [],
graphRange: initialRange,
initialQueries,
history: [],
queries: initialQueries,
queryTransactions: [],
range: initialRange,
showingGraph: true,
@ -135,7 +131,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
supportsTable: null,
};
}
this.queryExpressions = initialQueries.map(q => q.query);
this.modifiedQueries = initialQueries.slice();
}
async componentDidMount() {
@ -198,32 +194,26 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}
// Check if queries can be imported from previously selected datasource
let queryExpressions = this.queryExpressions;
let modifiedQueries = this.modifiedQueries;
if (origin) {
if (origin.meta.id === datasource.meta.id) {
// Keep same queries if same type of datasource
queryExpressions = [...this.queryExpressions];
modifiedQueries = [...this.modifiedQueries];
} else if (datasource.importQueries) {
// Datasource-specific importers, wrapping to satisfy interface
const wrappedQueries: DataQuery[] = this.queryExpressions.map((query, index) => ({
refId: String(index),
expr: query,
}));
const modifiedQueries: DataQuery[] = await datasource.importQueries(wrappedQueries, origin.meta);
queryExpressions = modifiedQueries.map(({ expr }) => expr);
// Datasource-specific importers
modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta);
} else {
// Default is blank queries
queryExpressions = this.queryExpressions.map(() => '');
modifiedQueries = ensureQueries();
}
}
// Reset edit state with new queries
const nextQueries = this.state.queries.map((q, i) => ({
...q,
key: generateQueryKey(i),
query: queryExpressions[i],
const nextQueries = this.state.initialQueries.map((q, i) => ({
...modifiedQueries[i],
...generateQueryKeys(i),
}));
this.queryExpressions = queryExpressions;
this.modifiedQueries = modifiedQueries;
// Custom components
const StartPage = datasource.pluginExports.ExploreStartPage;
@ -239,7 +229,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
supportsTable,
datasourceLoading: false,
datasourceName: datasource.name,
queries: nextQueries,
initialQueries: nextQueries,
showingStartPage: Boolean(StartPage),
},
() => {
@ -256,16 +246,15 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onAddQueryRow = index => {
// Local cache
this.queryExpressions[index + 1] = '';
this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) };
this.setState(state => {
const { queries, queryTransactions } = state;
const { initialQueries, queryTransactions } = state;
// Add row by generating new react key
const nextQueries = [
...queries.slice(0, index + 1),
{ query: '', key: generateQueryKey() },
...queries.slice(index + 1),
...initialQueries.slice(0, index + 1),
{ ...this.modifiedQueries[index + 1] },
...initialQueries.slice(index + 1),
];
// Ongoing transactions need to update their row indices
@ -279,7 +268,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
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);
};
onChangeQuery = (value: string, index: number, override?: boolean) => {
onChangeQuery = (value: DataQuery, index: number, override?: boolean) => {
// Null value means reset
if (value === null) {
value = { ...generateQueryKeys(index) };
}
// Keep current value in local cache
this.queryExpressions[index] = value;
this.modifiedQueries[index] = value;
if (override) {
this.setState(state => {
// Replace query row
const { queries, queryTransactions } = state;
const nextQuery: Query = {
key: generateQueryKey(index),
query: value,
// Replace query row by injecting new key
const { initialQueries, queryTransactions } = state;
const query: DataQuery = {
...value,
...generateQueryKeys(index),
};
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
const nextQueries = [...initialQueries];
nextQueries[index] = query;
this.modifiedQueries = [...nextQueries];
// Discard ongoing transaction related to row query
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
return {
queries: nextQueries,
initialQueries: nextQueries,
queryTransactions: nextQueryTransactions,
};
}, this.onSubmit);
@ -330,10 +325,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
};
onClickClear = () => {
this.queryExpressions = [''];
this.modifiedQueries = ensureQueries();
this.setState(
prevState => ({
queries: ensureQueries(),
initialQueries: [...this.modifiedQueries],
queryTransactions: [],
showingStartPage: Boolean(prevState.StartPage),
}),
@ -387,10 +382,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
};
// Use this in help pages to set page to a single query
onClickQuery = query => {
const nextQueries = [{ query, key: generateQueryKey() }];
this.queryExpressions = nextQueries.map(q => q.query);
this.setState({ queries: nextQueries }, this.onSubmit);
onClickExample = (query: DataQuery) => {
const nextQueries = [{ ...query, ...generateQueryKeys() }];
this.modifiedQueries = [...nextQueries];
this.setState({ initialQueries: nextQueries }, this.onSubmit);
};
onClickSplit = () => {
@ -430,28 +425,28 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
const preventSubmit = action.preventSubmit;
this.setState(
state => {
const { queries, queryTransactions } = state;
let nextQueries;
const { initialQueries, queryTransactions } = state;
let nextQueries: DataQuery[];
let nextQueryTransactions;
if (index === undefined) {
// Modify all queries
nextQueries = queries.map((q, i) => ({
key: generateQueryKey(i),
query: datasource.modifyQuery(this.queryExpressions[i], action),
nextQueries = initialQueries.map((query, i) => ({
...datasource.modifyQuery(this.modifiedQueries[i], action),
...generateQueryKeys(i),
}));
// Discard all ongoing transactions
nextQueryTransactions = [];
} else {
// Modify query only at index
nextQueries = queries.map((q, i) => {
nextQueries = initialQueries.map((query, i) => {
// Synchronise all queries with local query cache to ensure consistency
q.query = this.queryExpressions[i];
// TODO still needed?
return i === index
? {
key: generateQueryKey(index),
query: datasource.modifyQuery(q.query, action),
...datasource.modifyQuery(this.modifiedQueries[i], action),
...generateQueryKeys(i),
}
: q;
: query;
});
nextQueryTransactions = queryTransactions
// Consume the hint corresponding to the action
@ -464,9 +459,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
// Preserve previous row query transaction to keep results visible if next query is incomplete
.filter(qt => preventSubmit || qt.rowIndex !== index);
}
this.queryExpressions = nextQueries.map(q => q.query);
this.modifiedQueries = [...nextQueries];
return {
queries: nextQueries,
initialQueries: nextQueries,
queryTransactions: nextQueryTransactions,
};
},
@ -478,22 +473,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onRemoveQueryRow = index => {
// Remove from local cache
this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)];
this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)];
this.setState(
state => {
const { queries, queryTransactions } = state;
if (queries.length <= 1) {
const { initialQueries, queryTransactions } = state;
if (initialQueries.length <= 1) {
return null;
}
// Remove row from react state
const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
// Discard transactions related to row query
const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
return {
queries: nextQueries,
initialQueries: nextQueries,
queryTransactions: nextQueryTransactions,
};
},
@ -503,52 +498,68 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
onSubmit = () => {
const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
// Keep table queries first since they need to return quickly
if (showingTable && supportsTable) {
this.runTableQuery();
this.runQueries(
'Table',
{
format: 'table',
instant: true,
valueWithRefId: true,
},
data => data[0]
);
}
if (showingGraph && supportsGraph) {
this.runGraphQueries();
this.runQueries(
'Graph',
{
format: 'time_series',
instant: false,
},
makeTimeSeriesList
);
}
if (showingLogs && supportsLogs) {
this.runLogsQuery();
this.runQueries('Logs', { format: 'logs' });
}
this.saveState();
};
buildQueryOptions(
query: string,
rowIndex: number,
targetOptions: { format: string; hinting?: boolean; instant?: boolean }
) {
buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) {
const { datasource, range } = this.state;
const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth);
const targets = [
const configuredQueries = [
{
...targetOptions,
// Target identifier is needed for table transformations
refId: rowIndex + 1,
expr: query,
...queryOptions,
...query,
},
];
// Clone range for query request
const queryRange: RawTimeRange = { ...range };
// Datasource is using `panelId + query.refId` for cancellation logic.
// Using `format` here because it relates to the view panel that the request is for.
const panelId = queryOptions.format;
return {
interval,
intervalMs,
targets,
panelId,
targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
range: queryRange,
};
}
startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
const queryOptions = this.buildQueryOptions(query, rowIndex, options);
startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
const queryOptions = this.buildQueryOptions(query, options);
const transaction: QueryTransaction = {
query,
resultType,
rowIndex,
id: generateQueryKey(),
id: generateKey(), // reusing for unique ID
done: false,
latency: 0,
options: queryOptions,
@ -578,7 +589,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
transactionId: string,
result: any,
latency: number,
queries: string[],
queries: DataQuery[],
datasourceId: string
) {
const { datasource } = this.state;
@ -597,8 +608,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}
// Get query hints
let hints;
if (datasource.getQueryHints) {
let hints: QueryHint[];
if (datasource.getQueryHints as QueryHintGetter) {
hints = datasource.getQueryHints(transaction.query, result);
}
@ -634,7 +645,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
const { datasource } = this.state;
if (datasource.meta.id !== datasourceId) {
if (datasource.meta.id !== datasourceId || response.cancelled) {
// Navigated away, queries did not matter
return;
}
@ -678,88 +689,25 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
});
}
async runGraphQueries() {
const queries = [...this.queryExpressions];
if (!hasQuery(queries)) {
async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) {
const queries = [...this.modifiedQueries];
if (!hasNonEmptyQuery(queries)) {
return;
}
const { datasource } = this.state;
const datasourceId = datasource.meta.id;
// Run all queries concurrently
queries.forEach(async (query, rowIndex) => {
if (query) {
const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', {
format: 'time_series',
instant: false,
});
try {
const now = Date.now();
const res = await datasource.query(transaction.options);
const latency = Date.now() - now;
const results = makeTimeSeriesList(res.data, transaction.options);
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
this.setState({ graphRange: transaction.options.range });
} catch (response) {
this.failQueryTransaction(transaction.id, response, datasourceId);
}
} else {
this.discardTransactions(rowIndex);
}
});
}
async runTableQuery() {
const queries = [...this.queryExpressions];
if (!hasQuery(queries)) {
return;
}
const { datasource } = this.state;
const datasourceId = datasource.meta.id;
// Run all queries concurrently
queries.forEach(async (query, rowIndex) => {
if (query) {
const transaction = this.startQueryTransaction(query, rowIndex, 'Table', {
format: 'table',
instant: true,
valueWithRefId: true,
});
try {
const now = Date.now();
const res = await datasource.query(transaction.options);
const latency = Date.now() - now;
const results = res.data[0];
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
} catch (response) {
this.failQueryTransaction(transaction.id, response, datasourceId);
}
} else {
this.discardTransactions(rowIndex);
}
});
}
async runLogsQuery() {
const queries = [...this.queryExpressions];
if (!hasQuery(queries)) {
return;
}
const { datasource } = this.state;
const datasourceId = datasource.meta.id;
// Run all queries concurrently
queries.forEach(async (query, rowIndex) => {
if (query) {
const transaction = this.startQueryTransaction(query, rowIndex, 'Logs', { format: 'logs' });
try {
const now = Date.now();
const res = await datasource.query(transaction.options);
const latency = Date.now() - now;
const results = res.data;
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
} catch (response) {
this.failQueryTransaction(transaction.id, response, datasourceId);
}
} else {
this.discardTransactions(rowIndex);
const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions);
try {
const now = Date.now();
const res = await datasource.query(transaction.options);
const latency = Date.now() - now;
const results = resultGetter ? resultGetter(res.data) : res.data;
this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
this.setState({ graphRange: transaction.options.range });
} catch (response) {
this.failQueryTransaction(transaction.id, response, datasourceId);
}
});
}
@ -769,7 +717,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
return {
...this.state,
queryTransactions: [],
queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
initialQueries: [...this.modifiedQueries],
};
}
@ -789,7 +737,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
exploreDatasources,
graphRange,
history,
queries,
initialQueries,
queryTransactions,
range,
showingGraph,
@ -903,7 +851,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
<QueryRows
datasource={datasource}
history={history}
queries={queries}
initialQueries={initialQueries}
onAddQueryRow={this.onAddQueryRow}
onChangeQuery={this.onChangeQuery}
onClickHintFix={this.onModifyQueries}
@ -913,7 +861,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/>
<main className="m-t-2">
<ErrorBoundary>
{showingStartPage && <StartPage onClickQuery={this.onClickQuery} />}
{showingStartPage && <StartPage onClickExample={this.onClickExample} />}
{!showingStartPage && (
<>
{supportsGraph && (

View File

@ -13,6 +13,7 @@ import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2';
import Legend from './Legend';
import { equal, intersect } from './utils/set';
const MAX_NUMBER_OF_TIME_SERIES = 20;
@ -85,13 +86,20 @@ interface GraphProps {
}
interface GraphState {
/**
* Type parameter refers to the `alias` property of a `TimeSeries`.
* Consequently, all series sharing the same alias will share visibility state.
*/
hiddenSeries: Set<string>;
showAllTimeSeries: boolean;
}
export class Graph extends PureComponent<GraphProps, GraphState> {
$el: any;
dynamicOptions = null;
state = {
hiddenSeries: new Set(),
showAllTimeSeries: false,
};
@ -107,13 +115,14 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
this.$el.bind('plotselected', this.onPlotSelected);
}
componentDidUpdate(prevProps: GraphProps) {
componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
if (
prevProps.data !== this.props.data ||
prevProps.range !== this.props.range ||
prevProps.split !== this.props.split ||
prevProps.height !== this.props.height ||
(prevProps.size && prevProps.size.width !== this.props.size.width)
(prevProps.size && prevProps.size.width !== this.props.size.width) ||
!equal(prevState.hiddenSeries, this.state.hiddenSeries)
) {
this.draw();
}
@ -133,30 +142,8 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
}
};
onShowAllTimeSeries = () => {
this.setState(
{
showAllTimeSeries: true,
},
this.draw
);
};
draw() {
const { range, size, userOptions = {} } = this.props;
const data = this.getGraphData();
const $el = $(`#${this.props.id}`);
let series = [{ data: [[0, 0]] }];
if (data && data.length > 0) {
series = data.map((ts: TimeSeries) => ({
color: ts.color,
label: ts.label,
data: ts.getFlotPairs('null'),
}));
}
getDynamicOptions() {
const { range, size } = this.props;
const ticks = (size.width || 0) / 100;
let { from, to } = range;
if (!moment.isMoment(from)) {
@ -167,7 +154,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
}
const min = from.valueOf();
const max = to.valueOf();
const dynamicOptions = {
return {
xaxis: {
mode: 'time',
min: min,
@ -178,16 +165,76 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
timeformat: time_format(ticks, min, max),
},
};
}
onShowAllTimeSeries = () => {
this.setState(
{
showAllTimeSeries: true,
},
this.draw
);
};
onToggleSeries = (series: TimeSeries, exclusive: boolean) => {
this.setState((state, props) => {
const { data } = props;
const { hiddenSeries } = state;
const hidden = hiddenSeries.has(series.alias);
// Deduplicate series as visibility tracks the alias property
const oneSeriesVisible = hiddenSeries.size === new Set(data.map(d => d.alias)).size - 1;
if (exclusive) {
return {
hiddenSeries:
!hidden && oneSeriesVisible
? new Set()
: new Set(data.filter(d => d.alias !== series.alias).map(d => d.alias)),
};
}
// Prune hidden series no longer part of those available from the most recent query
const availableSeries = new Set(data.map(d => d.alias));
const nextHiddenSeries = intersect(new Set(hiddenSeries), availableSeries);
if (nextHiddenSeries.has(series.alias)) {
nextHiddenSeries.delete(series.alias);
} else {
nextHiddenSeries.add(series.alias);
}
return {
hiddenSeries: nextHiddenSeries,
};
}, this.draw);
};
draw() {
const { userOptions = {} } = this.props;
const { hiddenSeries } = this.state;
const data = this.getGraphData();
const $el = $(`#${this.props.id}`);
let series = [{ data: [[0, 0]] }];
if (data && data.length > 0) {
series = data.filter((ts: TimeSeries) => !hiddenSeries.has(ts.alias)).map((ts: TimeSeries) => ({
color: ts.color,
label: ts.label,
data: ts.getFlotPairs('null'),
}));
}
this.dynamicOptions = this.getDynamicOptions();
const options = {
...FLOT_OPTIONS,
...dynamicOptions,
...this.dynamicOptions,
...userOptions,
};
$.plot($el, series, options);
}
render() {
const { height = '100px', id = 'graph' } = this.props;
const { hiddenSeries } = this.state;
const data = this.getGraphData();
return (
@ -204,7 +251,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
</div>
)}
<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 }) => (
<div className="graph-legend-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}>
{series.alias}
</a>
</div>
);
interface LegendProps {
data: TimeSeries[];
hiddenSeries: Set<string>;
onToggleSeries?: (series: TimeSeries, exclusive: boolean) => void;
}
interface LegendItemProps {
hidden: boolean;
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() {
const { className = '', data } = this.props;
const items = data || [];
const { hidden, series } = this.props;
const seriesClasses = classNames({
'graph-legend-series-hidden': hidden,
});
return (
<div className={`${className} graph-legend ps`}>
{items.map(series => <LegendItem key={series.id} series={series} />)}
<div className={`graph-legend-series ${seriesClasses}`}>
<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>
);
}

View File

@ -27,14 +27,14 @@ function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
return suggestions && suggestions.length > 0;
}
interface QueryFieldProps {
export interface QueryFieldProps {
additionalPlugins?: any[];
cleanText?: (text: string) => string;
initialValue: string | null;
initialQuery: string | null;
onBlur?: () => void;
onFocus?: () => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
onValueChanged?: (value: Value) => void;
onValueChanged?: (value: string) => void;
onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
placeholder?: string;
portalOrigin?: string;
@ -60,16 +60,22 @@ export interface TypeaheadInput {
wrapperNode: Element;
}
/**
* Renders an editor field.
* Pass initial value as initialQuery and listen to changes in props.onValueChanged.
* This component can only process strings. Internally it uses Slate Value.
* Implement props.onTypeahead to use suggestions, see PromQueryField.tsx as an example.
*/
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
menuEl: HTMLElement | null;
placeholdersBuffer: PlaceholdersBuffer;
plugins: any[];
resetTimer: any;
constructor(props, context) {
constructor(props: QueryFieldProps, context) {
super(props, context);
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialQuery || '');
// Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p);
@ -92,7 +98,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
clearTimeout(this.resetTimer);
}
componentDidUpdate(prevProps, prevState) {
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
// Only update menu location when suggestion existence or text/selection changed
if (
this.state.value !== prevState.value ||

View File

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

View File

@ -35,7 +35,9 @@ export default class QueryTransactionStatus extends PureComponent<QueryTransacti
const { transactions } = this.props;
return (
<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>
);
}

View File

@ -453,6 +453,8 @@ exports[`Render should render component 1`] = `
},
]
}
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/>
</Fragment>
`;
@ -947,6 +949,8 @@ exports[`Render should render component with disclaimer 1`] = `
},
]
}
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/>
</Fragment>
`;
@ -964,6 +968,8 @@ exports[`Render should show query return no time series 1`] = `
/>
<Legend
data={Array []}
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/>
</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 './datasource_srv';
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 (
$scope.current.type === 'query' &&
_.isString($scope.current.query) &&
$scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)'))
) {
appEvents.emit('alert-warning', [
@ -106,11 +107,20 @@ export class VariableEditorCtrl {
});
};
$scope.onQueryChange = (query, definition) => {
$scope.current.query = query;
$scope.current.definition = definition;
$scope.runQuery();
};
$scope.edit = variable => {
$scope.current = variable;
$scope.currentIsNew = false;
$scope.mode = 'edit';
$scope.validate();
datasourceSrv.get($scope.current.datasource).then(ds => {
$scope.currentDatasource = ds;
});
};
$scope.duplicate = variable => {
@ -171,6 +181,13 @@ export class VariableEditorCtrl {
$scope.showMoreOptions = () => {
$scope.optionsLimit += 20;
};
$scope.datasourceChanged = async () => {
datasourceSrv.get($scope.current.datasource).then(ds => {
$scope.current.query = '';
$scope.currentDatasource = ds;
});
};
}
}

View File

@ -17,14 +17,16 @@
</a>
<div class="grafana-info-box">
<h5>What do variables do?</h5>
<p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names
in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
<p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor
names
in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the
top of
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
Check out the
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
Templating documentation
</a> for more information.
Check out the
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
Templating documentation
</a> for more information.
</div>
</div>
</div>
@ -32,7 +34,7 @@
<div ng-if="variables.length">
<div class="page-action-bar">
<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>
<table class="filter-table filter-table--hover">
@ -51,7 +53,7 @@
</span>
</td>
<td style="max-width: 200px;" ng-click="edit(variable)" class="pointer max-width">
{{variable.query}}
{{variable.definition ? variable.definition : variable.query}}
</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>
@ -77,7 +79,8 @@
<div class="gf-form-inline">
<div class="gf-form max-width-19">
<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 class="gf-form max-width-19">
<span class="gf-form-label width-6">
@ -87,13 +90,15 @@
</info-popover>
</span>
<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 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 class="gf-form-inline">
@ -115,7 +120,8 @@
<div class="gf-form">
<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 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>
</span>
<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 class="gf-form">
<span class="gf-form-label" ng-show="current.auto">
Min interval <tip>The calculated value will not go below this threshold</tip>
</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>
@ -143,7 +151,8 @@
<h5 class="section-heading">Custom Options</h5>
<div class="gf-form">
<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>
@ -168,15 +177,17 @@
<div class="gf-form-inline">
<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">
<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>
</select>
</div>
</div>
<div class="gf-form max-width-22">
<span class="gf-form-label width-7">
<span class="gf-form-label width-10">
Refresh
<info-popover mode="right-normal">
When to update the values of this variable.
@ -187,28 +198,32 @@
</div>
</div>
</div>
<rebuild-on-change property="currentDatasource">
<variable-query-editor-loader>
</variable-query-editor-loader>
</rebuild-on-change>
<div class="gf-form">
<span class="gf-form-label width-7">Query</span>
<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">
<span class="gf-form-label width-10">
Regex
<info-popover mode="right-normal">
Optional, if you want to extract part of a series name or metric node segment.
</info-popover>
</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 class="gf-form max-width-21">
<span class="gf-form-label width-7">
<span class="gf-form-label width-10">
Sort
<info-popover mode="right-normal">
How to sort the values of this variable.
</info-popover>
</span>
<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>
@ -219,7 +234,8 @@
<div class="gf-form">
<label class="gf-form-label width-12">Type</label>
<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>
@ -234,7 +250,8 @@
</info-popover>
</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>
@ -243,7 +260,8 @@
<div class="gf-form max-width-21">
<span class="gf-form-label width-8">Data source</span>
<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>
</select>
</div>
@ -253,18 +271,11 @@
<div class="section gf-form-group" ng-show="variableTypes[current.type].supportsMulti">
<h5 class="section-heading">Selection Options</h5>
<div class="section">
<gf-form-switch class="gf-form"
label="Multi-value"
label-class="width-10"
tooltip="Enables multiple values to be selected at the same time"
checked="current.multi"
on-change="runQuery()">
<gf-form-switch class="gf-form" label="Multi-value" 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 class="gf-form"
label="Include All option"
label-class="width-10"
checked="current.includeAll"
on-change="runQuery()">
<gf-form-switch class="gf-form" label="Include All option" label-class="width-10" checked="current.includeAll"
on-change="runQuery()">
</gf-form-switch>
</div>
<div class="gf-form" ng-if="current.includeAll">
@ -279,11 +290,13 @@
</gf-form-switch>
<div class="gf-form last" ng-if="current.useTags">
<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 class="gf-form" ng-if="current.useTags">
<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>
@ -291,11 +304,11 @@
<h5>Preview of values</h5>
<div class="gf-form-inline">
<div class="gf-form" ng-repeat="option in current.options | limitTo: optionsLimit">
<span class="gf-form-label">{{option.text}}</span>
</div>
<div class="gf-form" ng-if= "current.options.length > optionsLimit">
<a class="gf-form-label btn-secondary" ng-click="showMoreOptions()">Show more</a>
</div>
<span class="gf-form-label">{{option.text}}</span>
</div>
<div class="gf-form" ng-if="current.options.length > optionsLimit">
<a class="gf-form-label btn-secondary" ng-click="showMoreOptions()">Show more</a>
</div>
</div>
</div>
@ -309,5 +322,4 @@
</div>
</form>
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -7,12 +7,37 @@ describe('Language completion provider', () => {
metadataRequest: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(0);
describe('empty query suggestions', () => {
it('returns default suggestions on emtpty context', () => {
const instance = new LanguageProvider(datasource);
const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(0);
});
it('returns default suggestions with history on emtpty context when history was provided', () => {
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('');
const history = [
{
query: { refId: '1', expr: '{app="foo"}' },
},
];
const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }, { history });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toMatchObject([
{
label: 'History',
items: [
{
label: '{app="foo"}',
},
],
},
]);
});
});
describe('label suggestions', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

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', () => {

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

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 { StackdriverConfigCtrl } from './config_ctrl';
import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl';
import { StackdriverVariableQueryEditor } from './components/VariableQueryEditor';
export {
StackdriverDatasource as Datasource,
StackdriverQueryCtrl as QueryCtrl,
StackdriverConfigCtrl as ConfigCtrl,
StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl,
StackdriverVariableQueryEditor as VariableQueryEditor,
};

View File

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

View File

@ -14,7 +14,7 @@
</div>
<div class="gf-form-inline">
<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" />
</div>
<div class="gf-form">
@ -70,4 +70,4 @@
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>
</query-editor-row>

View File

@ -1,37 +1,52 @@
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Service</span>
<gf-form-dropdown model="ctrl.service" get-options="ctrl.services" class="min-width-20" disabled type="text"
allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onServiceChange(ctrl.service)"></gf-form-dropdown>
<span class="gf-form-label width-9 query-keyword">Service</span>
<select
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 class="gf-form">
<span class="gf-form-label width-9">Metric</span>
<gf-form-dropdown model="ctrl.metricType" get-options="ctrl.metrics" class="min-width-20" disabled type="text"
allow-custom="true" lookup-text="true" css-class="min-width-12" on-change="ctrl.onMetricTypeChange()"></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
<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"
allow-custom="true"
lookup-text="true"
css-class="min-width-12"
on-change="ctrl.onMetricTypeChange()"
></gf-form-dropdown>
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Filter</span>
<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 class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>
<div class="gf-form-inline" ng-hide="ctrl.$scope.hideGroupBys">
<div class="gf-form">
<span class="gf-form-label query-keyword width-9">Group By</span>
<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 class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
<div class="gf-form gf-form--grow"><div class="gf-form-label gf-form-label--grow"></div></div>
</div>

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ export interface PluginExports {
QueryCtrl?: any;
ConfigCtrl?: any;
AnnotationsQueryCtrl?: any;
VariableQueryEditor?: any;
ExploreQueryField?: any;
ExploreStartPage?: any;
@ -107,3 +108,10 @@ export interface PluginsState {
hasFetched: boolean;
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="$@"
# Right now we hack this in into the publish script.
# Right now we hack this in into the publish script.
# Eventually we might want to keep a list of all previous releases somewhere.
_releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-3-x/10244"
_whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-3/"
_releaseNoteUrl="https://community.grafana.com/t/release-notes-v5-4-x/12215"
_whatsNewUrl="http://docs.grafana.org/guides/whats-new-in-v5-4/"
./scripts/build/release_publisher/release_publisher \
--wn ${_whatsNewUrl} \

View File

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

View File

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