Просмотр исходного кода

Merge branch 'master' into org-page-to-react

Peter Holmberg 7 лет назад
Родитель
Сommit
7c5e04277c
77 измененных файлов с 1909 добавлено и 1013 удалено
  1. 8 0
      .circleci/config.yml
  2. 5 0
      CHANGELOG.md
  3. 5 0
      Gruntfile.js
  4. 8 2
      Makefile
  5. 13 0
      build.go
  6. 123 6
      devenv/dev-dashboards/panel_tests_graph.json
  7. 1 1
      docs/sources/administration/provisioning.md
  8. 2 2
      docs/sources/auth/gitlab.md
  9. 10 0
      packaging/docker/build-enterprise.sh
  10. 0 29
      packaging/release_process.md
  11. 7 7
      pkg/cmd/grafana-server/main.go
  12. 8 13
      pkg/cmd/grafana-server/server.go
  13. 1 1
      pkg/login/ldap.go
  14. 7 6
      pkg/middleware/middleware.go
  15. 0 1
      pkg/plugins/plugins.go
  16. 2 5
      pkg/services/alerting/reader.go
  17. 3 5
      pkg/setting/setting.go
  18. 11 2
      pkg/tsdb/cloudwatch/metric_find_query.go
  19. 32 1
      pkg/tsdb/cloudwatch/metric_find_query_test.go
  20. 2 4
      pkg/tsdb/graphite/graphite.go
  21. 1 1
      pkg/tsdb/mysql/macros.go
  22. 3 3
      pkg/tsdb/mysql/macros_test.go
  23. 1 2
      pkg/tsdb/stackdriver/stackdriver.go
  24. 28 0
      public/app/core/actions/appNotification.ts
  25. 2 1
      public/app/core/actions/index.ts
  26. 2 0
      public/app/core/angular_wrappers.ts
  27. 38 0
      public/app/core/components/AppNotifications/AppNotificationItem.tsx
  28. 60 0
      public/app/core/components/AppNotifications/AppNotificationList.tsx
  29. 1 0
      public/app/core/components/Label/Label.tsx
  30. 46 0
      public/app/core/components/Switch/Switch.tsx
  31. 46 0
      public/app/core/copy/appNotification.ts
  32. 51 0
      public/app/core/reducers/appNotification.test.ts
  33. 19 0
      public/app/core/reducers/appNotification.ts
  34. 2 0
      public/app/core/reducers/index.ts
  35. 4 92
      public/app/core/services/alert_srv.ts
  36. 7 6
      public/app/core/services/backend_srv.ts
  37. 1 1
      public/app/core/specs/backend_srv.test.ts
  38. 11 0
      public/app/core/utils/connectWithReduxStore.tsx
  39. 18 11
      public/app/features/annotations/annotations_srv.ts
  40. 10 2
      public/app/features/dashboard/dashboard_ctrl.ts
  41. 6 6
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  42. 1 9
      public/app/features/dashboard/permissions/DashboardPermissions.tsx
  43. 2 2
      public/app/features/dashboard/upload.ts
  44. 25 18
      public/app/features/explore/Explore.tsx
  45. 72 0
      public/app/features/explore/PlaceholdersBuffer.test.ts
  46. 112 0
      public/app/features/explore/PlaceholdersBuffer.ts
  47. 1 228
      public/app/features/explore/PromQueryField.test.tsx
  48. 31 347
      public/app/features/explore/PromQueryField.tsx
  49. 33 89
      public/app/features/explore/QueryField.tsx
  50. 36 8
      public/app/features/explore/QueryRows.tsx
  51. 10 10
      public/app/features/explore/Typeahead.tsx
  52. 1 1
      public/app/features/panel/solo_panel_ctrl.ts
  53. 1 1
      public/app/features/teams/TeamSettings.tsx
  54. 3 0
      public/app/plugins/datasource/cloudwatch/datasource.ts
  55. 6 0
      public/app/plugins/datasource/prometheus/datasource.ts
  56. 347 0
      public/app/plugins/datasource/prometheus/language_provider.ts
  57. 0 3
      public/app/plugins/datasource/prometheus/language_utils.ts
  58. 0 0
      public/app/plugins/datasource/prometheus/promql.ts
  59. 27 1
      public/app/plugins/datasource/prometheus/query_hints.ts
  60. 273 0
      public/app/plugins/datasource/prometheus/specs/language_provider.test.ts
  61. 1 1
      public/app/plugins/datasource/prometheus/specs/language_utils.test.ts
  62. 22 1
      public/app/plugins/datasource/prometheus/specs/query_hints.test.ts
  63. 0 1
      public/app/plugins/datasource/stackdriver/datasource.ts
  64. 9 1
      public/app/plugins/panel/graph2/module.tsx
  65. 2 0
      public/app/plugins/panel/graph2/plugin.json
  66. 76 26
      public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg
  67. 0 4
      public/app/routes/GrafanaCtrl.ts
  68. 25 0
      public/app/types/appNotifications.ts
  69. 111 1
      public/app/types/explore.ts
  70. 11 0
      public/app/types/index.ts
  71. 2 2
      public/sass/components/_alerts.scss
  72. 3 0
      public/sass/pages/_dashboard.scss
  73. 42 0
      public/vendor/flot/jquery.flot.js
  74. 5 37
      public/views/index.template.html
  75. 1 1
      scripts/build/build-all.sh
  76. 11 9
      scripts/build/publish.go
  77. 2 2
      scripts/grunt/options/compress.js

+ 8 - 0
.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

+ 5 - 0
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)

+ 5 - 0
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 {

+ 8 - 2
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

+ 13 - 0
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 {

+ 123 - 6
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
 }

+ 1 - 1
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 |

+ 2 - 2
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

+ 10 - 0
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 \
+  .

+ 0 - 29
packaging/release_process.md

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

+ 7 - 7
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)

+ 8 - 13
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()
 }
 

+ 1 - 1
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
 			}
 		}

+ 7 - 6
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)

+ 0 - 1
pkg/plugins/plugins.go

@@ -121,7 +121,6 @@ func (pm *PluginManager) Run(ctx context.Context) error {
 			pm.checkForUpdates()
 		case <-ctx.Done():
 			run = false
-			break
 		}
 	}
 

+ 2 - 5
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()
 	}
 }
 

+ 3 - 5
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

+ 11 - 2
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
 }

+ 32 - 1
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{

+ 2 - 4
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
 }

+ 1 - 1
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)

+ 3 - 3
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() {

+ 1 - 2
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)

+ 28 - 0
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,
+});

+ 2 - 1
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 };

+ 2 - 0
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, []);

+ 38 - 0
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<Props> {
+  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 (
+      <div className={`alert-${appNotification.severity} alert`}>
+        <div className="alert-icon">
+          <i className={appNotification.icon} />
+        </div>
+        <div className="alert-body">
+          <div className="alert-title">{appNotification.title}</div>
+          <div className="alert-text">{appNotification.text}</div>
+        </div>
+        <button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
+          <i className="fa fa fa-remove" />
+        </button>
+      </div>
+    );
+  }
+}

+ 60 - 0
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<Props> {
+  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 (
+      <div>
+        {appNotifications.map((appNotification, index) => {
+          return (
+            <AppNotificationItem
+              key={`${appNotification.id}-${index}`}
+              appNotification={appNotification}
+              onClearNotification={id => this.onClearAppNotification(id)}
+            />
+          );
+        })}
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = (state: StoreState) => ({
+  appNotifications: state.appNotifications.appNotifications,
+});
+
+const mapDispatchToProps = {
+  notifyApp,
+  clearAppNotification,
+};
+
+export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps);

+ 1 - 0
public/app/core/components/Forms/Forms.tsx → public/app/core/components/Label/Label.tsx

@@ -19,3 +19,4 @@ export const Label: SFC<Props> = props => {
     </span>
   );
 };
+

+ 46 - 0
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<Props, State> {
+  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 (
+      <div className="gf-form">
+        {label && (
+          <label htmlFor={labelId} className={labelClassName}>
+            {label}
+          </label>
+        )}
+        <div className={switchClassName}>
+          <input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
+          <label htmlFor={labelId} />
+        </div>
+      </div>
+    );
+  }
+}

+ 46 - 0
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(),
+});

+ 51 - 0
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);
+  });
+});

+ 19 - 0
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;
+};

+ 2 - 0
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,
 };

+ 4 - 92
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);

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

+ 1 - 1
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 () => {

+ 11 - 0
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 <ConnectedWrappedComponent {...props} store={store} />;
+  };
+}

+ 18 - 11
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);
-  }
-
-  clearCache() {
-    this.globalAnnotationsPromise = null;
-    this.alertStatesPromise = null;
-    this.datasourcePromises = null;
+  constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {}
+
+  init(dashboard: DashboardModel) {
+    // clear promises on refresh events
+    dashboard.on('refresh', () => {
+      this.globalAnnotationsPromise = null;
+      this.alertStatesPromise = null;
+      this.datasourcePromises = null;
+    });
   }
 
   getAnnotations(options) {

+ 10 - 2
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));
   }

+ 6 - 6
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<DashboardGridProps, any> {
         onDragStop={this.onDragStop}
         onResize={this.onResize}
         onResizeStop={this.onResizeStop}
+        isFullscreen={this.props.dashboard.meta.fullscreen}
       >
         {this.renderPanels()}
       </SizedReactLayoutGrid>

+ 1 - 9
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<Props, State> {
   }
 }
 
-function connectWithStore(WrappedComponent, ...args) {
-  const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
-  return props => {
-    return <ConnectedWrappedComponent {...props} store={store} />;
-  };
-}
-
 const mapStateToProps = (state: StoreState) => ({
   permissions: state.dashboard.permissions,
 });

+ 2 - 2
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']);
       }
     },
   };

+ 25 - 18
public/app/features/explore/Explore.tsx

@@ -373,9 +373,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     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<ExploreProps, ExploreState> {
             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<ExploreProps, ExploreState> {
             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<ExploreProps, ExploreState> {
     });
   }
 
-  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<ExploreProps, ExploreState> {
         {datasource && !datasourceError ? (
           <div className="explore-container">
             <QueryRows
+              datasource={datasource}
               history={history}
               queries={queries}
-              request={this.request}
               onAddQueryRow={this.onAddQueryRow}
               onChangeQuery={this.onChangeQuery}
               onClickHintFix={this.onModifyQueries}

+ 72 - 0
public/app/features/explore/PlaceholdersBuffer.test.ts

@@ -0,0 +1,72 @@
+import PlaceholdersBuffer from './PlaceholdersBuffer';
+
+describe('PlaceholdersBuffer', () => {
+  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);
+  });
+});

+ 112 - 0
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[];
+};

+ 1 - 228
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(<PromQueryField {...defaultProps} />).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(<PromQueryField {...defaultProps} />).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(
-        <PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
-      ).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(
-        <PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
-      ).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(<PromQueryField {...defaultProps} />).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(
-        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
-      ).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(
-        <PromQueryField
-          {...defaultProps}
-          labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
-        />
-      ).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(
-        <PromQueryField
-          {...defaultProps}
-          labelKeys={{ '{}': ['label'] }}
-          labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
-        />
-      ).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(
-        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
-      ).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(
-        <PromQueryField
-          {...defaultProps}
-          labelKeys={{ '{__name__="metric"}': ['bar'] }}
-          labelValues={{ '{__name__="metric"}': { bar: ['baz'] } }}
-        />
-      ).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(
-        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric",foo="xx"}': ['bar'] }} />
-      ).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(
-        <PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
-      ).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', () => {

+ 31 - 347
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';
-
-const DEFAULT_KEYS = ['job', 'instance'];
-const EMPTY_SELECTOR = '{}';
+
+import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField';
+
 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<PromQueryFieldProps, PromQueryFieldState> {
   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<PromQueryFieldProps, PromQueryF
     ];
 
     this.state = {
-      histogramMetrics: props.histogramMetrics || [],
-      labelKeys: props.labelKeys || {},
-      labelValues: props.labelValues || {},
       logLabelOptions: [],
-      metrics: props.metrics || [],
-      metricsByPrefix: props.metricsByPrefix || [],
+      metricsByPrefix: [],
       metricsOptions: [],
       syntaxLoaded: false,
     };
   }
 
   componentDidMount() {
-    // Temporarily reused by logging
-    const { supportsLogs } = this.props;
-    if (supportsLogs) {
-      this.fetchLogLabels();
-    } else {
-      // Usual actions
-      this.fetchMetricNames();
-      this.fetchHistogramMetrics();
+    if (this.languageProvider) {
+      this.languageProvider.start().then(() => this.onReceiveMetrics());
     }
   }
 
@@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
   };
 
   onReceiveMetrics = () => {
-    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<PromQueryFieldProps, PromQueryF
   };
 
   onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+    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<PromQueryFieldProps, PromQueryF
     const labelKey = labelKeyNode && labelKeyNode.textContent;
     const nextChar = getNextCharacter();
 
-    const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
+    const result = this.languageProvider.provideCompletionItems(
+      { text, value, prefix, wrapperClasses, labelKey },
+      { history }
+    );
 
     console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
 
     return result;
   };
 
-  // Keep this DOM-free for testing
-  getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): 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.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<any> = 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<any> = 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 (
       <div className="prom-query-field">

+ 33 - 89
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<TypeaheadFieldProps, TypeaheadFieldState> {
   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<TypeaheadFieldProps, TypeaheadField
       typeaheadIndex: 0,
       typeaheadPrefix: '',
       typeaheadText: '',
-      value: makeValue(props.initialValue || '', props.syntax),
+      value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
     };
   }
 
@@ -175,12 +105,14 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
   componentWillReceiveProps(nextProps: TypeaheadFieldProps) {
     if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
       // Need a bogus edit to re-render the editor after syntax has fully loaded
-      this.onChange(
-        this.state.value
-          .change()
-          .insertText(' ')
-          .deleteBackward()
-      );
+      const change = this.state.value
+        .change()
+        .insertText(' ')
+        .deleteBackward();
+      if (this.placeholdersBuffer.hasPlaceholders()) {
+        change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
+      }
+      this.onChange(change);
     }
   }
 
@@ -293,7 +225,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
     }
   }, TYPEAHEAD_DEBOUNCE);
 
-  applyTypeahead(change: Change, suggestion: Suggestion): Change {
+  applyTypeahead(change: Change, suggestion: CompletionItem): Change {
     const { cleanText, onWillApplySuggestion, syntax } = this.props;
     const { typeaheadPrefix, typeaheadText } = this.state;
     let suggestionText = suggestion.insertText || suggestion.label;
@@ -363,7 +295,17 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
           }
 
           const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
-          this.applyTypeahead(change, suggestion);
+          const nextChange = this.applyTypeahead(change, suggestion);
+
+          const insertTextOperation = nextChange.operations.find(operation => 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<TypeaheadFieldProps, TypeaheadField
     // If we dont wait here, menu clicks wont work because the menu
     // will be gone.
     this.resetTimer = setTimeout(this.resetTypeahead, 100);
+    // Disrupting placeholder entry wipes all remaining placeholders needing input
+    this.placeholdersBuffer.clearPlaceholders();
     if (onBlur) {
       onBlur();
     }
@@ -422,7 +366,7 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
     }
   };
 
-  onClickMenu = (item: Suggestion) => {
+  onClickMenu = (item: CompletionItem) => {
     // Manually triggering change
     const change = this.applyTypeahead(this.state.value.change(), item);
     this.onChange(change);

+ 36 - 8
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<any, {}> {
+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<QueryRowProps> {
   onChangeQuery = (value, override?: boolean) => {
     const { index, onChangeQuery } = this.props;
     if (onChangeQuery) {
@@ -55,8 +78,8 @@ class QueryRow extends PureComponent<any, {}> {
   };
 
   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<any, {}> {
         </div>
         <div className="query-row-field">
           <QueryField
+            datasource={datasource}
             error={queryError}
             hint={hint}
             initialQuery={query}
@@ -73,7 +97,6 @@ class QueryRow extends PureComponent<any, {}> {
             onClickHintFix={this.onClickHintFix}
             onPressEnter={this.onPressEnter}
             onQueryChange={this.onChangeQuery}
-            request={request}
             supportsLogs={supportsLogs}
           />
         </div>
@@ -93,9 +116,14 @@ class QueryRow extends PureComponent<any, {}> {
   }
 }
 
-export default class QueryRows extends PureComponent<any, {}> {
+type QueryRowsProps = QueryRowCommonProps &
+  QueryRowEventHandlers & {
+    queries: Query[];
+  };
+
+export default class QueryRows extends PureComponent<QueryRowsProps> {
   render() {
-    const { className = '', queries, queryHints, transactions, ...handlers } = this.props;
+    const { className = '', queries, transactions, ...handlers } = this.props;
     return (
       <div className={className}>
         {queries.map((q, index) => (

+ 10 - 10
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<TypeaheadItemProps, {}> {
+class TypeaheadItem extends React.PureComponent<TypeaheadItemProps> {
   el: HTMLElement;
 
   componentDidUpdate(prevProps) {
@@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
 }
 
 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<TypeaheadGroupProps, {}> {
+class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps> {
   render() {
     const { items, label, selected, onClickItem, prefix } = this.props;
     return (
@@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
 }
 
 interface TypeaheadProps {
-  groupedItems: SuggestionGroup[];
+  groupedItems: CompletionItemGroup[];
   menuRef: any;
-  selectedItem: Suggestion | null;
+  selectedItem: CompletionItem | null;
   onClickItem: (Suggestion) => void;
   prefix?: string;
 }
-class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
+class Typeahead extends React.PureComponent<TypeaheadProps> {
   render() {
     const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
     return (

+ 1 - 1
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) {

+ 1 - 1
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';

+ 3 - 0
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);

+ 6 - 0
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) {

+ 347 - 0
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<any> = 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<any> = 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);
+    }
+  }
+}

+ 0 - 3
public/app/features/explore/utils/prometheus.ts → 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;

+ 0 - 0
public/app/features/explore/slate-plugins/prism/promql.ts → public/app/plugins/datasource/prometheus/promql.ts


+ 27 - 1
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;
 }

+ 273 - 0
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',
+        },
+      ]);
+    });
+  });
+});

+ 1 - 1
public/app/features/explore/utils/prometheus.test.ts → 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;

+ 22 - 1
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,
+        },
+      },
+    });
+  });
 });

+ 0 - 1
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 = {

+ 9 - 1
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<Props> {
 }
 
 export class TextOptions extends PureComponent<any> {
+  onChange = () => {};
+
   render() {
-    return <p>Text2 Options component</p>;
+    return (
+      <div className="section gf-form-group">
+        <h5 className="section-heading">Draw Modes</h5>
+        <Switch label="Lines" checked={true} onChange={this.onChange} />
+      </div>
+    );
   }
 }
 

+ 2 - 0
public/app/plugins/panel/graph2/plugin.json

@@ -3,6 +3,8 @@
   "name": "React Graph",
   "id": "graph2",
 
+  "state": "alpha",
+
   "info": {
     "author": {
       "name": "Grafana Project",

+ 76 - 26
public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg

@@ -1,33 +1,83 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
 <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
 	 width="100px" height="100px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
+<style type="text/css">
+	.st0{opacity:0.26;fill:url(#SVGID_1_);}
+	.st1{fill:url(#SVGID_2_);}
+	.st2{fill:url(#SVGID_3_);}
+	.st3{fill:url(#SVGID_4_);}
+	.st4{fill:url(#SVGID_5_);}
+	.st5{fill:none;stroke:url(#SVGID_6_);stroke-miterlimit:10;}
+</style>
 <g>
-	<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="32.3342" y1="95.7019" x2="32.3342" y2="5.2695">
-		<stop  offset="0" style="stop-color:#FFDE17"/>
-		<stop  offset="0.0803" style="stop-color:#FFD210"/>
-		<stop  offset="0.1774" style="stop-color:#FEC90D"/>
-		<stop  offset="0.2809" style="stop-color:#FDC70C"/>
-		<stop  offset="0.6685" style="stop-color:#F3903F"/>
-		<stop  offset="0.8876" style="stop-color:#ED683C"/>
-		<stop  offset="1" style="stop-color:#E93E3A"/>
+	<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="50" y1="65.6698" x2="50" y2="93.5681">
+		<stop  offset="0" style="stop-color:#FFF23A"/>
+		<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+		<stop  offset="0.1171" style="stop-color:#FED41A"/>
+		<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+		<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+		<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+		<stop  offset="0.8876" style="stop-color:#ED693C"/>
+		<stop  offset="1" style="stop-color:#E83E39"/>
 	</linearGradient>
-	<path style="fill:url(#SVGID_1_);" d="M48.173,57.757V39.825c0-1.302,1.055-2.357,2.357-2.357h9.691
-		c0.897,0,1.346-1.084,0.712-1.718L34.112,0.737c-0.982-0.982-2.574-0.982-3.556,0L3.735,35.75
-		c-0.634,0.634-0.185,1.718,0.712,1.718h9.691c1.302,0,2.357,1.055,2.357,2.357v17.932c0,0.958,0.776,1.734,1.734,1.734h28.21
-		C47.397,59.491,48.173,58.715,48.173,57.757z"/>
-	<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="67.6658" y1="94.1706" x2="67.6658" y2="3.7383">
-		<stop  offset="0" style="stop-color:#FFDE17"/>
-		<stop  offset="0.0803" style="stop-color:#FFD210"/>
-		<stop  offset="0.1774" style="stop-color:#FEC90D"/>
-		<stop  offset="0.2809" style="stop-color:#FDC70C"/>
-		<stop  offset="0.6685" style="stop-color:#F3903F"/>
-		<stop  offset="0.8876" style="stop-color:#ED683C"/>
-		<stop  offset="1" style="stop-color:#E93E3A"/>
+	<path class="st0" d="M97.6,83.8H2.4c-1.3,0-2.4-1.1-2.4-2.4v-1.8l17-1l19.2-4.3l16.3-1.6l16.5,0l15.8-4.7l15.1-3v16.3
+		C100,82.8,98.9,83.8,97.6,83.8z"/>
+	<g>
+		<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="19.098" y1="76.0776" x2="19.098" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st1" d="M19.6,64.3V38.9l-5.2,3.9l-3.5-6l9.4-6.9h6.8v34.4H19.6z"/>
+		<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="42.412" y1="76.0776" x2="42.412" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st2" d="M53.1,39.4c0,1.1-0.1,2.2-0.4,3.2c-0.3,1-0.7,1.9-1.2,2.8c-0.5,0.9-1,1.7-1.7,2.5c-0.6,0.8-1.2,1.6-1.9,2.3
+			l-6.4,7.4h11.1v6.7H32.3v-6.9l10.5-12c0.8-1,1.5-2,2-3c0.5-1,0.7-2,0.7-2.9c0-1-0.2-1.9-0.7-2.6c-0.5-0.7-1.2-1.1-2.2-1.1
+			c-0.9,0-1.7,0.4-2.3,1.1c-0.6,0.8-1,1.9-1.1,3.3l-7.3-0.7c0.4-3.5,1.6-6.1,3.6-7.9c2-1.7,4.5-2.6,7.4-2.6c1.6,0,3,0.2,4.3,0.7
+			c1.3,0.5,2.3,1.2,3.2,2c0.9,0.9,1.6,1.9,2.1,3.2C52.8,36.4,53.1,37.8,53.1,39.4z"/>
+		<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="60.3739" y1="76.0776" x2="60.3739" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st3" d="M64.5,60.4c0,1.2-0.4,2.3-1.2,3.1c-0.8,0.8-1.8,1.3-3,1.3c-1.2,0-2.2-0.4-3-1.3c-0.8-0.8-1.1-1.9-1.1-3.1
+			c0-1.2,0.4-2.2,1.1-3.1c0.8-0.9,1.8-1.3,3-1.3c1.2,0,2.2,0.4,3,1.3C64.1,58.1,64.5,59.2,64.5,60.4z"/>
+		<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="77.5234" y1="76.0776" x2="77.5234" y2="27.8027">
+			<stop  offset="0" style="stop-color:#FFF23A"/>
+			<stop  offset="4.010540e-02" style="stop-color:#FEE62D"/>
+			<stop  offset="0.1171" style="stop-color:#FED41A"/>
+			<stop  offset="0.1964" style="stop-color:#FDC90F"/>
+			<stop  offset="0.2809" style="stop-color:#FDC60B"/>
+			<stop  offset="0.6685" style="stop-color:#F28F3F"/>
+			<stop  offset="0.8876" style="stop-color:#ED693C"/>
+			<stop  offset="1" style="stop-color:#E83E39"/>
+		</linearGradient>
+		<path class="st4" d="M85.5,57.4v6.9h-6.9v-6.9H66v-6.6l10.1-20.9h9.4V51H89v6.4H85.5z M78.8,37.5L78.8,37.5l-6,13.5h6V37.5z"/>
+	</g>
+	<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-2.852199e-02" y1="72.3985" x2="100.0976" y2="72.3985">
+		<stop  offset="0" style="stop-color:#F28F3F"/>
+		<stop  offset="1" style="stop-color:#F28F3F"/>
 	</linearGradient>
-	<path style="fill:url(#SVGID_2_);" d="M95.553,62.532h-9.691c-1.302,0-2.357-1.055-2.357-2.357V42.243
-		c0-0.958-0.776-1.734-1.734-1.734h-28.21c-0.958,0-1.734,0.776-1.734,1.734v17.932c0,1.302-1.055,2.357-2.357,2.357h-9.691
-		c-0.897,0-1.346,1.084-0.712,1.718l26.821,35.013c0.982,0.982,2.574,0.982,3.556,0L96.265,64.25
-		C96.898,63.616,96.45,62.532,95.553,62.532z"/>
+	<polyline class="st5" points="0,79.7 17,78.7 36.2,74.4 52.5,72.8 69,72.9 84.9,68.1 100,65.1 	"/>
 </g>
 </svg>

+ 0 - 4
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;

+ 25 - 0
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[];
+}

+ 111 - 1
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<any>;
+  start: () => Promise<any>;
+}
+
+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;

+ 11 - 0
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;
 }

+ 2 - 2
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;
 }

+ 3 - 0
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;

+ 42 - 0
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)

+ 5 - 37
public/views/index.template.html

@@ -14,6 +14,9 @@
   <link rel="icon" type="image/png" href="public/img/fav32.png">
   <link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">
   <link rel="apple-touch-icon" sizes="180x180" href="public/img/apple-touch-icon.png">
+
+  <link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]+[[ .BuildCommit ]]">
+
   <meta name="apple-mobile-web-app-capable" content="yes">
   <meta name="apple-mobile-web-app-status-bar-style" content="black">
   <meta name="msapplication-TileColor" content="#2b5797">
@@ -23,13 +26,6 @@
 <body class="theme-[[ .Theme ]]">
 
   <style>
-    body {
-      margin: 0;
-      height: 100%;
-      width: 100%;
-      position: absolute;
-    }
-
     .preloader {
       height: 100%;
       flex-direction: column;
@@ -38,14 +34,6 @@
       align-items: center;
     }
 
-    .theme-light .preloader {
-      background: linear-gradient(-60deg, #f7f8fa, #f5f6f9 70%, #f7f8fa 98%);
-    }
-
-    .theme-dark .preloader {
-      background: linear-gradient(180deg, #222426 10px, #161719 100px);
-    }
-
     .preloader__enter {
       opacity: 0;
       animation-name: preloader-fade-in;
@@ -200,21 +188,8 @@
 
   <grafana-app class="grafana-app" ng-cloak>
     <sidemenu class="sidemenu"></sidemenu>
+    <app-notifications-list class="page-alert-list"></app-notifications-list>
 
-    <div class="page-alert-list">
-      <div ng-repeat='alert in dashAlerts.list' class="alert-{{alert.severity}} alert">
-        <div class="alert-icon">
-          <i class="{{alert.icon}}"></i>
-        </div>
-        <div class="alert-body">
-          <div class="alert-title">{{alert.title}}</div>
-          <div class="alert-text" ng-bind='alert.text'></div>
-        </div>
-        <button type="button" class="alert-close" ng-click="dashAlerts.clear(alert)">
-          <i class="fa fa fa-remove"></i>
-        </button>
-      </div>
-    </div>
 
     <div class="main-view">
       <div class="scroll-canvas" page-scrollbar>
@@ -266,14 +241,7 @@
       navTree: [[.NavTree]]
     };
 
-    // load css async
-    var myCSS = document.createElement("link");
-    myCSS.rel = "stylesheet";
-    myCSS.href = "public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]+[[ .BuildCommit ]]";
-
-    // insert it at the end of the head in a legacy-friendly manner
-    document.head.insertBefore(myCSS, document.head.childNodes[document.head.childNodes.length - 1].nextSibling);
-    // switch loader to show all has loaded
+		// In case the js files fails to load the code below will show an info message.
     window.onload = function() {
       var preloader = document.getElementsByClassName("preloader");
       if (preloader.length) {

+ 1 - 1
scripts/build/build-all.sh

@@ -76,7 +76,7 @@ if [ -d '/tmp/phantomjs/windows' ]; then
   cp /tmp/phantomjs/windows/phantomjs.exe tools/phantomjs/phantomjs.exe
   rm tools/phantomjs/phantomjs
 else
-    echo 'PhantomJS binaries for darwin missing!'
+    echo 'PhantomJS binaries for Windows missing!'
 fi
 go run build.go -goos windows -pkg-arch amd64 ${OPT} package-only
 

+ 11 - 9
scripts/build/publish.go

@@ -22,13 +22,13 @@ var versionRe = regexp.MustCompile(`grafana-(.*)(\.|_)(arm64|armhfp|aarch64|armv
 var debVersionRe = regexp.MustCompile(`grafana_(.*)_(arm64|armv7|armhf|amd64)\.deb`)
 var builds = []build{}
 var architectureMapping = map[string]string{
-	"armv7":"armv7",
-	"armhfp":"armv7",
-	"armhf":"armv7",
-	"arm64":"arm64",
-	"aarch64":"arm64",
-	"amd64":"amd64",
-	"x86_64":"amd64",
+	"armv7":   "armv7",
+	"armhfp":  "armv7",
+	"armhf":   "armv7",
+	"arm64":   "arm64",
+	"aarch64": "arm64",
+	"amd64":   "amd64",
+	"x86_64":  "amd64",
 }
 
 func main() {
@@ -78,7 +78,7 @@ func mapPackage(path string, name string, shaBytes []byte) (build, error) {
 	if len(result) > 0 {
 		version = string(result[1])
 		log.Printf("Version detected: %v", version)
-	} else if (len(debResult) > 0) {
+	} else if len(debResult) > 0 {
 		version = string(debResult[1])
 	} else {
 		return build{}, fmt.Errorf("Unable to figure out version from '%v'", name)
@@ -124,6 +124,9 @@ func mapPackage(path string, name string, shaBytes []byte) (build, error) {
 }
 
 func packageWalker(path string, f os.FileInfo, err error) error {
+	if err != nil {
+		log.Printf("error: %v", err)
+	}
 	if f.Name() == "dist" || strings.Contains(f.Name(), "sha256") || strings.Contains(f.Name(), "latest") {
 		return nil
 	}
@@ -134,7 +137,6 @@ func packageWalker(path string, f os.FileInfo, err error) error {
 	}
 
 	build, err := mapPackage(path, f.Name(), shaBytes)
-
 	if err != nil {
 		log.Printf("Could not map metadata from package: %v", err)
 		return nil

+ 2 - 2
scripts/grunt/options/compress.js

@@ -4,7 +4,7 @@ module.exports = function(config) {
   var task = {
     release: {
       options: {
-        archive: '<%= destDir %>/<%= pkg.name %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.tar.gz'
+        archive: '<%= destDir %>/<%= pkg.name %><%= enterprise ? "-enterprise" : ""  %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.tar.gz'
       },
       files : [
         {
@@ -23,7 +23,7 @@ module.exports = function(config) {
   };
 
   if (config.platform === 'windows') {
-    task.release.options.archive = '<%= destDir %>/<%= pkg.name %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.zip';
+    task.release.options.archive = '<%= destDir %>/<%= pkg.name %><%= enterprise ? "-enterprise" : ""  %>-<%= pkg.version %>.<%= platform %>-<%= arch %>.zip';
   }
 
   return task;