diff --git a/.circleci/config.yml b/.circleci/config.yml index c293ea26a9d..28400fc6bd6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -206,6 +206,10 @@ jobs: - run: docker info - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}" + - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cd packaging/docker && ./build-enterprise.sh "master" + grafana-docker-pr: docker: @@ -230,6 +234,9 @@ jobs: - run: docker info - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}" + - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}" build-enterprise: docker: @@ -409,6 +416,7 @@ workflows: - grafana-docker-master: requires: - build-all + - build-all-enterprise - test-backend - test-frontend - codespell diff --git a/CHANGELOG.md b/CHANGELOG.md index 778da9cd499..0b14126e5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,16 @@ * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +* **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) ### 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) +# 5.3.3 (unreleased) + +* **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769) + # 5.3.2 (2018-10-24) * **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm) diff --git a/Gruntfile.js b/Gruntfile.js index 2d5990b5f58..de3e68d4a92 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -9,12 +9,17 @@ module.exports = function (grunt) { destDir: 'dist', tempDir: 'tmp', platform: process.platform.replace('win32', 'windows'), + enterprise: false, }; if (grunt.option('platform')) { config.platform = grunt.option('platform'); } + if (grunt.option('enterprise')) { + config.enterprise = true; + } + if (grunt.option('arch')) { config.arch = grunt.option('arch'); } else { diff --git a/Makefile b/Makefile index c9e51d897f3..fcb740d2fac 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,7 @@ all: deps build deps-go: go run build.go setup -deps-js: - yarn install --pure-lockfile --no-progress +deps-js: node_modules deps: deps-js @@ -43,3 +42,10 @@ test: test-go test-js run: ./bin/grafana-server + +clean: + rm -rf node_modules + rm -rf public/build + +node_modules: package.json yarn.lock + yarn install --pure-lockfile --no-progress diff --git a/build.go b/build.go index 69fbf3bada8..b136754efbc 100644 --- a/build.go +++ b/build.go @@ -403,6 +403,10 @@ func gruntBuildArg(task string) []string { if phjsToRelease != "" { args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease)) } + if enterprise { + args = append(args, "--enterprise") + } + args = append(args, fmt.Sprintf("--platform=%v", goos)) return args @@ -467,6 +471,7 @@ func ldflags() string { b.WriteString(fmt.Sprintf(" -X main.version=%s", version)) b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha())) b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp())) + b.WriteString(fmt.Sprintf(" -X main.buildBranch=%s", getGitBranch())) return b.String() } @@ -514,6 +519,14 @@ func setBuildEnv() { } } +func getGitBranch() string { + v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "master" + } + return string(v) +} + func getGitSha() string { v, err := runError("git", "rev-parse", "--short", "HEAD") if err != nil { diff --git a/devenv/dev-dashboards/panel_tests_graph.json b/devenv/dev-dashboards/panel_tests_graph.json index 8a1770f0fa6..ba677764a43 100644 --- a/devenv/dev-dashboards/panel_tests_graph.json +++ b/devenv/dev-dashboards/panel_tests_graph.json @@ -927,6 +927,123 @@ "title": "", "type": "text" }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 0, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 44 + }, + "id": 21, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "C-series", + "steppedLine": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "hide": false, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,null,40,null,90,null,null,100,null,null,100,null,null,80,null", + "target": "" + }, + { + "alias": "", + "hide": false, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "20,null40,null,null,50,null,70,null,100,null,10,null,30,null", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Null between points", + "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": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points", + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 44 + }, + "id": 22, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, { "aliasColors": {}, "bars": false, @@ -939,7 +1056,7 @@ "h": 7, "w": 24, "x": 0, - "y": 44 + "y": 51 }, "id": 20, "legend": { @@ -1024,7 +1141,7 @@ "h": 7, "w": 12, "x": 0, - "y": 51 + "y": 58 }, "id": 16, "legend": { @@ -1127,7 +1244,7 @@ "h": 7, "w": 12, "x": 12, - "y": 51 + "y": 58 }, "id": 17, "legend": { @@ -1266,7 +1383,7 @@ "h": 7, "w": 12, "x": 0, - "y": 58 + "y": 65 }, "id": 18, "legend": { @@ -1370,7 +1487,7 @@ "h": 7, "w": 12, "x": 12, - "y": 58 + "y": 65 }, "id": 19, "legend": { @@ -1554,5 +1671,5 @@ "timezone": "browser", "title": "Panel Tests - Graph", "uid": "5SdHCadmz", - "version": 3 + "version": 1 } diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 8916b2bf6e3..9149aa42130 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -158,7 +158,7 @@ Since not all datasources have the same configuration settings we only have the | timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source | | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) | | timeField | string | Elasticsearch | Which field that should be used as timestamp | -| interval | string | Elasticsearch | Index date time format | +| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | | assumeRoleArn | string | Cloudwatch | ARN of Assume Role | | defaultRegion | string | Cloudwatch | AWS region | diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md index e3a450f9fc7..56fc3b131a5 100644 --- a/docs/sources/auth/gitlab.md +++ b/docs/sources/auth/gitlab.md @@ -100,12 +100,12 @@ display name, especially if the display name contains spaces or special characters. Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. -Here's a complete example with `alloed_sign_up` enabled, and access limited to +Here's a complete example with `allow_sign_up` enabled, and access limited to the `example` and `foo/bar` groups: ```ini [auth.gitlab] -enabled = false +enabled = true allow_sign_up = true client_id = GITLAB_APPLICATION_ID client_secret = GITLAB_SECRET diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh new file mode 100755 index 00000000000..f716a1f44f1 --- /dev/null +++ b/packaging/docker/build-enterprise.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +_grafana_tag=$1 +_docker_repo=${2:-grafana/grafana-enterprise} + +docker build \ + --tag "${_docker_repo}:${_grafana_tag}"\ + --no-cache=true \ + . diff --git a/packaging/release_process.md b/packaging/release_process.md deleted file mode 100644 index 6037a9c499c..00000000000 --- a/packaging/release_process.md +++ /dev/null @@ -1,29 +0,0 @@ -# New Grafana Release Processes - -## Building release packages - -1) Update package.json so that it has the right version. -2) Create a git tag for the release: `git tag -a v3.0.4 -m "3.0.4 release"` -3) Push branch & tag to github! -2) Packages from master a built automatically by circle CI for this repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer) - -### Non master branch - -When building from non master branch create a new branch in repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer) -and configure circle.yml to deploy that branch as well, https://github.com/grafana/grafana-packer/blob/master/circle.yml#L25, -you also need to update https://github.com/grafana/grafana-packer/blob/v3.1.x/deploy.sh#L7. - -### Windows build - -Sign into ci.appveyor.com and the Grafana project's build history page. Builds for windows take a long time (around 20min) -and fail quite often for random reasons so I usually continue with the release process without a windows build already built. - -1) Click on the green build that has the correct version and tag -2) Click on `DEPLOYMENTS` -3) Click on `NEW DEPLOYMENT` -4) Select GrafanaBuildS3 -4) Select the build you want to deploy. - -The deployment should be quick (just uploads the release zip file to S3) - - diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 06c07a2887c..4f10ad7a60a 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -3,6 +3,8 @@ package main import ( "flag" "fmt" + "net/http" + _ "net/http/pprof" "os" "os/signal" "runtime" @@ -11,16 +13,12 @@ import ( "syscall" "time" - "net/http" - _ "net/http/pprof" - + extensions "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/setting" - - extensions "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/services/alerting/conditions" _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" + "github.com/grafana/grafana/pkg/setting" _ "github.com/grafana/grafana/pkg/tsdb/cloudwatch" _ "github.com/grafana/grafana/pkg/tsdb/elasticsearch" _ "github.com/grafana/grafana/pkg/tsdb/graphite" @@ -35,6 +33,7 @@ import ( var version = "5.0.0" var commit = "NA" +var buildBranch = "master" var buildstamp string var configFile = flag.String("config", "", "path to config file") @@ -47,7 +46,7 @@ func main() { profilePort := flag.Int("profile-port", 6060, "Define custom port for profiling") flag.Parse() if *v { - fmt.Printf("Version %s (commit: %s)\n", version, commit) + fmt.Printf("Version %s (commit: %s, branch: %s)\n", version, commit, buildBranch) os.Exit(0) } @@ -78,6 +77,7 @@ func main() { setting.BuildVersion = version setting.BuildCommit = commit setting.BuildStamp = buildstampInt64 + setting.BuildBranch = buildBranch setting.IsEnterprise = extensions.IsEnterprise metrics.M_Grafana_Version.WithLabelValues(version).Set(1) diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 8794d7d8338..765b8ddf993 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -12,24 +12,16 @@ import ( "time" "github.com/facebookgo/inject" + "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/registry" - - "golang.org/x/sync/errgroup" - - "github.com/grafana/grafana/pkg/api" + _ "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/login" - "github.com/grafana/grafana/pkg/setting" - - "github.com/grafana/grafana/pkg/social" - - // self registering services - _ "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/middleware" _ "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry" _ "github.com/grafana/grafana/pkg/services/alerting" _ "github.com/grafana/grafana/pkg/services/cleanup" _ "github.com/grafana/grafana/pkg/services/notifications" @@ -37,7 +29,10 @@ import ( _ "github.com/grafana/grafana/pkg/services/rendering" _ "github.com/grafana/grafana/pkg/services/search" _ "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/social" // self registering services _ "github.com/grafana/grafana/pkg/tracing" + "golang.org/x/sync/errgroup" ) func NewGrafanaServer() *GrafanaServerImpl { @@ -159,7 +154,7 @@ func (g *GrafanaServerImpl) loadConfiguration() { os.Exit(1) } - g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0)) + g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "branch", buildBranch, "compiled", time.Unix(setting.BuildStamp, 0)) g.cfg.LogConfigSources() } diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 4c71ab3cd5f..d4e81d2bd46 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -185,7 +185,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo if ldapUser.isMemberOf(group.GroupDN) { extUser.OrgRoles[group.OrgId] = group.OrgRole - if extUser.IsGrafanaAdmin == nil || *extUser.IsGrafanaAdmin == false { + if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin { extUser.IsGrafanaAdmin = group.IsGrafanaAdmin } } diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 3e83a60f94b..7b29901c1a3 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -43,12 +43,13 @@ func GetContextHandler() macaron.Handler { // then init session and look for userId in session // then look for api key in session (special case for render calls via api) // then test if anonymous access is enabled - if initContextWithRenderAuth(ctx) || - initContextWithApiKey(ctx) || - initContextWithBasicAuth(ctx, orgId) || - initContextWithAuthProxy(ctx, orgId) || - initContextWithUserSessionCookie(ctx, orgId) || - initContextWithAnonymousUser(ctx) { + switch { + case initContextWithRenderAuth(ctx): + case initContextWithApiKey(ctx): + case initContextWithBasicAuth(ctx, orgId): + case initContextWithAuthProxy(ctx, orgId): + case initContextWithUserSessionCookie(ctx, orgId): + case initContextWithAnonymousUser(ctx): } ctx.Logger = log.New("context", "userId", ctx.UserId, "orgId", ctx.OrgId, "uname", ctx.Login) diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 67eb0f51d70..4f15441bb2f 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -121,7 +121,6 @@ func (pm *PluginManager) Run(ctx context.Context) error { pm.checkForUpdates() case <-ctx.Done(): run = false - break } } diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go index 627159c286b..2cdbc57b41d 100644 --- a/pkg/services/alerting/reader.go +++ b/pkg/services/alerting/reader.go @@ -34,11 +34,8 @@ func NewRuleReader() *DefaultRuleReader { func (arr *DefaultRuleReader) initReader() { heartbeat := time.NewTicker(time.Second * 10) - for { - select { - case <-heartbeat.C: - arr.heartbeat() - } + for range heartbeat.C { + arr.heartbeat() } } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 16158ded002..077822a37a4 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -13,15 +13,12 @@ import ( "regexp" "runtime" "strings" - - "gopkg.in/ini.v1" - - "github.com/go-macaron/session" - "time" + "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/util" + "gopkg.in/ini.v1" ) type Scheme string @@ -49,6 +46,7 @@ var ( // build BuildVersion string BuildCommit string + BuildBranch string BuildStamp int64 IsEnterprise bool ApplicationName string diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index b74af76f09a..718f9e0d253 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -35,6 +35,7 @@ type CustomMetricsCache struct { var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache +var regionCache sync.Map func init() { metricsMap = map[string][]string{ @@ -233,13 +234,20 @@ func parseMultiSelectValue(input string) []string { // Whenever this list is updated, frontend list should also be updated. // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + dsInfo := e.getDsInfo("default") + profile := dsInfo.Profile + if cache, ok := regionCache.Load(profile); ok { + if cache2, ok2 := cache.([]suggestData); ok2 { + return cache2, nil + } + } + regions := []string{ "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1", "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2", "cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1", } - - err := e.ensureClientSession("us-east-1") + err := e.ensureClientSession("default") if err != nil { return nil, err } @@ -269,6 +277,7 @@ func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *s for _, region := range regions { result = append(result, suggestData{Text: region, Value: region}) } + regionCache.Store(profile, result) return result, nil } diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index e3903e8027e..34c3379b4df 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -9,20 +9,26 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" "github.com/bmizerany/assert" + "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" ) type mockedEc2 struct { ec2iface.EC2API - Resp ec2.DescribeInstancesOutput + Resp ec2.DescribeInstancesOutput + RespRegions ec2.DescribeRegionsOutput } func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error { fn(&m.Resp, true) return nil } +func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeRegionsOutput, error) { + return &m.RespRegions, nil +} func TestCloudWatchMetrics(t *testing.T) { @@ -82,6 +88,31 @@ func TestCloudWatchMetrics(t *testing.T) { }) }) + Convey("When calling handleGetRegions", t, func() { + executor := &CloudWatchExecutor{ + ec2Svc: mockedEc2{RespRegions: ec2.DescribeRegionsOutput{ + Regions: []*ec2.Region{ + { + RegionName: aws.String("ap-northeast-2"), + }, + }, + }}, + } + jsonData := simplejson.New() + jsonData.Set("defaultRegion", "default") + executor.DataSource = &models.DataSource{ + JsonData: jsonData, + SecureJsonData: securejsondata.SecureJsonData{}, + } + + result, _ := executor.handleGetRegions(context.Background(), simplejson.New(), &tsdb.TsdbQuery{}) + + Convey("Should return regions", func() { + So(result[0].Text, ShouldEqual, "ap-northeast-1") + So(result[1].Text, ShouldEqual, "ap-northeast-2") + }) + }) + Convey("When calling handleGetEc2InstanceAttribute", t, func() { executor := &CloudWatchExecutor{ ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{ diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 2960ba0edc4..ff0ed8d0620 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -164,14 +164,12 @@ func formatTimeRange(input string) string { func fixIntervalFormat(target string) string { rMinute := regexp.MustCompile(`'(\d+)m'`) - rMin := regexp.MustCompile("m") target = rMinute.ReplaceAllStringFunc(target, func(m string) string { - return rMin.ReplaceAllString(m, "min") + return strings.Replace(m, "m", "min", -1) }) rMonth := regexp.MustCompile(`'(\d+)M'`) - rMon := regexp.MustCompile("M") target = rMonth.ReplaceAllStringFunc(target, func(M string) string { - return rMon.ReplaceAllString(M, "mon") + return strings.Replace(M, "M", "mon", -1) }) return target } diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index a037aa9277a..839f805568e 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -60,7 +60,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil + return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], m.timeRange.GetFromAsSecondsEpoch(), m.timeRange.GetToAsSecondsEpoch()), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index 3c9a5a26c94..24bf18873d5 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -60,7 +60,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { @@ -92,7 +92,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { @@ -112,7 +112,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { diff --git a/pkg/tsdb/stackdriver/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go index b33d33fb41c..2ad47cc4b83 100644 --- a/pkg/tsdb/stackdriver/stackdriver.go +++ b/pkg/tsdb/stackdriver/stackdriver.go @@ -186,8 +186,7 @@ func reverse(s string) string { } func interpolateFilterWildcards(value string) string { - re := regexp.MustCompile("[*]") - matches := len(re.FindAllStringIndex(value, -1)) + matches := strings.Count(value, "*") if matches == 2 && strings.HasSuffix(value, "*") && strings.HasPrefix(value, "*") { value = strings.Replace(value, "*", "", -1) value = fmt.Sprintf(`has_substring("%s")`, value) diff --git a/public/app/core/actions/appNotification.ts b/public/app/core/actions/appNotification.ts new file mode 100644 index 00000000000..b79b642eef1 --- /dev/null +++ b/public/app/core/actions/appNotification.ts @@ -0,0 +1,28 @@ +import { AppNotification } from 'app/types/'; + +export enum ActionTypes { + AddAppNotification = 'ADD_APP_NOTIFICATION', + ClearAppNotification = 'CLEAR_APP_NOTIFICATION', +} + +interface AddAppNotificationAction { + type: ActionTypes.AddAppNotification; + payload: AppNotification; +} + +interface ClearAppNotificationAction { + type: ActionTypes.ClearAppNotification; + payload: number; +} + +export type Action = AddAppNotificationAction | ClearAppNotificationAction; + +export const clearAppNotification = (appNotificationId: number) => ({ + type: ActionTypes.ClearAppNotification, + payload: appNotificationId, +}); + +export const notifyApp = (appNotification: AppNotification) => ({ + type: ActionTypes.AddAppNotification, + payload: appNotification, +}); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index 451a13dae99..f7ce2dda945 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -1,4 +1,5 @@ import { updateLocation } from './location'; import { updateNavIndex, UpdateNavIndexAction } from './navModel'; +import { notifyApp, clearAppNotification } from './appNotification'; -export { updateLocation, updateNavIndex, UpdateNavIndexAction }; +export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification }; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 6974d40aac8..7be28272f11 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -5,10 +5,12 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA'; import { SearchResult } from './components/search/SearchResult'; import { TagFilter } from './components/TagFilter/TagFilter'; import { SideMenu } from './components/sidemenu/SideMenu'; +import AppNotificationList from './components/AppNotifications/AppNotificationList'; export function registerAngularDirectives() { react2AngularDirective('passwordStrength', PasswordStrength, ['password']); react2AngularDirective('sidemenu', SideMenu, []); + react2AngularDirective('appNotificationsList', AppNotificationList, []); react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); react2AngularDirective('emptyListCta', EmptyListCTA, ['model']); react2AngularDirective('searchResult', SearchResult, []); diff --git a/public/app/core/components/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx new file mode 100644 index 00000000000..5169c39e7a0 --- /dev/null +++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx @@ -0,0 +1,38 @@ +import React, { Component } from 'react'; +import { AppNotification } from 'app/types'; + +interface Props { + appNotification: AppNotification; + onClearNotification: (id) => void; +} + +export default class AppNotificationItem extends Component { + shouldComponentUpdate(nextProps) { + return this.props.appNotification.id !== nextProps.appNotification.id; + } + + componentDidMount() { + const { appNotification, onClearNotification } = this.props; + setTimeout(() => { + onClearNotification(appNotification.id); + }, appNotification.timeout); + } + + render() { + const { appNotification, onClearNotification } = this.props; + return ( +
+
+ +
+
+
{appNotification.title}
+
{appNotification.text}
+
+ +
+ ); + } +} diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx new file mode 100644 index 00000000000..c91f8372384 --- /dev/null +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -0,0 +1,60 @@ +import React, { PureComponent } from 'react'; +import appEvents from 'app/core/app_events'; +import AppNotificationItem from './AppNotificationItem'; +import { notifyApp, clearAppNotification } from 'app/core/actions'; +import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; +import { AppNotification, StoreState } from 'app/types'; +import { + createErrorNotification, + createSuccessNotification, + createWarningNotification, +} from '../../copy/appNotification'; + +export interface Props { + appNotifications: AppNotification[]; + notifyApp: typeof notifyApp; + clearAppNotification: typeof clearAppNotification; +} + +export class AppNotificationList extends PureComponent { + componentDidMount() { + const { notifyApp } = this.props; + + appEvents.on('alert-warning', options => notifyApp(createWarningNotification(options[0], options[1]))); + appEvents.on('alert-success', options => notifyApp(createSuccessNotification(options[0], options[1]))); + appEvents.on('alert-error', options => notifyApp(createErrorNotification(options[0], options[1]))); + } + + onClearAppNotification = id => { + this.props.clearAppNotification(id); + }; + + render() { + const { appNotifications } = this.props; + + return ( +
+ {appNotifications.map((appNotification, index) => { + return ( + this.onClearAppNotification(id)} + /> + ); + })} +
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + appNotifications: state.appNotifications.appNotifications, +}); + +const mapDispatchToProps = { + notifyApp, + clearAppNotification, +}; + +export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps); diff --git a/public/app/core/components/Forms/Forms.tsx b/public/app/core/components/Label/Label.tsx similarity index 99% rename from public/app/core/components/Forms/Forms.tsx rename to public/app/core/components/Label/Label.tsx index 543e1a1d6df..6d4fd20dbfe 100644 --- a/public/app/core/components/Forms/Forms.tsx +++ b/public/app/core/components/Label/Label.tsx @@ -19,3 +19,4 @@ export const Label: SFC = props => { ); }; + diff --git a/public/app/core/components/Switch/Switch.tsx b/public/app/core/components/Switch/Switch.tsx new file mode 100644 index 00000000000..ba09267ebd2 --- /dev/null +++ b/public/app/core/components/Switch/Switch.tsx @@ -0,0 +1,46 @@ +import React, { PureComponent } from 'react'; +import _ from 'lodash'; + +export interface Props { + label: string; + checked: boolean; + labelClass?: string; + switchClass?: string; + onChange: (event) => any; +} + +export interface State { + id: any; +} + +export class Switch extends PureComponent { + state = { + id: _.uniqueId(), + }; + + internalOnChange = event => { + event.stopPropagation(); + this.props.onChange(event); + }; + + render() { + const { labelClass, switchClass, label, checked } = this.props; + const labelId = `check-${this.state.id}`; + const labelClassName = `gf-form-label ${labelClass} pointer`; + const switchClassName = `gf-form-switch ${switchClass}`; + + return ( +
+ {label && ( + + )} +
+ +
+
+ ); + } +} diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts new file mode 100644 index 00000000000..c34480d7aad --- /dev/null +++ b/public/app/core/copy/appNotification.ts @@ -0,0 +1,46 @@ +import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types'; + +const defaultSuccessNotification: AppNotification = { + title: '', + text: '', + severity: AppNotificationSeverity.Success, + icon: 'fa fa-check', + timeout: AppNotificationTimeout.Success, +}; + +const defaultWarningNotification: AppNotification = { + title: '', + text: '', + severity: AppNotificationSeverity.Warning, + icon: 'fa fa-exclamation', + timeout: AppNotificationTimeout.Warning, +}; + +const defaultErrorNotification: AppNotification = { + title: '', + text: '', + severity: AppNotificationSeverity.Error, + icon: 'fa fa-exclamation-triangle', + timeout: AppNotificationTimeout.Error, +}; + +export const createSuccessNotification = (title: string, text?: string): AppNotification => ({ + ...defaultSuccessNotification, + title: title, + text: text, + id: Date.now(), +}); + +export const createErrorNotification = (title: string, text?: string): AppNotification => ({ + ...defaultErrorNotification, + title: title, + text: text, + id: Date.now(), +}); + +export const createWarningNotification = (title: string, text?: string): AppNotification => ({ + ...defaultWarningNotification, + title: title, + text: text, + id: Date.now(), +}); diff --git a/public/app/core/reducers/appNotification.test.ts b/public/app/core/reducers/appNotification.test.ts new file mode 100644 index 00000000000..183b699f5fc --- /dev/null +++ b/public/app/core/reducers/appNotification.test.ts @@ -0,0 +1,51 @@ +import { appNotificationsReducer } from './appNotification'; +import { ActionTypes } from '../actions/appNotification'; +import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/'; + +describe('clear alert', () => { + it('should filter alert', () => { + const id1 = 1540301236048; + const id2 = 1540301248293; + + const initialState = { + appNotifications: [ + { + id: id1, + severity: AppNotificationSeverity.Success, + icon: 'success', + title: 'test', + text: 'test alert', + timeout: AppNotificationTimeout.Success, + }, + { + id: id2, + severity: AppNotificationSeverity.Warning, + icon: 'warning', + title: 'test2', + text: 'test alert fail 2', + timeout: AppNotificationTimeout.Warning, + }, + ], + }; + + const result = appNotificationsReducer(initialState, { + type: ActionTypes.ClearAppNotification, + payload: id2, + }); + + const expectedResult = { + appNotifications: [ + { + id: id1, + severity: AppNotificationSeverity.Success, + icon: 'success', + title: 'test', + text: 'test alert', + timeout: AppNotificationTimeout.Success, + }, + ], + }; + + expect(result).toEqual(expectedResult); + }); +}); diff --git a/public/app/core/reducers/appNotification.ts b/public/app/core/reducers/appNotification.ts new file mode 100644 index 00000000000..2c8bbbbd84d --- /dev/null +++ b/public/app/core/reducers/appNotification.ts @@ -0,0 +1,19 @@ +import { AppNotification, AppNotificationsState } from 'app/types/'; +import { Action, ActionTypes } from '../actions/appNotification'; + +export const initialState: AppNotificationsState = { + appNotifications: [] as AppNotification[], +}; + +export const appNotificationsReducer = (state = initialState, action: Action): AppNotificationsState => { + switch (action.type) { + case ActionTypes.AddAppNotification: + return { ...state, appNotifications: state.appNotifications.concat([action.payload]) }; + case ActionTypes.ClearAppNotification: + return { + ...state, + appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload), + }; + } + return state; +}; diff --git a/public/app/core/reducers/index.ts b/public/app/core/reducers/index.ts index be13528c91c..1c8670ed0d6 100644 --- a/public/app/core/reducers/index.ts +++ b/public/app/core/reducers/index.ts @@ -1,7 +1,9 @@ import { navIndexReducer as navIndex } from './navModel'; import { locationReducer as location } from './location'; +import { appNotificationsReducer as appNotifications } from './appNotification'; export default { navIndex, location, + appNotifications, }; diff --git a/public/app/core/services/alert_srv.ts b/public/app/core/services/alert_srv.ts index 2d447651b75..4995b148abd 100644 --- a/public/app/core/services/alert_srv.ts +++ b/public/app/core/services/alert_srv.ts @@ -1,100 +1,12 @@ -import angular from 'angular'; -import _ from 'lodash'; import coreModule from 'app/core/core_module'; -import appEvents from 'app/core/app_events'; export class AlertSrv { - list: any[]; + constructor() {} - /** @ngInject */ - constructor(private $timeout, private $rootScope) { - this.list = []; - } - - init() { - this.$rootScope.onAppEvent( - 'alert-error', - (e, alert) => { - this.set(alert[0], alert[1], 'error', 12000); - }, - this.$rootScope - ); - - this.$rootScope.onAppEvent( - 'alert-warning', - (e, alert) => { - this.set(alert[0], alert[1], 'warning', 5000); - }, - this.$rootScope - ); - - this.$rootScope.onAppEvent( - 'alert-success', - (e, alert) => { - this.set(alert[0], alert[1], 'success', 3000); - }, - this.$rootScope - ); - - appEvents.on('alert-warning', options => this.set(options[0], options[1], 'warning', 5000)); - appEvents.on('alert-success', options => this.set(options[0], options[1], 'success', 3000)); - appEvents.on('alert-error', options => this.set(options[0], options[1], 'error', 7000)); - } - - getIconForSeverity(severity) { - switch (severity) { - case 'success': - return 'fa fa-check'; - case 'error': - return 'fa fa-exclamation-triangle'; - default: - return 'fa fa-exclamation'; - } - } - - set(title, text, severity, timeout) { - if (_.isObject(text)) { - console.log('alert error', text); - if (text.statusText) { - text = `HTTP Error (${text.status}) ${text.statusText}`; - } - } - - const newAlert = { - title: title || '', - text: text || '', - severity: severity || 'info', - icon: this.getIconForSeverity(severity), - }; - - const newAlertJson = angular.toJson(newAlert); - - // remove same alert if it already exists - _.remove(this.list, value => { - return angular.toJson(value) === newAlertJson; - }); - - this.list.push(newAlert); - if (timeout > 0) { - this.$timeout(() => { - this.list = _.without(this.list, newAlert); - }, timeout); - } - - if (!this.$rootScope.$$phase) { - this.$rootScope.$digest(); - } - - return newAlert; - } - - clear(alert) { - this.list = _.without(this.list, alert); - } - - clearAll() { - this.list = []; + set() { + console.log('old depricated alert srv being used'); } } +// this is just added to not break old plugins that might be using it coreModule.service('alertSrv', AlertSrv); diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 3e8132a695b..144567efeb9 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -9,7 +9,7 @@ export class BackendSrv { private noBackendCache: boolean; /** @ngInject */ - constructor(private $http, private alertSrv, private $q, private $timeout, private contextSrv) {} + constructor(private $http, private $q, private $timeout, private contextSrv) {} get(url, params?) { return this.request({ method: 'GET', url: url, params: params }); @@ -49,14 +49,14 @@ export class BackendSrv { } if (err.status === 422) { - this.alertSrv.set('Validation failed', data.message, 'warning', 4000); + appEvents.emit('alert-warning', ['Validation failed', data.message]); throw data; } - data.severity = 'error'; + let severity = 'error'; if (err.status < 500) { - data.severity = 'warning'; + severity = 'warning'; } if (data.message) { @@ -66,7 +66,8 @@ export class BackendSrv { description = message; message = 'Error'; } - this.alertSrv.set(message, description, data.severity, 10000); + + appEvents.emit('alert-' + severity, [message, description]); } throw data; @@ -93,7 +94,7 @@ export class BackendSrv { if (options.method !== 'GET') { if (results && results.data.message) { if (options.showSuccessAlert !== false) { - this.alertSrv.set(results.data.message, '', 'success', 3000); + appEvents.emit('alert-success', [results.data.message]); } } } diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts index 2e35b87deb4..a6cb5a7d331 100644 --- a/public/app/core/specs/backend_srv.test.ts +++ b/public/app/core/specs/backend_srv.test.ts @@ -9,7 +9,7 @@ describe('backend_srv', () => { return Promise.resolve({}); }; - const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}, {}); + const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}); describe('when handling errors', () => { it('should return the http status code', async () => { diff --git a/public/app/core/utils/connectWithReduxStore.tsx b/public/app/core/utils/connectWithReduxStore.tsx new file mode 100644 index 00000000000..92c61db4e77 --- /dev/null +++ b/public/app/core/utils/connectWithReduxStore.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { store } from '../../store/configureStore'; + +export function connectWithStore(WrappedComponent, ...args) { + const ConnectedWrappedComponent = connect(...args)(WrappedComponent); + + return props => { + return ; + }; +} diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index 19850da52d9..4fe6e10b2cf 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -1,25 +1,32 @@ -import './editor_ctrl'; - +// Libaries import angular from 'angular'; import _ from 'lodash'; + +// Components +import './editor_ctrl'; import coreModule from 'app/core/core_module'; + +// Utils & Services import { makeRegions, dedupAnnotations } from './events_processing'; +// Types +import { DashboardModel } from '../dashboard/dashboard_model'; + export class AnnotationsSrv { globalAnnotationsPromise: any; alertStatesPromise: any; datasourcePromises: any; /** @ngInject */ - constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) { - $rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope); - $rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope); - } + constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {} - clearCache() { - this.globalAnnotationsPromise = null; - this.alertStatesPromise = null; - this.datasourcePromises = null; + init(dashboard: DashboardModel) { + // clear promises on refresh events + dashboard.on('refresh', () => { + this.globalAnnotationsPromise = null; + this.alertStatesPromise = null; + this.datasourcePromises = null; + }); } getAnnotations(options) { diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index c34b9ddaff2..5871a579f3c 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -1,6 +1,12 @@ +// Utils import config from 'app/core/config'; - +import appEvents from 'app/core/app_events'; import coreModule from 'app/core/core_module'; + +// Services +import { AnnotationsSrv } from '../annotations/annotations_srv'; + +// Types import { DashboardModel } from './dashboard_model'; import { PanelModel } from './panel_model'; @@ -21,6 +27,7 @@ export class DashboardCtrl { private dashboardSrv, private unsavedChangesSrv, private dashboardViewStateSrv, + private annotationsSrv: AnnotationsSrv, public playlistSrv ) { // temp hack due to way dashboards are loaded @@ -49,6 +56,7 @@ export class DashboardCtrl { // init services this.timeSrv.init(dashboard); this.alertingSrv.init(dashboard, data.alerts); + this.annotationsSrv.init(dashboard); // template values service needs to initialize completely before // the rest of the dashboard can load @@ -72,7 +80,7 @@ export class DashboardCtrl { this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); this.setWindowTitleAndTheme(); - this.$scope.appEvent('dashboard-initialized', dashboard); + appEvents.emit('dashboard-initialized', dashboard); }) .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); } diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 1f5fa4cbe12..fe55e64634f 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -21,15 +21,14 @@ function GridWrapper({ className, isResizable, isDraggable, + isFullscreen, }) { - if (size.width === 0) { - console.log('size is zero!'); - } - const width = size.width > 0 ? size.width : lastGridWidth; if (width !== lastGridWidth) { - onWidthChange(); - lastGridWidth = width; + if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) { + onWidthChange(); + lastGridWidth = width; + } } return ( @@ -197,6 +196,7 @@ export class DashboardGrid extends React.Component { onDragStop={this.onDragStop} onResize={this.onResize} onResizeStop={this.onResizeStop} + isFullscreen={this.props.dashboard.meta.fullscreen} > {this.renderPanels()} diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/permissions/DashboardPermissions.tsx index 5651242a485..c07bef42930 100644 --- a/public/app/features/dashboard/permissions/DashboardPermissions.tsx +++ b/public/app/features/dashboard/permissions/DashboardPermissions.tsx @@ -1,5 +1,4 @@ import React, { PureComponent } from 'react'; -import { connect } from 'react-redux'; import Tooltip from 'app/core/components/Tooltip/Tooltip'; import SlideDown from 'app/core/components/Animations/SlideDown'; import { StoreState, FolderInfo } from 'app/types'; @@ -13,7 +12,7 @@ import { import PermissionList from 'app/core/components/PermissionList/PermissionList'; import AddPermission from 'app/core/components/PermissionList/AddPermission'; import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; -import { store } from 'app/store/configureStore'; +import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; export interface Props { dashboardId: number; @@ -95,13 +94,6 @@ export class DashboardPermissions extends PureComponent { } } -function connectWithStore(WrappedComponent, ...args) { - const ConnectedWrappedComponent = connect(...args)(WrappedComponent); - return props => { - return ; - }; -} - const mapStateToProps = (state: StoreState) => ({ permissions: state.dashboard.permissions, }); diff --git a/public/app/features/dashboard/upload.ts b/public/app/features/dashboard/upload.ts index 42871327eb6..ec4ad9a03cb 100644 --- a/public/app/features/dashboard/upload.ts +++ b/public/app/features/dashboard/upload.ts @@ -11,7 +11,7 @@ const template = ` `; /** @ngInject */ -function uploadDashboardDirective(timer, alertSrv, $location) { +function uploadDashboardDirective(timer, $location) { return { restrict: 'E', template: template, @@ -59,7 +59,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) { // Something elem[0].addEventListener('change', file_selected, false); } else { - alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error'); + appEvents.emit('alert-error', ['Oops', 'The HTML5 File APIs are not fully supported in this browser']); } }, }; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index bac063116f1..f29d38d283a 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -373,9 +373,10 @@ export class Explore extends React.PureComponent { this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue }); }; - onModifyQueries = (action: object, index?: number) => { + onModifyQueries = (action, index?: number) => { const { datasource } = this.state; if (datasource && datasource.modifyQuery) { + const preventSubmit = action.preventSubmit; this.setState( state => { const { queries, queryTransactions } = state; @@ -391,16 +392,26 @@ export class Explore extends React.PureComponent { nextQueryTransactions = []; } else { // Modify query only at index - nextQueries = [ - ...queries.slice(0, index), - { - key: generateQueryKey(index), - query: datasource.modifyQuery(this.queryExpressions[index], action), - }, - ...queries.slice(index + 1), - ]; - // Discard transactions related to row query - nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + nextQueries = queries.map((q, i) => { + // Synchronise all queries with local query cache to ensure consistency + q.query = this.queryExpressions[i]; + return i === index + ? { + key: generateQueryKey(index), + query: datasource.modifyQuery(q.query, action), + } + : q; + }); + nextQueryTransactions = queryTransactions + // Consume the hint corresponding to the action + .map(qt => { + if (qt.hints != null && qt.rowIndex === index) { + qt.hints = qt.hints.filter(hint => hint.fix.action !== action); + } + return qt; + }) + // 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); return { @@ -408,7 +419,8 @@ export class Explore extends React.PureComponent { queryTransactions: nextQueryTransactions, }; }, - () => this.onSubmit() + // Accepting certain fixes do not result in a well-formed query which should not be submitted + !preventSubmit ? () => this.onSubmit() : null ); } }; @@ -695,11 +707,6 @@ export class Explore extends React.PureComponent { }); } - request = url => { - const { datasource } = this.state; - return datasource.metadataRequest(url); - }; - cloneState(): ExploreState { // Copy state, but copy queries including modifications return { @@ -831,9 +838,9 @@ export class Explore extends React.PureComponent { {datasource && !datasourceError ? (
{ + it('does nothing if no placeholders are defined', () => { + const text = 'metric'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe(text); + expect(buffer.getNextMoveOffset()).toBe(0); + }); + + it('respects the traversal order of placeholders', () => { + const text = 'sum($2 offset $1) by ($3)'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum( offset ) by ()'); + expect(buffer.getNextMoveOffset()).toBe(12); + + buffer.setNextPlaceholderValue('1h'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum( offset 1h) by ()'); + expect(buffer.getNextMoveOffset()).toBe(-10); + + buffer.setNextPlaceholderValue('metric'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum(metric offset 1h) by ()'); + expect(buffer.getNextMoveOffset()).toBe(16); + + buffer.setNextPlaceholderValue('label'); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)'); + expect(buffer.getNextMoveOffset()).toBe(0); + }); + + it('respects the traversal order of adjacent placeholders', () => { + const text = '$1$3$2$4'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe(''); + expect(buffer.getNextMoveOffset()).toBe(0); + + buffer.setNextPlaceholderValue('1'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('1'); + expect(buffer.getNextMoveOffset()).toBe(0); + + buffer.setNextPlaceholderValue('2'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('12'); + expect(buffer.getNextMoveOffset()).toBe(-1); + + buffer.setNextPlaceholderValue('3'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('132'); + expect(buffer.getNextMoveOffset()).toBe(1); + + buffer.setNextPlaceholderValue('4'); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe('1324'); + expect(buffer.getNextMoveOffset()).toBe(0); + }); +}); diff --git a/public/app/features/explore/PlaceholdersBuffer.ts b/public/app/features/explore/PlaceholdersBuffer.ts new file mode 100644 index 00000000000..9a0db18ef04 --- /dev/null +++ b/public/app/features/explore/PlaceholdersBuffer.ts @@ -0,0 +1,112 @@ +/** + * Provides a stateful means of managing placeholders in text. + * + * Placeholders are numbers prefixed with the `$` character (e.g. `$1`). + * Each number value represents the order in which a placeholder should + * receive focus if multiple placeholders exist. + * + * Example scenario given `sum($3 offset $1) by($2)`: + * 1. `sum( offset |) by()` + * 2. `sum( offset 1h) by(|)` + * 3. `sum(| offset 1h) by (label)` + */ +export default class PlaceholdersBuffer { + private nextMoveOffset: number; + private orders: number[]; + private parts: string[]; + + constructor(text: string) { + const result = this.parse(text); + const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0; + this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex); + this.orders = result.orders; + this.parts = result.parts; + } + + clearPlaceholders() { + this.nextMoveOffset = 0; + this.orders = []; + } + + getNextMoveOffset(): number { + return this.nextMoveOffset; + } + + hasPlaceholders(): boolean { + return this.orders.length > 0; + } + + setNextPlaceholderValue(value: string) { + if (this.orders.length === 0) { + return; + } + const currentPlaceholderIndex = this.orders[0]; + this.parts[currentPlaceholderIndex] = value; + this.orders = this.orders.slice(1); + if (this.orders.length === 0) { + this.nextMoveOffset = 0; + return; + } + const nextPlaceholderIndex = this.orders[0]; + // Case should never happen but handle it gracefully in case + if (currentPlaceholderIndex === nextPlaceholderIndex) { + this.nextMoveOffset = 0; + return; + } + const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex; + const indices = backwardMove + ? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 } + : { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex }; + this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end); + } + + toString(): string { + return this.parts.join(''); + } + + private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) { + return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0); + } + + private parse(text: string): ParseResult { + const placeholderRegExp = /\$(\d+)/g; + const parts = []; + const orders = []; + let textOffset = 0; + while (true) { + const match = placeholderRegExp.exec(text); + if (!match) { + break; + } + const part = text.slice(textOffset, match.index); + parts.push(part); + // Accounts for placeholders at text boundaries + if (part !== '') { + parts.push(''); + } + const order = parseInt(match[1], 10); + orders.push({ index: parts.length - 1, order }); + textOffset += part.length + match.length; + } + // Ensures string serialisation still works if no placeholders were parsed + // and also accounts for the remainder of text with placeholders + parts.push(text.slice(textOffset)); + return { + // Placeholder values do not necessarily appear sequentially so sort the + // indices to traverse in priority order + orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index), + parts, + }; + } +} + +type ParseResult = { + /** + * Indices to placeholder items in `parts` in traversal order. + */ + orders: number[]; + /** + * Parts comprising the original text with placeholders occupying distinct items. + */ + parts: string[]; +}; diff --git a/public/app/features/explore/PromQueryField.test.tsx b/public/app/features/explore/PromQueryField.test.tsx index 802bb695f2c..bc7eb301be1 100644 --- a/public/app/features/explore/PromQueryField.test.tsx +++ b/public/app/features/explore/PromQueryField.test.tsx @@ -1,231 +1,4 @@ -import React from 'react'; -import Enzyme, { shallow } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Plain from 'slate-plain-serializer'; - -import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField'; - -Enzyme.configure({ adapter: new Adapter() }); - -describe('PromQueryField typeahead handling', () => { - const defaultProps = { - request: () => ({ data: { data: [] } }), - }; - - it('returns default suggestions on emtpty context', () => { - const instance = shallow().instance() as PromQueryField; - const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - - describe('range suggestions', () => { - it('returns range suggestions in range context', () => { - const instance = shallow().instance() as PromQueryField; - const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] }); - expect(result.context).toBe('context-range'); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions).toEqual([ - { - items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }], - label: 'Range vector', - }, - ]); - }); - }); - - describe('metric suggestions', () => { - it('returns metrics suggestions by default', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - - it('returns default suggestions after a binary operator', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - }); - - describe('label suggestions', () => { - it('returns default label suggestions on label context and no metric', () => { - const instance = shallow().instance() as PromQueryField; - const value = Plain.deserialize('{}'); - const range = value.selection.merge({ - anchorOffset: 1, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); - }); - - it('returns label suggestions on label context and metric', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label suggestions on label context but leaves out labels that already exist', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}'); - const range = value.selection.merge({ - anchorOffset: 36, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label value suggestions inside a label value context after a negated matching operator', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('{label!=}'); - const range = value.selection.merge({ anchorOffset: 8 }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '!=', - prefix: '', - wrapperClasses: ['context-labels'], - labelKey: 'label', - value: valueWithSelection, - }); - expect(result.context).toBe('context-label-values'); - expect(result.suggestions).toEqual([ - { - items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }], - label: 'Label values for "label"', - }, - ]); - }); - - it('returns a refresher on label context and unavailable metric', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeInstanceOf(Promise); - expect(result.suggestions).toEqual([]); - }); - - it('returns label values on label context when given a metric and a label key', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{bar=ba}'); - const range = value.selection.merge({ - anchorOffset: 13, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '=ba', - prefix: 'ba', - wrapperClasses: ['context-labels'], - labelKey: 'bar', - value: valueWithSelection, - }); - expect(result.context).toBe('context-label-values'); - expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); - }); - - it('returns label suggestions on aggregation context and metric w/ selector', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('sum(metric{foo="xx"}) by ()'); - const range = value.selection.merge({ - anchorOffset: 26, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-aggregation'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label suggestions on aggregation context and metric w/o selector', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('sum(metric) by ()'); - const range = value.selection.merge({ - anchorOffset: 16, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-aggregation'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - }); -}); +import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField'; describe('groupMetricsByPrefix()', () => { it('returns an empty group for no metrics', () => { diff --git a/public/app/features/explore/PromQueryField.tsx b/public/app/features/explore/PromQueryField.tsx index 442e51af987..58856e3116f 100644 --- a/public/app/features/explore/PromQueryField.tsx +++ b/public/app/features/explore/PromQueryField.tsx @@ -1,67 +1,23 @@ import _ from 'lodash'; -import moment from 'moment'; import React from 'react'; -import { Value } from 'slate'; import Cascader from 'rc-cascader'; import PluginPrism from 'slate-prism'; import Prism from 'prismjs'; +import { TypeaheadOutput } from 'app/types/explore'; + // dom also includes Element polyfills import { getNextCharacter, getPreviousCousin } from './utils/dom'; -import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql'; import BracesPlugin from './slate-plugins/braces'; import RunnerPlugin from './slate-plugins/runner'; -import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus'; -import TypeaheadField, { - Suggestion, - SuggestionGroup, - TypeaheadInput, - TypeaheadFieldState, - TypeaheadOutput, -} from './QueryField'; +import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField'; -const DEFAULT_KEYS = ['job', 'instance']; -const EMPTY_SELECTOR = '{}'; const HISTOGRAM_GROUP = '__histograms__'; -const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms -const HISTORY_ITEM_COUNT = 5; -const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h const METRIC_MARK = 'metric'; const PRISM_SYNTAX = 'promql'; export const RECORDING_RULES_GROUP = '__recording_rules__'; -export const wrapLabel = (label: string) => ({ label }); -export const setFunctionMove = (suggestion: Suggestion): Suggestion => { - suggestion.move = -1; - return suggestion; -}; - -// Syntax highlighting -Prism.languages[PRISM_SYNTAX] = PrismPromql; -function setPrismTokens(language, field, values, alias = 'variable') { - Prism.languages[language][field] = { - alias, - pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`), - }; -} - -export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion { - const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; - const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label); - const count = historyForItem.length; - const recent = historyForItem[0]; - let hint = `Queried ${count} times in the last 24h.`; - if (recent) { - const lastQueried = moment(recent.ts).fromNow(); - hint = `${hint} Last queried ${lastQueried}.`; - } - return { - ...item, - documentation: hint, - }; -} - export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] { // Filter out recording rules and insert as first option const ruleRegex = /:\w+:/; @@ -133,48 +89,36 @@ interface CascaderOption { } interface PromQueryFieldProps { + datasource: any; error?: string; hint?: any; - histogramMetrics?: string[]; history?: any[]; initialQuery?: string | null; - labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] - labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] - metrics?: string[]; metricsByPrefix?: CascaderOption[]; onClickHintFix?: (action: any) => void; onPressEnter?: () => void; onQueryChange?: (value: string, override?: boolean) => void; - portalOrigin?: string; - request?: (url: string) => any; supportsLogs?: boolean; // To be removed after Logging gets its own query field } interface PromQueryFieldState { - histogramMetrics: string[]; - labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...] - labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] logLabelOptions: any[]; - metrics: string[]; metricsOptions: any[]; metricsByPrefix: CascaderOption[]; syntaxLoaded: boolean; } -interface PromTypeaheadInput { - text: string; - prefix: string; - wrapperClasses: string[]; - labelKey?: string; - value?: Value; -} - class PromQueryField extends React.PureComponent { plugins: any[]; + languageProvider: any; constructor(props: PromQueryFieldProps, context) { super(props, context); + if (props.datasource.languageProvider) { + this.languageProvider = props.datasource.languageProvider; + } + this.plugins = [ BracesPlugin(), RunnerPlugin({ handler: props.onPressEnter }), @@ -185,26 +129,16 @@ class PromQueryField extends React.PureComponent this.onReceiveMetrics()); } } @@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent { - const { histogramMetrics, metrics, metricsByPrefix } = this.state; + const { histogramMetrics, metrics } = this.languageProvider; if (!metrics) { return; } - // Update global prism config - setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics); + Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax(); + Prism.languages[PRISM_SYNTAX][METRIC_MARK] = { + alias: 'variable', + pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`), + }; // Build metrics tree + const metricsByPrefix = groupMetricsByPrefix(metrics); const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm })); const metricsOptions = [ { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions }, @@ -281,6 +219,11 @@ class PromQueryField extends React.PureComponent { + if (!this.languageProvider) { + return { suggestions: [] }; + } + + const { history } = this.props; const { prefix, text, value, wrapperNode } = typeahead; // Get DOM-dependent context @@ -289,279 +232,20 @@ class PromQueryField extends React.PureComponent 3; - // Determine candidates by CSS context - if (_.includes(wrapperClasses, 'context-range')) { - // Suggestions for metric[|] - return this.getRangeTypeahead(); - } else if (_.includes(wrapperClasses, 'context-labels')) { - // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} - return this.getLabelTypeahead.apply(this, arguments); - } else if (_.includes(wrapperClasses, 'context-aggregation')) { - return this.getAggregationTypeahead.apply(this, arguments); - } else if ( - // Show default suggestions in a couple of scenarios - (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token - (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace - text.match(/[+\-*/^%]/) // Anything after binary operator - ) { - return this.getEmptyTypeahead(); - } - - return { - suggestions: [], - }; - } - - getEmptyTypeahead(): TypeaheadOutput { - const { history } = this.props; - const { metrics } = this.state; - const suggestions: SuggestionGroup[] = []; - - if (history && history.length > 0) { - const historyItems = _.chain(history) - .uniqBy('query') - .take(HISTORY_ITEM_COUNT) - .map(h => h.query) - .map(wrapLabel) - .map(item => addHistoryMetadata(item, history)) - .value(); - - suggestions.push({ - prefixMatch: true, - skipSort: true, - label: 'History', - items: historyItems, - }); - } - - suggestions.push({ - prefixMatch: true, - label: 'Functions', - items: FUNCTIONS.map(setFunctionMove), - }); - - if (metrics) { - suggestions.push({ - label: 'Metrics', - items: metrics.map(wrapLabel), - }); - } - return { suggestions }; - } - - getRangeTypeahead(): TypeaheadOutput { - return { - context: 'context-range', - suggestions: [ - { - label: 'Range vector', - items: [...RATE_RANGES].map(wrapLabel), - }, - ], - }; - } - - getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput { - let refresher: Promise = null; - const suggestions: SuggestionGroup[] = []; - - // sum(foo{bar="1"}) by (|) - const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; - // sum(foo{bar="1"}) by ( - const leftSide = line.slice(0, cursorOffset); - const openParensAggregationIndex = leftSide.lastIndexOf('('); - const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('('); - const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex; - // foo{bar="1"} - const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); - const selector = parseSelector(selectorString, selectorString.length - 2).selector; - - const labelKeys = this.state.labelKeys[selector]; - if (labelKeys) { - suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) }); - } else { - refresher = this.fetchSeriesLabels(selector); - } - - return { - refresher, - suggestions, - context: 'context-aggregation', - }; - } - - getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput { - let context: string; - let refresher: Promise = null; - const suggestions: SuggestionGroup[] = []; - const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; - - // Get normalized selector - let selector; - let parsedSelector; - try { - parsedSelector = parseSelector(line, cursorOffset); - selector = parsedSelector.selector; - } catch { - selector = EMPTY_SELECTOR; - } - const containsMetric = selector.indexOf('__name__=') > -1; - const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; - - if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { - // Label values - if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) { - const labelValues = this.state.labelValues[selector][labelKey]; - context = 'context-label-values'; - suggestions.push({ - label: `Label values for "${labelKey}"`, - items: labelValues.map(wrapLabel), - }); - } - } else { - // Label keys - const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS); - if (labelKeys) { - const possibleKeys = _.difference(labelKeys, existingKeys); - if (possibleKeys.length > 0) { - context = 'context-labels'; - suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) }); - } - } - } - - // Query labels for selector - // Temporarily add skip for logging - if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) { - if (selector === EMPTY_SELECTOR) { - // Query label values for default labels - refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); - } else { - refresher = this.fetchSeriesLabels(selector, !containsMetric); - } - } - - return { context, refresher, suggestions }; - } - - request = url => { - if (this.props.request) { - return this.props.request(url); - } - return fetch(url); - }; - - fetchHistogramMetrics() { - this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => { - const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR]; - if (histogramSeries && histogramSeries['__name__']) { - const histogramMetrics = histogramSeries['__name__'].slice().sort(); - this.setState({ histogramMetrics }, this.onReceiveMetrics); - } - }); - } - - // Temporarily here while reusing this field for logging - async fetchLogLabels() { - const url = '/api/prom/label'; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const labelKeys = body.data.slice().sort(); - const labelKeysBySelector = { - ...this.state.labelKeys, - [EMPTY_SELECTOR]: labelKeys, - }; - const labelValuesByKey = {}; - const logLabelOptions = []; - for (const key of labelKeys) { - const valuesUrl = `/api/prom/label/${key}/values`; - const res = await this.request(valuesUrl); - const body = await (res.data || res.json()); - const values = body.data.slice().sort(); - labelValuesByKey[key] = values; - logLabelOptions.push({ - label: key, - value: key, - children: values.map(value => ({ label: value, value })), - }); - } - const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey }; - this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions }); - } catch (e) { - console.error(e); - } - } - - async fetchLabelValues(key: string) { - const url = `/api/v1/label/${key}/values`; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const exisingValues = this.state.labelValues[EMPTY_SELECTOR]; - const values = { - ...exisingValues, - [key]: body.data, - }; - const labelValues = { - ...this.state.labelValues, - [EMPTY_SELECTOR]: values, - }; - this.setState({ labelValues }); - } catch (e) { - console.error(e); - } - } - - async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) { - const url = `/api/v1/series?match[]=${name}`; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const { keys, values } = processLabels(body.data, withName); - const labelKeys = { - ...this.state.labelKeys, - [name]: keys, - }; - const labelValues = { - ...this.state.labelValues, - [name]: values, - }; - this.setState({ labelKeys, labelValues }, callback); - } catch (e) { - console.error(e); - } - } - - async fetchMetricNames() { - const url = '/api/v1/label/__name__/values'; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const metrics = body.data; - const metricsByPrefix = groupMetricsByPrefix(metrics); - this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics); - } catch (error) { - console.error(error); - } - } - render() { const { error, hint, initialQuery, supportsLogs } = this.props; const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state; + const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined; return (
diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index ce0bcd71ed0..9350682f1a0 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -5,95 +5,28 @@ import { Change, Value } from 'slate'; import { Editor } from 'slate-react'; import Plain from 'slate-plain-serializer'; +import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; + import ClearPlugin from './slate-plugins/clear'; import NewlinePlugin from './slate-plugins/newline'; import Typeahead from './Typeahead'; import { makeFragment, makeValue } from './Value'; +import PlaceholdersBuffer from './PlaceholdersBuffer'; export const TYPEAHEAD_DEBOUNCE = 100; -function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion { +function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem { // Flatten suggestion groups const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []); const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length; return flattenedSuggestions[correctedIndex]; } -function hasSuggestions(suggestions: SuggestionGroup[]): boolean { +function hasSuggestions(suggestions: CompletionItemGroup[]): boolean { return suggestions && suggestions.length > 0; } -export interface Suggestion { - /** - * The label of this completion item. By default - * this is also the text that is inserted when selecting - * this completion. - */ - label: string; - /** - * The kind of this completion item. Based on the kind - * an icon is chosen by the editor. - */ - kind?: string; - /** - * A human-readable string with additional information - * about this item, like type or symbol information. - */ - detail?: string; - /** - * A human-readable string, can be Markdown, that represents a doc-comment. - */ - documentation?: string; - /** - * A string that should be used when comparing this item - * with other items. When `falsy` the `label` is used. - */ - sortText?: string; - /** - * A string that should be used when filtering a set of - * completion items. When `falsy` the `label` is used. - */ - filterText?: string; - /** - * A string or snippet that should be inserted in a document when selecting - * this completion. When `falsy` the `label` is used. - */ - insertText?: string; - /** - * Delete number of characters before the caret position, - * by default the letters from the beginning of the word. - */ - deleteBackwards?: number; - /** - * Number of steps to move after the insertion, can be negative. - */ - move?: number; -} - -export interface SuggestionGroup { - /** - * Label that will be displayed for all entries of this group. - */ - label: string; - /** - * List of suggestions of this group. - */ - items: Suggestion[]; - /** - * If true, match only by prefix (and not mid-word). - */ - prefixMatch?: boolean; - /** - * If true, do not filter items in this group based on the search. - */ - skipFilter?: boolean; - /** - * If true, do not sort items. - */ - skipSort?: boolean; -} - interface TypeaheadFieldProps { additionalPlugins?: any[]; cleanText?: (text: string) => string; @@ -110,7 +43,7 @@ interface TypeaheadFieldProps { } export interface TypeaheadFieldState { - suggestions: SuggestionGroup[]; + suggestions: CompletionItemGroup[]; typeaheadContext: string | null; typeaheadIndex: number; typeaheadPrefix: string; @@ -127,20 +60,17 @@ export interface TypeaheadInput { wrapperNode: Element; } -export interface TypeaheadOutput { - context?: string; - refresher?: Promise<{}>; - suggestions: SuggestionGroup[]; -} - class QueryField extends React.PureComponent { menuEl: HTMLElement | null; + placeholdersBuffer: PlaceholdersBuffer; plugins: any[]; resetTimer: any; constructor(props, context) { super(props, context); + this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || ''); + // Base plugins this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins]; @@ -150,7 +80,7 @@ class QueryField extends React.PureComponent operation.type === 'insert_text'); + if (insertTextOperation) { + const suggestionText = insertTextOperation.text; + this.placeholdersBuffer.setNextPlaceholderValue(suggestionText); + if (this.placeholdersBuffer.hasPlaceholders()) { + nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus(); + } + } + return true; } break; @@ -410,6 +352,8 @@ class QueryField extends React.PureComponent { + onClickMenu = (item: CompletionItem) => { // Manually triggering change const change = this.applyTypeahead(this.state.value.change(), item); this.onChange(change); diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 0b0d7085d2d..aaa7dfcd20b 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -1,12 +1,12 @@ import React, { PureComponent } from 'react'; -import { QueryTransaction } from 'app/types/explore'; +import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore'; // TODO make this datasource-plugin-dependent import QueryField from './PromQueryField'; import QueryTransactions from './QueryTransactions'; -function getFirstHintFromTransactions(transactions: QueryTransaction[]) { +function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); if (transaction) { return transaction.hints[0]; @@ -14,7 +14,30 @@ function getFirstHintFromTransactions(transactions: QueryTransaction[]) { return undefined; } -class QueryRow extends PureComponent { +interface QueryRowEventHandlers { + onAddQueryRow: (index: number) => void; + onChangeQuery: (value: string, index: number, override?: boolean) => void; + onClickHintFix: (action: object, index?: number) => void; + onExecuteQuery: () => void; + onRemoveQueryRow: (index: number) => void; +} + +interface QueryRowCommonProps { + className?: string; + datasource: any; + history: HistoryItem[]; + // Temporarily + supportsLogs?: boolean; + transactions: QueryTransaction[]; +} + +type QueryRowProps = QueryRowCommonProps & + QueryRowEventHandlers & { + index: number; + query: string; + }; + +class QueryRow extends PureComponent { onChangeQuery = (value, override?: boolean) => { const { index, onChangeQuery } = this.props; if (onChangeQuery) { @@ -55,8 +78,8 @@ class QueryRow extends PureComponent { }; render() { - const { history, query, request, supportsLogs, transactions } = this.props; - const transactionWithError = transactions.find(t => t.error); + const { datasource, history, query, supportsLogs, transactions } = this.props; + const transactionWithError = transactions.find(t => t.error !== undefined); const hint = getFirstHintFromTransactions(transactions); const queryError = transactionWithError ? transactionWithError.error : null; return ( @@ -66,6 +89,7 @@ class QueryRow extends PureComponent {
{ onClickHintFix={this.onClickHintFix} onPressEnter={this.onPressEnter} onQueryChange={this.onChangeQuery} - request={request} supportsLogs={supportsLogs} />
@@ -93,9 +116,14 @@ class QueryRow extends PureComponent { } } -export default class QueryRows extends PureComponent { +type QueryRowsProps = QueryRowCommonProps & + QueryRowEventHandlers & { + queries: Query[]; + }; + +export default class QueryRows extends PureComponent { render() { - const { className = '', queries, queryHints, transactions, ...handlers } = this.props; + const { className = '', queries, transactions, ...handlers } = this.props; return (
{queries.map((q, index) => ( diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index 0c01cbe01ba..13882e030f6 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/public/app/features/explore/Typeahead.tsx @@ -1,7 +1,7 @@ import React from 'react'; import Highlighter from 'react-highlight-words'; -import { Suggestion, SuggestionGroup } from './QueryField'; +import { CompletionItem, CompletionItemGroup } from 'app/types/explore'; function scrollIntoView(el: HTMLElement) { if (!el || !el.offsetParent) { @@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) { interface TypeaheadItemProps { isSelected: boolean; - item: Suggestion; + item: CompletionItem; onClickItem: (Suggestion) => void; prefix?: string; } -class TypeaheadItem extends React.PureComponent { +class TypeaheadItem extends React.PureComponent { el: HTMLElement; componentDidUpdate(prevProps) { @@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent { } interface TypeaheadGroupProps { - items: Suggestion[]; + items: CompletionItem[]; label: string; - onClickItem: (Suggestion) => void; - selected: Suggestion; + onClickItem: (CompletionItem) => void; + selected: CompletionItem; prefix?: string; } -class TypeaheadGroup extends React.PureComponent { +class TypeaheadGroup extends React.PureComponent { render() { const { items, label, selected, onClickItem, prefix } = this.props; return ( @@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent { } interface TypeaheadProps { - groupedItems: SuggestionGroup[]; + groupedItems: CompletionItemGroup[]; menuRef: any; - selectedItem: Suggestion | null; + selectedItem: CompletionItem | null; onClickItem: (Suggestion) => void; prefix?: string; } -class Typeahead extends React.PureComponent { +class Typeahead extends React.PureComponent { render() { const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props; return ( diff --git a/public/app/features/panel/solo_panel_ctrl.ts b/public/app/features/panel/solo_panel_ctrl.ts index 15d35188d6d..a8bf5371913 100644 --- a/public/app/features/panel/solo_panel_ctrl.ts +++ b/public/app/features/panel/solo_panel_ctrl.ts @@ -14,7 +14,7 @@ export class SoloPanelCtrl { const params = $location.search(); panelId = parseInt(params.panelId, 10); - $scope.onAppEvent('dashboard-initialized', $scope.initPanelScope); + appEvents.on('dashboard-initialized', $scope.initPanelScope); // if no uid, redirect to new route based on slug if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) { diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index ef9a5ae0b70..45977de95bf 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Label } from 'app/core/components/Forms/Forms'; +import { Label } from 'app/core/components/Label/Label'; import { Team } from '../../types'; import { updateTeam } from './state/actions'; import { getRouteParamsId } from '../../core/selectors/location'; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index b4f739f934c..3eb8eff2d09 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -37,6 +37,9 @@ export default class CloudWatchDatasource { item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars); item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars); item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars); + item.statistics = item.statistics.map(s => { + return this.templateSrv.replace(s, options.scopedVars); + }); item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting item.id = this.templateSrv.replace(item.id, options.scopedVars); item.expression = this.templateSrv.replace(item.expression, options.scopedVars); diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 89f88a946c2..8514b6ca7d4 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -5,6 +5,7 @@ import kbn from 'app/core/utils/kbn'; import * as dateMath from 'app/core/utils/datemath'; import PrometheusMetricFindQuery from './metric_find_query'; import { ResultTransformer } from './result_transformer'; +import PrometheusLanguageProvider from './language_provider'; import { BackendSrv } from 'app/core/services/backend_srv'; import addLabelToQuery from './add_label_to_query'; @@ -60,6 +61,7 @@ export class PrometheusDatasource { interval: string; queryTimeout: string; httpMethod: string; + languageProvider: PrometheusLanguageProvider; resultTransformer: ResultTransformer; /** @ngInject */ @@ -76,6 +78,7 @@ export class PrometheusDatasource { this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET'; this.resultTransformer = new ResultTransformer(templateSrv); this.ruleMappings = {}; + this.languageProvider = new PrometheusLanguageProvider(this); } init() { @@ -461,6 +464,9 @@ export class PrometheusDatasource { case 'ADD_RATE': { return `rate(${query}[5m])`; } + case 'ADD_SUM': { + return `sum(${query.trim()}) by ($1)`; + } case 'EXPAND_RULES': { const mapping = action.mapping; if (mapping) { diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts new file mode 100644 index 00000000000..3dd15eb713e --- /dev/null +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -0,0 +1,347 @@ +import _ from 'lodash'; +import moment from 'moment'; + +import { + CompletionItem, + CompletionItemGroup, + LanguageProvider, + TypeaheadInput, + TypeaheadOutput, +} from 'app/types/explore'; + +import { parseSelector, processLabels, RATE_RANGES } from './language_utils'; +import PromqlSyntax, { FUNCTIONS } from './promql'; + +const DEFAULT_KEYS = ['job', 'instance']; +const EMPTY_SELECTOR = '{}'; +const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms +const HISTORY_ITEM_COUNT = 5; +const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h + +const wrapLabel = (label: string) => ({ label }); + +const setFunctionMove = (suggestion: CompletionItem): CompletionItem => { + suggestion.move = -1; + return suggestion; +}; + +export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem { + const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; + const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label); + const count = historyForItem.length; + const recent = historyForItem[0]; + let hint = `Queried ${count} times in the last 24h.`; + if (recent) { + const lastQueried = moment(recent.ts).fromNow(); + hint = `${hint} Last queried ${lastQueried}.`; + } + return { + ...item, + documentation: hint, + }; +} + +export default class PromQlLanguageProvider extends LanguageProvider { + histogramMetrics?: string[]; + labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] + labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] + metrics?: string[]; + logLabelOptions: any[]; + supportsLogs?: boolean; + started: boolean; + + constructor(datasource: any, initialValues?: any) { + super(); + + this.datasource = datasource; + this.histogramMetrics = []; + this.labelKeys = {}; + this.labelValues = {}; + this.metrics = []; + this.supportsLogs = false; + this.started = false; + + Object.assign(this, initialValues); + } + // Strip syntax chars + cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); + + getSyntax() { + return PromqlSyntax; + } + + request = url => { + return this.datasource.metadataRequest(url); + }; + + start = () => { + if (!this.started) { + this.started = true; + return Promise.all([this.fetchMetricNames(), this.fetchHistogramMetrics()]); + } + return Promise.resolve([]); + }; + + // Keep this DOM-free for testing + provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput { + // Syntax spans have 3 classes by default. More indicate a recognized token + const tokenRecognized = wrapperClasses.length > 3; + // Determine candidates by CSS context + if (_.includes(wrapperClasses, 'context-range')) { + // Suggestions for metric[|] + return this.getRangeCompletionItems(); + } else if (_.includes(wrapperClasses, 'context-labels')) { + // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} + return this.getLabelCompletionItems.apply(this, arguments); + } else if (_.includes(wrapperClasses, 'context-aggregation')) { + return this.getAggregationCompletionItems.apply(this, arguments); + } else if ( + // Show default suggestions in a couple of scenarios + (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token + (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace + text.match(/[+\-*/^%]/) // Anything after binary operator + ) { + return this.getEmptyCompletionItems(context || {}); + } + + return { + suggestions: [], + }; + } + + getEmptyCompletionItems(context: any): TypeaheadOutput { + const { history } = context; + const { metrics } = this; + const suggestions: CompletionItemGroup[] = []; + + if (history && history.length > 0) { + const historyItems = _.chain(history) + .uniqBy('query') + .take(HISTORY_ITEM_COUNT) + .map(h => h.query) + .map(wrapLabel) + .map(item => addHistoryMetadata(item, history)) + .value(); + + suggestions.push({ + prefixMatch: true, + skipSort: true, + label: 'History', + items: historyItems, + }); + } + + suggestions.push({ + prefixMatch: true, + label: 'Functions', + items: FUNCTIONS.map(setFunctionMove), + }); + + if (metrics) { + suggestions.push({ + label: 'Metrics', + items: metrics.map(wrapLabel), + }); + } + return { suggestions }; + } + + getRangeCompletionItems(): TypeaheadOutput { + return { + context: 'context-range', + suggestions: [ + { + label: 'Range vector', + items: [...RATE_RANGES].map(wrapLabel), + }, + ], + }; + } + + getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput { + let refresher: Promise = null; + const suggestions: CompletionItemGroup[] = []; + + // Stitch all query lines together to support multi-line queries + let queryOffset; + const queryText = value.document.getBlocks().reduce((text, block) => { + const blockText = block.getText(); + if (value.anchorBlock.key === block.key) { + // Newline characters are not accounted for but this is irrelevant + // for the purpose of extracting the selector string + queryOffset = value.anchorOffset + text.length; + } + text += blockText; + return text; + }, ''); + + const leftSide = queryText.slice(0, queryOffset); + const openParensAggregationIndex = leftSide.lastIndexOf('('); + const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('('); + const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex; + + let selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); + + // Range vector syntax not accounted for by subsequent parse so discard it if present + selectorString = selectorString.replace(/\[[^\]]+\]$/, ''); + + const selector = parseSelector(selectorString, selectorString.length - 2).selector; + + const labelKeys = this.labelKeys[selector]; + if (labelKeys) { + suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) }); + } else { + refresher = this.fetchSeriesLabels(selector); + } + + return { + refresher, + suggestions, + context: 'context-aggregation', + }; + } + + getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput { + let context: string; + let refresher: Promise = null; + const suggestions: CompletionItemGroup[] = []; + const line = value.anchorBlock.getText(); + const cursorOffset: number = value.anchorOffset; + + // Get normalized selector + let selector; + let parsedSelector; + try { + parsedSelector = parseSelector(line, cursorOffset); + selector = parsedSelector.selector; + } catch { + selector = EMPTY_SELECTOR; + } + const containsMetric = selector.indexOf('__name__=') > -1; + const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; + + if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { + // Label values + if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) { + const labelValues = this.labelValues[selector][labelKey]; + context = 'context-label-values'; + suggestions.push({ + label: `Label values for "${labelKey}"`, + items: labelValues.map(wrapLabel), + }); + } + } else { + // Label keys + const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS); + if (labelKeys) { + const possibleKeys = _.difference(labelKeys, existingKeys); + if (possibleKeys.length > 0) { + context = 'context-labels'; + suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) }); + } + } + } + + // Query labels for selector + // Temporarily add skip for logging + if (selector && !this.labelValues[selector] && !this.supportsLogs) { + if (selector === EMPTY_SELECTOR) { + // Query label values for default labels + refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); + } else { + refresher = this.fetchSeriesLabels(selector, !containsMetric); + } + } + + return { context, refresher, suggestions }; + } + + async fetchMetricNames() { + const url = '/api/v1/label/__name__/values'; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + this.metrics = body.data; + } catch (error) { + console.error(error); + } + } + + async fetchHistogramMetrics() { + await this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true); + const histogramSeries = this.labelValues[HISTOGRAM_SELECTOR]; + if (histogramSeries && histogramSeries['__name__']) { + this.histogramMetrics = histogramSeries['__name__'].slice().sort(); + } + } + + // Temporarily here while reusing this field for logging + async fetchLogLabels() { + const url = '/api/prom/label'; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + const labelKeys = body.data.slice().sort(); + const labelKeysBySelector = { + ...this.labelKeys, + [EMPTY_SELECTOR]: labelKeys, + }; + const labelValuesByKey = {}; + this.logLabelOptions = []; + for (const key of labelKeys) { + const valuesUrl = `/api/prom/label/${key}/values`; + const res = await this.request(valuesUrl); + const body = await (res.data || res.json()); + const values = body.data.slice().sort(); + labelValuesByKey[key] = values; + this.logLabelOptions.push({ + label: key, + value: key, + children: values.map(value => ({ label: value, value })), + }); + } + this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey }; + this.labelKeys = labelKeysBySelector; + } catch (e) { + console.error(e); + } + } + + async fetchLabelValues(key: string) { + const url = `/api/v1/label/${key}/values`; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + const exisingValues = this.labelValues[EMPTY_SELECTOR]; + const values = { + ...exisingValues, + [key]: body.data, + }; + this.labelValues = { + ...this.labelValues, + [EMPTY_SELECTOR]: values, + }; + } catch (e) { + console.error(e); + } + } + + async fetchSeriesLabels(name: string, withName?: boolean) { + const url = `/api/v1/series?match[]=${name}`; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + const { keys, values } = processLabels(body.data, withName); + this.labelKeys = { + ...this.labelKeys, + [name]: keys, + }; + this.labelValues = { + ...this.labelValues, + [name]: values, + }; + } catch (e) { + console.error(e); + } + } +} diff --git a/public/app/features/explore/utils/prometheus.ts b/public/app/plugins/datasource/prometheus/language_utils.ts similarity index 96% rename from public/app/features/explore/utils/prometheus.ts rename to public/app/plugins/datasource/prometheus/language_utils.ts index 170c5ec8cc5..5995c427cd1 100644 --- a/public/app/features/explore/utils/prometheus.ts +++ b/public/app/plugins/datasource/prometheus/language_utils.ts @@ -23,9 +23,6 @@ export function processLabels(labels, withName = false) { return { values, keys: Object.keys(values) }; } -// Strip syntax chars -export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); - // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/; const selectorRegexp = /\{[^}]*?\}/; const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g; diff --git a/public/app/features/explore/slate-plugins/prism/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts similarity index 100% rename from public/app/features/explore/slate-plugins/prism/promql.ts rename to public/app/plugins/datasource/prometheus/promql.ts diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts index cfd04c766ba..ea505832468 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.ts +++ b/public/app/plugins/datasource/prometheus/query_hints.ts @@ -1,6 +1,13 @@ import _ from 'lodash'; -export function getQueryHints(query: string, series?: any[], datasource?: any): any[] { +import { QueryHint } from 'app/types/explore'; + +/** + * Number of time series results needed before starting to suggest sum aggregation hints + */ +export const SUM_HINT_THRESHOLD_COUNT = 20; + +export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] { const hints = []; // ..._bucket metric needs a histogram_quantile() @@ -88,5 +95,24 @@ export function getQueryHints(query: string, series?: any[], datasource?: any): }); } } + + if (series.length >= SUM_HINT_THRESHOLD_COUNT) { + const simpleMetric = query.trim().match(/^\w+$/); + if (simpleMetric) { + hints.push({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: query, + preventSubmit: true, + }, + }, + }); + } + } + return hints.length > 0 ? hints : null; } diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts new file mode 100644 index 00000000000..20e148efd57 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts @@ -0,0 +1,273 @@ +import Plain from 'slate-plain-serializer'; + +import LanguageProvider from '../language_provider'; + +describe('Language completion provider', () => { + const datasource = { + 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(2); + }); + + describe('range suggestions', () => { + it('returns range suggestions in range context', () => { + const instance = new LanguageProvider(datasource); + const result = instance.provideCompletionItems({ text: '1', prefix: '1', wrapperClasses: ['context-range'] }); + expect(result.context).toBe('context-range'); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions).toEqual([ + { + items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }], + label: 'Range vector', + }, + ]); + }); + }); + + describe('metric suggestions', () => { + it('returns metrics suggestions by default', () => { + const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); + const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', wrapperClasses: [] }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(2); + }); + + it('returns default suggestions after a binary operator', () => { + const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); + const result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(2); + }); + }); + + describe('label suggestions', () => { + it('returns default label suggestions on label context and no metric', () => { + const instance = new LanguageProvider(datasource); + const value = Plain.deserialize('{}'); + const range = value.selection.merge({ + anchorOffset: 1, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); + }); + + it('returns label suggestions on label context and metric', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); + const value = Plain.deserialize('metric{}'); + const range = value.selection.merge({ + anchorOffset: 7, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label suggestions on label context but leaves out labels that already exist', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }, + }); + const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}'); + const range = value.selection.merge({ + anchorOffset: 36, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label value suggestions inside a label value context after a negated matching operator', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{}': ['label'] }, + labelValues: { '{}': { label: ['a', 'b', 'c'] } }, + }); + const value = Plain.deserialize('{label!=}'); + const range = value.selection.merge({ anchorOffset: 8 }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '!=', + prefix: '', + wrapperClasses: ['context-labels'], + labelKey: 'label', + value: valueWithSelection, + }); + expect(result.context).toBe('context-label-values'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }], + label: 'Label values for "label"', + }, + ]); + }); + + it('returns a refresher on label context and unavailable metric', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } }); + const value = Plain.deserialize('metric{}'); + const range = value.selection.merge({ + anchorOffset: 7, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeInstanceOf(Promise); + expect(result.suggestions).toEqual([]); + }); + + it('returns label values on label context when given a metric and a label key', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric"}': ['bar'] }, + labelValues: { '{__name__="metric"}': { bar: ['baz'] } }, + }); + const value = Plain.deserialize('metric{bar=ba}'); + const range = value.selection.merge({ + anchorOffset: 13, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '=ba', + prefix: 'ba', + wrapperClasses: ['context-labels'], + labelKey: 'bar', + value: valueWithSelection, + }); + expect(result.context).toBe('context-label-values'); + expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); + }); + + it('returns label suggestions on aggregation context and metric w/ selector', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } }); + const value = Plain.deserialize('sum(metric{foo="xx"}) by ()'); + const range = value.selection.merge({ + anchorOffset: 26, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label suggestions on aggregation context and metric w/o selector', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); + const value = Plain.deserialize('sum(metric) by ()'); + const range = value.selection.merge({ + anchorOffset: 16, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label suggestions inside a multi-line aggregation context', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, + }); + const value = Plain.deserialize('sum(\nmetric\n)\nby ()'); + const aggregationTextBlock = value.document.getBlocksAsArray()[3]; + const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }], + label: 'Labels', + }, + ]); + }); + + it('returns label suggestions inside an aggregation context with a range vector', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, + }); + const value = Plain.deserialize('sum(rate(metric[1h])) by ()'); + const range = value.selection.merge({ + anchorOffset: 26, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }], + label: 'Labels', + }, + ]); + }); + + it('returns label suggestions inside an aggregation context with a range vector and label', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] }, + }); + const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()'); + const range = value.selection.merge({ + anchorOffset: 42, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }], + label: 'Labels', + }, + ]); + }); + }); +}); diff --git a/public/app/features/explore/utils/prometheus.test.ts b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts similarity index 97% rename from public/app/features/explore/utils/prometheus.test.ts rename to public/app/plugins/datasource/prometheus/specs/language_utils.test.ts index 4e84deaa7e8..748217e21b7 100644 --- a/public/app/features/explore/utils/prometheus.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts @@ -1,4 +1,4 @@ -import { parseSelector } from './prometheus'; +import { parseSelector } from '../language_utils'; describe('parseSelector()', () => { let parsed; diff --git a/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts index 7eba54536fe..3b782d3dd09 100644 --- a/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts @@ -1,4 +1,4 @@ -import { getQueryHints } from '../query_hints'; +import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from '../query_hints'; describe('getQueryHints()', () => { it('returns no hints for no series', () => { @@ -79,4 +79,25 @@ describe('getQueryHints()', () => { }, }); }); + + it('returns a sum hint when many time series results are returned for a simple metric', () => { + const seriesCount = SUM_HINT_THRESHOLD_COUNT; + const series = Array.from({ length: seriesCount }, _ => ({ + datapoints: [[0, 0], [0, 0]], + })); + const hints = getQueryHints('metric', series); + expect(hints.length).toBe(1); + expect(hints[0]).toMatchObject({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: 'metric', + preventSubmit: true, + }, + }, + }); + }); }); diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index 4a81eb8a619..034333cbb86 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -114,7 +114,6 @@ export default class StackdriverDatasource { if (!queryRes.series) { return; } - this.projectName = queryRes.meta.defaultProject; const unit = this.resolvePanelUnitFromTargets(options.targets); queryRes.series.forEach(series => { let timeSerie: any = { diff --git a/public/app/plugins/panel/graph2/module.tsx b/public/app/plugins/panel/graph2/module.tsx index c2b8c355440..4011458bea9 100644 --- a/public/app/plugins/panel/graph2/module.tsx +++ b/public/app/plugins/panel/graph2/module.tsx @@ -5,6 +5,7 @@ import React, { PureComponent } from 'react'; // Components import Graph from 'app/viz/Graph'; import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; +import { Switch } from 'app/core/components/Switch/Switch'; // Types import { PanelProps, NullValueMode } from 'app/types'; @@ -35,8 +36,15 @@ export class Graph2 extends PureComponent { } export class TextOptions extends PureComponent { + onChange = () => {}; + render() { - return

Text2 Options component

; + return ( +
+
Draw Modes
+ +
+ ); } } diff --git a/public/app/plugins/panel/graph2/plugin.json b/public/app/plugins/panel/graph2/plugin.json index b519a57fae4..2e674ab3557 100644 --- a/public/app/plugins/panel/graph2/plugin.json +++ b/public/app/plugins/panel/graph2/plugin.json @@ -3,6 +3,8 @@ "name": "React Graph", "id": "graph2", + "state": "alpha", + "info": { "author": { "name": "Grafana Project", diff --git a/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg b/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg index a1e15d4d58d..746687d360f 100644 --- a/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg +++ b/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg @@ -1,33 +1,83 @@ - - + + + + - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index 5dfa8622614..75a34ac01c0 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -17,7 +17,6 @@ export class GrafanaCtrl { /** @ngInject */ constructor( $scope, - alertSrv, utilSrv, $rootScope, $controller, @@ -41,11 +40,8 @@ export class GrafanaCtrl { $scope._ = _; profiler.init(config, $rootScope); - alertSrv.init(); utilSrv.init(); bridgeSrv.init(); - - $scope.dashAlerts = alertSrv; }; $rootScope.colors = colors; diff --git a/public/app/types/appNotifications.ts b/public/app/types/appNotifications.ts new file mode 100644 index 00000000000..81e6cfd55e1 --- /dev/null +++ b/public/app/types/appNotifications.ts @@ -0,0 +1,25 @@ +export interface AppNotification { + id?: number; + severity: AppNotificationSeverity; + icon: string; + title: string; + text: string; + timeout: AppNotificationTimeout; +} + +export enum AppNotificationSeverity { + Success = 'success', + Warning = 'warning', + Error = 'error', + Info = 'info', +} + +export enum AppNotificationTimeout { + Warning = 5000, + Success = 3000, + Error = 7000, +} + +export interface AppNotificationsState { + appNotifications: AppNotification[]; +} diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 918dd4e4483..1db37392b0b 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -1,3 +1,75 @@ +import { Value } from 'slate'; + +export interface CompletionItem { + /** + * The label of this completion item. By default + * this is also the text that is inserted when selecting + * this completion. + */ + label: string; + /** + * The kind of this completion item. Based on the kind + * an icon is chosen by the editor. + */ + kind?: string; + /** + * A human-readable string with additional information + * about this item, like type or symbol information. + */ + detail?: string; + /** + * A human-readable string, can be Markdown, that represents a doc-comment. + */ + documentation?: string; + /** + * A string that should be used when comparing this item + * with other items. When `falsy` the `label` is used. + */ + sortText?: string; + /** + * A string that should be used when filtering a set of + * completion items. When `falsy` the `label` is used. + */ + filterText?: string; + /** + * A string or snippet that should be inserted in a document when selecting + * this completion. When `falsy` the `label` is used. + */ + insertText?: string; + /** + * Delete number of characters before the caret position, + * by default the letters from the beginning of the word. + */ + deleteBackwards?: number; + /** + * Number of steps to move after the insertion, can be negative. + */ + move?: number; +} + +export interface CompletionItemGroup { + /** + * Label that will be displayed for all entries of this group. + */ + label: string; + /** + * List of suggestions of this group. + */ + items: CompletionItem[]; + /** + * If true, match only by prefix (and not mid-word). + */ + prefixMatch?: boolean; + /** + * If true, do not filter items in this group based on the search. + */ + skipFilter?: boolean; + /** + * If true, do not sort items. + */ + skipSort?: boolean; +} + interface ExploreDatasource { value: string; label: string; @@ -8,6 +80,26 @@ export interface HistoryItem { query: string; } +export abstract class LanguageProvider { + datasource: any; + request: (url) => Promise; + start: () => Promise; +} + +export interface TypeaheadInput { + text: string; + prefix: string; + wrapperClasses: string[]; + labelKey?: string; + value?: Value; +} + +export interface TypeaheadOutput { + context?: string; + refresher?: Promise<{}>; + suggestions: CompletionItemGroup[]; +} + export interface Range { from: string; to: string; @@ -18,11 +110,29 @@ export interface Query { key?: string; } +export interface QueryFix { + type: string; + label: string; + action?: QueryFixAction; +} + +export interface QueryFixAction { + type: string; + query?: string; + preventSubmit?: boolean; +} + +export interface QueryHint { + type: string; + label: string; + fix?: QueryFix; +} + export interface QueryTransaction { id: string; done: boolean; error?: string; - hints?: any[]; + hints?: QueryHint[]; latency: number; options: any; query: string; diff --git a/public/app/types/index.ts b/public/app/types/index.ts index da0048bbb6d..d5264f908d5 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -23,6 +23,12 @@ import { import { PanelProps } from './panel'; import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins'; import { Organization, OrganizationPreferences, OrganizationState } from './organization'; +import { + AppNotification, + AppNotificationSeverity, + AppNotificationsState, + AppNotificationTimeout, +} from './appNotifications'; export { Team, @@ -74,6 +80,10 @@ export { Organization, OrganizationState, OrganizationPreferences, + AppNotification, + AppNotificationsState, + AppNotificationSeverity, + AppNotificationTimeout, }; export interface StoreState { @@ -87,4 +97,5 @@ export interface StoreState { dataSources: DataSourcesState; users: UsersState; organization: OrganizationState; + appNotifications: AppNotificationsState; } diff --git a/public/sass/components/_alerts.scss b/public/sass/components/_alerts.scss index 3420dcfdfaf..710c4d1ec0f 100644 --- a/public/sass/components/_alerts.scss +++ b/public/sass/components/_alerts.scss @@ -7,13 +7,13 @@ .alert { padding: 1.25rem 2rem 1.25rem 1.5rem; - margin-bottom: $line-height-base; + margin-bottom: $panel-margin / 2; text-shadow: 0 2px 0 rgba(255, 255, 255, 0.5); background: $alert-error-bg; position: relative; color: $white; text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); - border-radius: 2px; + border-radius: $border-radius; display: flex; flex-direction: row; } diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index d9ab29cc91c..795766a22de 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -21,6 +21,9 @@ div.flot-text { height: 100%; &--solo { + position: fixed; + bottom: 0; + right: 0; margin: 0; .panel-container { border: none; diff --git a/public/vendor/flot/jquery.flot.js b/public/vendor/flot/jquery.flot.js index 4a85b08c8d7..daa3ac6bb8e 100644 --- a/public/vendor/flot/jquery.flot.js +++ b/public/vendor/flot/jquery.flot.js @@ -2271,9 +2271,51 @@ Licensed under the MIT license. }); } + function drawOrphanedPoints(series) { + /* Filters series data for points with no neighbors before or after + * and plots single 0.5 radius points for them so that they are displayed. + */ + var abandonedPoints = []; + var beforeX = null; + var afterX = null; + var datapoints = series.datapoints; + // find any points with no neighbors before or after + var emptyPoints = []; + for (var j = 0; j < datapoints.pointsize - 2; j++) { + emptyPoints.push(0); + } + for (var i = 0; i < datapoints.points.length; i += datapoints.pointsize) { + var x = datapoints.points[i], y = datapoints.points[i + 1]; + if (i === datapoints.points.length - datapoints.pointsize) { + afterX = null; + } else { + afterX = datapoints.points[i + datapoints.pointsize]; + } + if (x !== null && y !== null && beforeX === null && afterX === null) { + abandonedPoints.push(x); + abandonedPoints.push(y); + abandonedPoints.push.apply(abandonedPoints, emptyPoints); + } + beforeX = x; + + } + var olddatapoints = datapoints.points + datapoints.points = abandonedPoints; + + series.points.radius = series.lines.lineWidth/2; + // plot the orphan points with a radius of lineWidth/2 + drawSeriesPoints(series); + // reset old info + datapoints.points = olddatapoints; + } + function drawSeries(series) { if (series.lines.show) drawSeriesLines(series); + if (!series.points.show && !series.bars.show) { + // not necessary if user wants points displayed for everything + drawOrphanedPoints(series); + } if (series.bars.show) drawSeriesBars(series); if (series.points.show) diff --git a/public/views/index.template.html b/public/views/index.template.html index c39d5e08321..0717908c84c 100644 --- a/public/views/index.template.html +++ b/public/views/index.template.html @@ -14,6 +14,9 @@ + + + @@ -23,13 +26,6 @@