فهرست منبع

Merge remote-tracking branch 'upstream/master' into postgres-query-builder

Sven Klemm 7 سال پیش
والد
کامیت
734118de86
100فایلهای تغییر یافته به همراه5715 افزوده شده و 1531 حذف شده
  1. 2 4
      .circleci/config.yml
  2. 13 1
      CHANGELOG.md
  3. 12 3
      Gopkg.lock
  4. 1 1
      Gopkg.toml
  5. 1 0
      build.go
  6. 2 0
      conf/ldap.toml
  7. 10 5
      devenv/README.md
  8. 6 1
      devenv/datasources.yaml
  9. 0 592
      devenv/dev-dashboards/dashboard_with_rows.json
  10. 1558 0
      devenv/dev-dashboards/panel_tests_graph.json
  11. 574 0
      devenv/dev-dashboards/panel_tests_singlestat.json
  12. 453 0
      devenv/dev-dashboards/panel_tests_table.json
  13. 3 3
      devenv/dev-dashboards/testdata_alerts.json
  14. 13 7
      devenv/setup.sh
  15. 2 1
      docker/blocks/nginx_proxy/Dockerfile
  16. 3 0
      docker/blocks/nginx_proxy/htpasswd
  17. 20 1
      docker/blocks/nginx_proxy/nginx.conf
  18. 85 0
      docker/blocks/openldap/ldap_dev.toml
  19. 2 5
      docker/blocks/openldap/notes.md
  20. 26 0
      docs/sources/features/datasources/prometheus.md
  21. 286 0
      docs/sources/http_api/playlist.md
  22. 9 1
      docs/sources/installation/configuration.md
  23. 9 2
      docs/sources/installation/ldap.md
  24. 7 1
      docs/sources/reference/templating.md
  25. 8 8
      package.json
  26. 1 1
      pkg/api/alerting_test.go
  27. 1 1
      pkg/api/annotations_test.go
  28. 1 2
      pkg/api/api.go
  29. 1 1
      pkg/api/dashboard_snapshot_test.go
  30. 2 2
      pkg/api/dashboard_test.go
  31. 1 1
      pkg/api/metrics.go
  32. 1 0
      pkg/api/playlist.go
  33. 1 0
      pkg/api/team.go
  34. 1 1
      pkg/api/team_test.go
  35. 12 3
      pkg/components/imguploader/webdavuploader.go
  36. 13 0
      pkg/components/imguploader/webdavuploader_test.go
  37. 7 0
      pkg/login/ext_user.go
  38. 4 3
      pkg/login/ldap.go
  39. 4 3
      pkg/login/ldap_settings.go
  40. 42 8
      pkg/login/ldap_test.go
  41. 8 0
      pkg/metrics/metrics.go
  42. 1 1
      pkg/models/playlist.go
  43. 8 8
      pkg/models/team.go
  44. 9 8
      pkg/models/user_auth.go
  45. 3 0
      pkg/plugins/datasource_plugin.go
  46. 5 5
      pkg/services/guardian/guardian.go
  47. 3 3
      pkg/services/guardian/guardian_util_test.go
  48. 1 0
      pkg/services/sqlstore/alert.go
  49. 13 2
      pkg/services/sqlstore/alert_test.go
  50. 2 1
      pkg/services/sqlstore/dashboard_test.go
  51. 1 0
      pkg/services/sqlstore/org_test.go
  52. 28 16
      pkg/services/sqlstore/team.go
  53. 11 4
      pkg/services/sqlstore/user.go
  54. 2 0
      pkg/setting/setting.go
  55. 205 26
      pkg/tsdb/cloudwatch/cloudwatch.go
  56. 4 0
      pkg/tsdb/cloudwatch/types.go
  57. 27 8
      pkg/tsdb/elasticsearch/client/index_pattern.go
  58. 26 1
      pkg/tsdb/elasticsearch/client/index_pattern_test.go
  59. 7 1
      pkg/tsdb/postgres/postgres.go
  60. 1 0
      pkg/tsdb/sql_engine.go
  61. 1 1
      pkg/tsdb/testdata/testdata.go
  62. 209 53
      public/app/containers/Explore/Explore.tsx
  63. 9 0
      public/app/containers/Explore/JSONViewer.tsx
  64. 66 0
      public/app/containers/Explore/Logs.tsx
  65. 125 0
      public/app/containers/Explore/PromQueryField.jest.tsx
  66. 340 0
      public/app/containers/Explore/PromQueryField.tsx
  67. 231 312
      public/app/containers/Explore/QueryField.tsx
  68. 1 5
      public/app/containers/Explore/QueryRows.tsx
  69. 39 22
      public/app/containers/Explore/Typeahead.tsx
  70. 359 58
      public/app/containers/Explore/slate-plugins/prism/promql.ts
  71. 0 12
      public/app/containers/Explore/utils/query.ts
  72. 2 2
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  73. 149 0
      public/app/containers/Teams/TeamGroupSync.tsx
  74. 125 0
      public/app/containers/Teams/TeamList.tsx
  75. 144 0
      public/app/containers/Teams/TeamMembers.tsx
  76. 77 0
      public/app/containers/Teams/TeamPages.tsx
  77. 69 0
      public/app/containers/Teams/TeamSettings.tsx
  78. 0 2
      public/app/core/angular_wrappers.ts
  79. 21 0
      public/app/core/components/Forms/Forms.tsx
  80. 24 24
      public/app/core/components/Permissions/AddPermissions.jest.tsx
  81. 23 38
      public/app/core/components/Permissions/AddPermissions.tsx
  82. 4 3
      public/app/core/components/Permissions/DashboardPermissions.tsx
  83. 1 1
      public/app/core/components/Permissions/DisabledPermissionsListItem.tsx
  84. 1 1
      public/app/core/components/Permissions/PermissionsListItem.tsx
  85. 5 5
      public/app/core/components/Picker/DescriptionPicker.tsx
  86. 14 10
      public/app/core/components/Picker/TeamPicker.jest.tsx
  87. 18 20
      public/app/core/components/Picker/TeamPicker.tsx
  88. 11 10
      public/app/core/components/Picker/UserPicker.jest.tsx
  89. 30 27
      public/app/core/components/Picker/UserPicker.tsx
  90. 0 34
      public/app/core/components/Picker/withPicker.tsx
  91. 3 1
      public/app/core/components/grafana_app.ts
  92. 4 2
      public/app/core/components/scroll/page_scroll.ts
  93. 0 64
      public/app/core/components/team_picker.ts
  94. 0 71
      public/app/core/components/user_picker.ts
  95. 0 4
      public/app/core/core.ts
  96. 29 0
      public/app/core/logs_model.ts
  97. 14 0
      public/app/core/services/backend_srv.ts
  98. 1 1
      public/app/core/services/keybindingSrv.ts
  99. 3 1
      public/app/core/utils/kbn.ts
  100. 1 1
      public/app/features/dashboard/dashnav/dashnav.html

+ 2 - 4
.circleci/config.yml

@@ -246,7 +246,7 @@ workflows:
   test-and-build:
     jobs:
       - build-all:
-          filters: *filter-not-release
+          filters: *filter-only-master
       - build-enterprise:
           filters: *filter-only-master
       - codespell:
@@ -270,9 +270,7 @@ workflows:
             - gometalinter
             - mysql-integration-test
             - postgres-integration-test
-          filters:
-           branches:
-             only: master
+          filters: *filter-only-master           
       - deploy-enterprise-master:
           requires:
             - build-all

+ 13 - 1
CHANGELOG.md

@@ -2,6 +2,8 @@
 
 * **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
 * **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
+* **LDAP**: Define Grafana Admin permission in ldap group mappings [#2469](https://github.com/grafana/grafana/issues/2496), PR [#12622](https://github.com/grafana/grafana/issues/12622)
+* **Cloudwatch**: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
 
 ### Minor
 
@@ -11,18 +13,28 @@
 * **Table**: Make table sorting stable when null values exist [#12362](https://github.com/grafana/grafana/pull/12362), thx [@bz2](https://github.com/bz2)
 * **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379)
 * **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484)
+* **Prometheus**: Add $interval, $interval_ms, $range, and $range_ms support for dashboard and template queries [#12597](https://github.com/grafana/grafana/issues/12597)
 * **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda)
 * **Postgres/MySQL/MSSQL**: Use floor rounding in $__timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm)
 * **MySQL/MSSQL**: Use datetime format instead of epoch for $__timeFilter, $__timeFrom and $__timeTo macros [#11618](https://github.com/grafana/grafana/issues/11618) [#11619](https://github.com/grafana/grafana/issues/11619), thx [@AustinWinstanley](https://github.com/AustinWinstanley)
+* **Postgres**: Escape ssl mode parameter in connectionstring [#12644](https://github.com/grafana/grafana/issues/12644), thx [@yogyrahmawan](https://github.com/yogyrahmawan)
 * **Github OAuth**: Allow changes of user info at Github to be synched to Grafana when signing in [#11818](https://github.com/grafana/grafana/issues/11818), thx [@rwaweber](https://github.com/rwaweber)
 * **Alerting**: Fix diff and percent_diff reducers [#11563](https://github.com/grafana/grafana/issues/11563), thx [@jessetane](https://github.com/jessetane)
+* **Units**: Polish złoty currency [#12691](https://github.com/grafana/grafana/pull/12691), thx [@mwegrzynek](https://github.com/mwegrzynek)
+* **Cloudwatch**: Improved error handling [#12489](https://github.com/grafana/grafana/issues/12489), thx [@mtanda](https://github.com/mtanda)
+* **Table**: Adjust header contrast for the light theme [#12668](https://github.com/grafana/grafana/issues/12668)
+* **Elasticsearch**: For alerting/backend, support having index name to the right of pattern in index pattern [#12731](https://github.com/grafana/grafana/issues/12731)
 
-# 5.2.2 (unreleased)
+# 5.2.2 (2018-07-25)
 
 ### Minor
 
 * **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379)
 * **Dashboard**: Dashboard links not updated when changing variables [#12506](https://github.com/grafana/grafana/issues/12506)
+* **Postgres/MySQL/MSSQL**: Fix connection leak [#12636](https://github.com/grafana/grafana/issues/12636) [#9827](https://github.com/grafana/grafana/issues/9827)
+* **Plugins**: Fix loading of external plugins [#12551](https://github.com/grafana/grafana/issues/12551)
+* **Dashboard**: Remove unwanted scrollbars in embedded panels [#12589](https://github.com/grafana/grafana/issues/12589)
+* **Prometheus**: Prevent error using $__interval_ms in query [#12533](https://github.com/grafana/grafana/pull/12533), thx [@mtanda](https://github.com/mtanda)
 
 # 5.2.1 (2018-06-29)
 

+ 12 - 3
Gopkg.lock

@@ -32,6 +32,7 @@
     "aws/credentials/ec2rolecreds",
     "aws/credentials/endpointcreds",
     "aws/credentials/stscreds",
+    "aws/csm",
     "aws/defaults",
     "aws/ec2metadata",
     "aws/endpoints",
@@ -43,6 +44,8 @@
     "internal/shareddefaults",
     "private/protocol",
     "private/protocol/ec2query",
+    "private/protocol/eventstream",
+    "private/protocol/eventstream/eventstreamapi",
     "private/protocol/query",
     "private/protocol/query/queryutil",
     "private/protocol/rest",
@@ -54,8 +57,8 @@
     "service/s3",
     "service/sts"
   ]
-  revision = "c7cd1ebe87257cde9b65112fc876b0339ea0ac30"
-  version = "v1.13.49"
+  revision = "fde4ded7becdeae4d26bf1212916aabba79349b4"
+  version = "v1.14.12"
 
 [[projects]]
   branch = "master"
@@ -424,6 +427,12 @@
   revision = "1744e2970ca51c86172c8190fadad617561ed6e7"
   version = "v1.0.0"
 
+[[projects]]
+  branch = "master"
+  name = "github.com/shurcooL/sanitized_anchor_name"
+  packages = ["."]
+  revision = "86672fcb3f950f35f2e675df2240550f2a50762f"
+
 [[projects]]
   name = "github.com/smartystreets/assertions"
   packages = [
@@ -670,6 +679,6 @@
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "85cc057e0cc074ab5b43bd620772d63d51e07b04e8782fcfe55e6929d2fc40f7"
+  inputs-digest = "cb8e7fd81f23ec987fc4d5dd9d31ae0f1164bc2f30cbea2fe86e0d97dd945beb"
   solver-name = "gps-cdcl"
   solver-version = 1

+ 1 - 1
Gopkg.toml

@@ -36,7 +36,7 @@ ignored = [
 
 [[constraint]]
   name = "github.com/aws/aws-sdk-go"
-  version = "1.12.65"
+  version = "1.13.56"
 
 [[constraint]]
   branch = "master"

+ 1 - 0
build.go

@@ -330,6 +330,7 @@ func createPackage(options linuxPackageOptions) {
 	name := "grafana"
 	if enterprise {
 		name += "-enterprise"
+		args = append(args, "--replaces", "grafana")
 	}
 	args = append(args, "--name", name)
 

+ 2 - 0
conf/ldap.toml

@@ -72,6 +72,8 @@ email =  "email"
 [[servers.group_mappings]]
 group_dn = "cn=admins,dc=grafana,dc=org"
 org_role = "Admin"
+# To make user an instance admin  (Grafana Admin) uncomment line below
+# grafana_admin = true
 # The Grafana organization database id, optional, if left out the default org (id 1) will be used
 # org_id = 1
 

+ 10 - 5
devenv/README.md

@@ -1,11 +1,16 @@
 This folder contains useful scripts and configuration for...
 
-* Configuring datasources in Grafana
-* Provision example dashboards in Grafana
-* Run preconfiured datasources as docker containers
-
-want to know more? run setup!
+* Configuring dev datasources in Grafana
+* Configuring dev & test scenarios dashboards.
 
 ```bash
 ./setup.sh
 ```
+
+After restarting grafana server there should now be a number of datasources named `gdev-<type>` provisioned as well as a dashboard folder named `gdev dashboards`. This folder contains dashboard & panel features tests dashboards. 
+
+# Dev dashboards
+
+Please update these dashboards or make new ones as new panels & dashboards features are developed or new bugs are found. The dashboards are located in the `devenv/dev-dashboards` folder. 
+
+

+ 6 - 1
devenv/datasources.yaml

@@ -14,6 +14,9 @@ datasources:
     isDefault: true
     url: http://localhost:9090
 
+  - name: gdev-testdata
+    type: testdata
+
   - name: gdev-influxdb
     type: influxdb
     access: proxy
@@ -60,7 +63,8 @@ datasources:
     url: localhost:5432
     database: grafana
     user: grafana
-    password: password
+    secureJsonData:
+      password: password
     jsonData:
       sslmode: "disable"
 
@@ -71,3 +75,4 @@ datasources:
       authType: credentials
       defaultRegion: eu-west-2
 
+

+ 0 - 592
devenv/dev-dashboards/dashboard_with_rows.json

@@ -1,592 +0,0 @@
-{
-  "annotations": {
-    "list": [
-      {
-        "builtIn": 1,
-        "datasource": "-- Grafana --",
-        "enable": true,
-        "hide": true,
-        "iconColor": "rgba(0, 211, 255, 1)",
-        "name": "Annotations & Alerts",
-        "type": "dashboard"
-      }
-    ]
-  },
-  "editable": true,
-  "gnetId": null,
-  "graphTooltip": 0,
-  "id": 59,
-  "links": [],
-  "panels": [
-    {
-      "collapsed": false,
-      "gridPos": {
-        "h": 1,
-        "w": 24,
-        "x": 0,
-        "y": 0
-      },
-      "id": 9,
-      "panels": [],
-      "title": "Row title",
-      "type": "row"
-    },
-    {
-      "aliasColors": {},
-      "bars": false,
-      "dashLength": 10,
-      "dashes": false,
-      "datasource": "Prometheus",
-      "fill": 1,
-      "gridPos": {
-        "h": 4,
-        "w": 12,
-        "x": 0,
-        "y": 1
-      },
-      "id": 12,
-      "legend": {
-        "avg": false,
-        "current": false,
-        "max": false,
-        "min": false,
-        "show": true,
-        "total": false,
-        "values": false
-      },
-      "lines": true,
-      "linewidth": 1,
-      "nullPointMode": "null",
-      "percentage": false,
-      "pointradius": 5,
-      "points": false,
-      "renderer": "flot",
-      "seriesOverrides": [],
-      "spaceLength": 10,
-      "stack": false,
-      "steppedLine": false,
-      "targets": [
-        {
-          "expr": "go_goroutines",
-          "format": "time_series",
-          "intervalFactor": 1,
-          "refId": "A"
-        }
-      ],
-      "thresholds": [],
-      "timeFrom": null,
-      "timeShift": null,
-      "title": "Panel Title",
-      "tooltip": {
-        "shared": true,
-        "sort": 0,
-        "value_type": "individual"
-      },
-      "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
-      }
-    },
-    {
-      "aliasColors": {},
-      "bars": false,
-      "dashLength": 10,
-      "dashes": false,
-      "datasource": "Prometheus",
-      "fill": 1,
-      "gridPos": {
-        "h": 4,
-        "w": 12,
-        "x": 12,
-        "y": 1
-      },
-      "id": 5,
-      "legend": {
-        "avg": false,
-        "current": false,
-        "max": false,
-        "min": false,
-        "show": true,
-        "total": false,
-        "values": false
-      },
-      "lines": true,
-      "linewidth": 1,
-      "nullPointMode": "null",
-      "percentage": false,
-      "pointradius": 5,
-      "points": false,
-      "renderer": "flot",
-      "seriesOverrides": [],
-      "spaceLength": 10,
-      "stack": false,
-      "steppedLine": false,
-      "targets": [
-        {
-          "expr": "go_goroutines",
-          "format": "time_series",
-          "intervalFactor": 1,
-          "refId": "A"
-        }
-      ],
-      "thresholds": [],
-      "timeFrom": null,
-      "timeShift": null,
-      "title": "Panel Title",
-      "tooltip": {
-        "shared": true,
-        "sort": 0,
-        "value_type": "individual"
-      },
-      "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
-      }
-    },
-    {
-      "collapsed": false,
-      "gridPos": {
-        "h": 1,
-        "w": 24,
-        "x": 0,
-        "y": 5
-      },
-      "id": 7,
-      "panels": [],
-      "title": "Row",
-      "type": "row"
-    },
-    {
-      "aliasColors": {},
-      "bars": false,
-      "dashLength": 10,
-      "dashes": false,
-      "datasource": "Prometheus",
-      "fill": 1,
-      "gridPos": {
-        "h": 4,
-        "w": 12,
-        "x": 0,
-        "y": 6
-      },
-      "id": 2,
-      "legend": {
-        "avg": false,
-        "current": false,
-        "max": false,
-        "min": false,
-        "show": true,
-        "total": false,
-        "values": false
-      },
-      "lines": true,
-      "linewidth": 1,
-      "nullPointMode": "null",
-      "percentage": false,
-      "pointradius": 5,
-      "points": false,
-      "renderer": "flot",
-      "seriesOverrides": [],
-      "spaceLength": 10,
-      "stack": false,
-      "steppedLine": false,
-      "targets": [
-        {
-          "expr": "go_goroutines",
-          "format": "time_series",
-          "intervalFactor": 1,
-          "refId": "A"
-        }
-      ],
-      "thresholds": [],
-      "timeFrom": null,
-      "timeShift": null,
-      "title": "Panel Title",
-      "tooltip": {
-        "shared": true,
-        "sort": 0,
-        "value_type": "individual"
-      },
-      "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
-      }
-    },
-    {
-      "aliasColors": {},
-      "bars": false,
-      "dashLength": 10,
-      "dashes": false,
-      "datasource": "Prometheus",
-      "fill": 1,
-      "gridPos": {
-        "h": 4,
-        "w": 12,
-        "x": 12,
-        "y": 6
-      },
-      "id": 13,
-      "legend": {
-        "avg": false,
-        "current": false,
-        "max": false,
-        "min": false,
-        "show": true,
-        "total": false,
-        "values": false
-      },
-      "lines": true,
-      "linewidth": 1,
-      "nullPointMode": "null",
-      "percentage": false,
-      "pointradius": 5,
-      "points": false,
-      "renderer": "flot",
-      "seriesOverrides": [],
-      "spaceLength": 10,
-      "stack": false,
-      "steppedLine": false,
-      "targets": [
-        {
-          "expr": "go_goroutines",
-          "format": "time_series",
-          "intervalFactor": 1,
-          "refId": "A"
-        }
-      ],
-      "thresholds": [],
-      "timeFrom": null,
-      "timeShift": null,
-      "title": "Panel Title",
-      "tooltip": {
-        "shared": true,
-        "sort": 0,
-        "value_type": "individual"
-      },
-      "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
-      }
-    },
-    {
-      "collapsed": false,
-      "gridPos": {
-        "h": 1,
-        "w": 24,
-        "x": 0,
-        "y": 10
-      },
-      "id": 11,
-      "panels": [],
-      "title": "Row title",
-      "type": "row"
-    },
-    {
-      "aliasColors": {},
-      "bars": false,
-      "dashLength": 10,
-      "dashes": false,
-      "datasource": "Prometheus",
-      "fill": 1,
-      "gridPos": {
-        "h": 4,
-        "w": 12,
-        "x": 0,
-        "y": 11
-      },
-      "id": 4,
-      "legend": {
-        "avg": false,
-        "current": false,
-        "max": false,
-        "min": false,
-        "show": true,
-        "total": false,
-        "values": false
-      },
-      "lines": true,
-      "linewidth": 1,
-      "nullPointMode": "null",
-      "percentage": false,
-      "pointradius": 5,
-      "points": false,
-      "renderer": "flot",
-      "seriesOverrides": [],
-      "spaceLength": 10,
-      "stack": false,
-      "steppedLine": false,
-      "targets": [
-        {
-          "expr": "go_goroutines",
-          "format": "time_series",
-          "intervalFactor": 1,
-          "refId": "A"
-        }
-      ],
-      "thresholds": [],
-      "timeFrom": null,
-      "timeShift": null,
-      "title": "Panel Title",
-      "tooltip": {
-        "shared": true,
-        "sort": 0,
-        "value_type": "individual"
-      },
-      "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
-      }
-    },
-    {
-      "aliasColors": {},
-      "bars": false,
-      "dashLength": 10,
-      "dashes": false,
-      "datasource": "Prometheus",
-      "fill": 1,
-      "gridPos": {
-        "h": 4,
-        "w": 12,
-        "x": 12,
-        "y": 11
-      },
-      "id": 3,
-      "legend": {
-        "avg": false,
-        "current": false,
-        "max": false,
-        "min": false,
-        "show": true,
-        "total": false,
-        "values": false
-      },
-      "lines": true,
-      "linewidth": 1,
-      "nullPointMode": "null",
-      "percentage": false,
-      "pointradius": 5,
-      "points": false,
-      "renderer": "flot",
-      "seriesOverrides": [],
-      "spaceLength": 10,
-      "stack": false,
-      "steppedLine": false,
-      "targets": [
-        {
-          "expr": "go_goroutines",
-          "format": "time_series",
-          "intervalFactor": 1,
-          "refId": "A"
-        }
-      ],
-      "thresholds": [],
-      "timeFrom": null,
-      "timeShift": null,
-      "title": "Panel Title",
-      "tooltip": {
-        "shared": true,
-        "sort": 0,
-        "value_type": "individual"
-      },
-      "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
-      }
-    }
-  ],
-  "schemaVersion": 16,
-  "style": "dark",
-  "tags": [],
-  "templating": {
-    "list": []
-  },
-  "time": {
-    "from": "now-30m",
-    "to": "now"
-  },
-  "timepicker": {
-    "refresh_intervals": [
-      "5s",
-      "10s",
-      "30s",
-      "1m",
-      "5m",
-      "15m",
-      "30m",
-      "1h",
-      "2h",
-      "1d"
-    ],
-    "time_options": [
-      "5m",
-      "15m",
-      "1h",
-      "6h",
-      "12h",
-      "24h",
-      "2d",
-      "7d",
-      "30d"
-    ]
-  },
-  "timezone": "",
-  "title": "Dashboard with rows",
-  "uid": "1DdOzBNmk",
-  "version": 5
-}

+ 1558 - 0
devenv/dev-dashboards/panel_tests_graph.json

@@ -0,0 +1,1558 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 0
+      },
+      "id": 1,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "no_data_points",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "No Data Points Warning",
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 0
+      },
+      "id": 2,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "datapoints_outside_range",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Datapoints Outside Range Warning",
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 0
+      },
+      "id": 3,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Random walk series",
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 16,
+        "x": 0,
+        "y": 7
+      },
+      "id": 4,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenario": "random_walk",
+          "scenarioId": "random_walk",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": "2s",
+      "timeShift": null,
+      "title": "Millisecond res x-axis and tooltip",
+      "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": "Just verify that the tooltip time has millisecond resolution ",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 7
+      },
+      "id": 6,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 9,
+        "w": 16,
+        "x": 0,
+        "y": 14
+      },
+      "id": 5,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "B-series",
+          "yaxis": 2
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "2000,3000,4000,1000,3000,10000",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "2 yaxis and axis labels",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "percent",
+          "label": "Perecent",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": "Pressure",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "content": "Verify that axis labels look ok",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 9,
+        "w": 8,
+        "x": 16,
+        "y": 14
+      },
+      "id": 7,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 23
+      },
+      "id": 8,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "connected",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "null value connected",
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 23
+      },
+      "id": 10,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null as zero",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "null value null as zero",
+      "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": "Should be a long line connecting the null region in the `connected`  mode, and in zero it should just be a line with zero value at the null points. ",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 23
+      },
+      "id": 13,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 16,
+        "x": 0,
+        "y": 30
+      },
+      "id": 9,
+      "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": "B-series",
+          "zindex": -3
+        }
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "hide": false,
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Stacking value ontop of nulls",
+      "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": "Stacking values on top of nulls, should treat the null values as zero. ",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 30
+      },
+      "id": 14,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "editable": true,
+      "error": false,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 16,
+        "x": 0,
+        "y": 37
+      },
+      "id": 12,
+      "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": "B-series",
+          "zindex": -3
+        }
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        },
+        {
+          "alias": "",
+          "hide": false,
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Stacking all series null segment",
+      "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": "Stacking when all values are null should leave a gap in the graph",
+      "editable": true,
+      "error": false,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 37
+      },
+      "id": 15,
+      "links": [],
+      "mode": "markdown",
+      "title": "",
+      "type": "text"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 44
+      },
+      "id": 20,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table Single Series Should Take Minimum Height",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 51
+      },
+      "id": 16,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table No Scroll Visible",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 51
+      },
+      "id": 17,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "E",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "F",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "G",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "H",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "I",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "J",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table Should Scroll",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 58
+      },
+      "id": 18,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table No Scroll Visible",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "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
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "decimals": 3,
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 58
+      },
+      "id": 19,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "total": true,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "C",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "D",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "E",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "F",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "G",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "H",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "I",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "J",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "K",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        },
+        {
+          "refId": "L",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0",
+          "target": ""
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Legend Table No Scroll Visible",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "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
+      }
+    }
+  ],
+  "refresh": false,
+  "revision": 8,
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "gdev",
+    "panel-tests"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "browser",
+  "title": "Panel Tests - Graph",
+  "uid": "5SdHCadmz",
+  "version": 3
+}

+ 574 - 0
devenv/dev-dashboards/panel_tests_singlestat.json

@@ -0,0 +1,574 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "#299c46",
+        "rgba(237, 129, 40, 0.89)",
+        "#d44a3a"
+      ],
+      "datasource": "gdev-testdata",
+      "decimals": null,
+      "description": "",
+      "format": "ms",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 0
+      },
+      "id": 2,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "postfix",
+      "postfixFontSize": "50%",
+      "prefix": "prefix",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": false,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,2,3,4,5"
+        }
+      ],
+      "thresholds": "5,10",
+      "title": "prefix 3 ms (green) postfixt + sparkline",
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorPrefix": false,
+      "colorValue": true,
+      "colors": [
+        "#d44a3a",
+        "rgba(237, 129, 40, 0.89)",
+        "#299c46"
+      ],
+      "datasource": "gdev-testdata",
+      "decimals": null,
+      "description": "",
+      "format": "ms",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 0
+      },
+      "id": 3,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,2,3,4,5"
+        }
+      ],
+      "thresholds": "5,10",
+      "title": "3 ms (red)  + full height sparkline",
+      "type": "singlestat",
+      "valueFontSize": "200%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": true,
+      "colorPrefix": false,
+      "colorValue": false,
+      "colors": [
+        "#d44a3a",
+        "rgba(237, 129, 40, 0.89)",
+        "#299c46"
+      ],
+      "datasource": "gdev-testdata",
+      "decimals": null,
+      "description": "",
+      "format": "ms",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 0
+      },
+      "id": 4,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,2,3,4,5"
+        }
+      ],
+      "thresholds": "5,10",
+      "title": "3 ms + red background",
+      "type": "singlestat",
+      "valueFontSize": "200%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorPrefix": false,
+      "colorValue": true,
+      "colors": [
+        "#299c46",
+        "rgba(237, 129, 40, 0.89)",
+        "#d44a3a"
+      ],
+      "datasource": "gdev-testdata",
+      "decimals": null,
+      "description": "",
+      "format": "ms",
+      "gauge": {
+        "maxValue": 150,
+        "minValue": 0,
+        "show": true,
+        "thresholdLabels": true,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 7
+      },
+      "id": 5,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "10,20,80"
+        }
+      ],
+      "thresholds": "81,90",
+      "title": "80 ms green gauge, thresholds 81, 90",
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "current"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorPrefix": false,
+      "colorValue": true,
+      "colors": [
+        "#299c46",
+        "rgba(237, 129, 40, 0.89)",
+        "#d44a3a"
+      ],
+      "datasource": "gdev-testdata",
+      "decimals": null,
+      "description": "",
+      "format": "ms",
+      "gauge": {
+        "maxValue": 150,
+        "minValue": 0,
+        "show": true,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 7
+      },
+      "id": 6,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "10,20,80"
+        }
+      ],
+      "thresholds": "81,90",
+      "title": "80 ms green gauge, thresholds 81, 90, no labels",
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "current"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorPrefix": false,
+      "colorValue": true,
+      "colors": [
+        "#299c46",
+        "rgba(237, 129, 40, 0.89)",
+        "#d44a3a"
+      ],
+      "datasource": "gdev-testdata",
+      "decimals": null,
+      "description": "",
+      "format": "ms",
+      "gauge": {
+        "maxValue": 150,
+        "minValue": 0,
+        "show": true,
+        "thresholdLabels": false,
+        "thresholdMarkers": false
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 7
+      },
+      "id": 7,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "expr": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "10,20,80"
+        }
+      ],
+      "thresholds": "81,90",
+      "title": "80 ms green gauge, thresholds 81, 90, no markers or labels",
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "current"
+    }
+  ],
+  "refresh": false,
+  "revision": 8,
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "gdev",
+    "panel-tests"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "browser",
+  "title": "Panel Tests - Singlestat",
+  "uid": "singlestat",
+  "version": 14
+}

+ 453 - 0
devenv/dev-dashboards/panel_tests_table.json

@@ -0,0 +1,453 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "columns": [],
+      "datasource": "gdev-testdata",
+      "fontSize": "100%",
+      "gridPos": {
+        "h": 11,
+        "w": 12,
+        "x": 0,
+        "y": 0
+      },
+      "id": 3,
+      "links": [],
+      "pageSize": 10,
+      "scroll": true,
+      "showHeader": true,
+      "sort": {
+        "col": 0,
+        "desc": true
+      },
+      "styles": [
+        {
+          "alias": "Time",
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "pattern": "Time",
+          "type": "date"
+        },
+        {
+          "alias": "",
+          "colorMode": "cell",
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "mappingType": 1,
+          "pattern": "ColorCell",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "currencyUSD"
+        },
+        {
+          "alias": "",
+          "colorMode": "value",
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "mappingType": 1,
+          "pattern": "ColorValue",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "Bps"
+        },
+        {
+          "alias": "",
+          "colorMode": null,
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "decimals": 2,
+          "pattern": "/.*/",
+          "thresholds": [],
+          "type": "number",
+          "unit": "short"
+        }
+      ],
+      "targets": [
+        {
+          "alias": "server1",
+          "expr": "",
+          "format": "table",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0,20,10"
+        },
+        {
+          "alias": "server2",
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "title": "Time series to rows (2 pages)",
+      "transform": "timeseries_to_rows",
+      "type": "table"
+    },
+    {
+      "columns": [
+        {
+          "text": "Avg",
+          "value": "avg"
+        },
+        {
+          "text": "Max",
+          "value": "max"
+        },
+        {
+          "text": "Current",
+          "value": "current"
+        }
+      ],
+      "datasource": "gdev-testdata",
+      "fontSize": "100%",
+      "gridPos": {
+        "h": 11,
+        "w": 12,
+        "x": 12,
+        "y": 0
+      },
+      "id": 4,
+      "links": [],
+      "pageSize": 10,
+      "scroll": true,
+      "showHeader": true,
+      "sort": {
+        "col": 0,
+        "desc": true
+      },
+      "styles": [
+        {
+          "alias": "Time",
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "pattern": "Time",
+          "type": "date"
+        },
+        {
+          "alias": "",
+          "colorMode": "cell",
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "mappingType": 1,
+          "pattern": "ColorCell",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "currencyUSD"
+        },
+        {
+          "alias": "",
+          "colorMode": "value",
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "mappingType": 1,
+          "pattern": "ColorValue",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "Bps"
+        },
+        {
+          "alias": "",
+          "colorMode": null,
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "decimals": 2,
+          "pattern": "/.*/",
+          "thresholds": [],
+          "type": "number",
+          "unit": "short"
+        }
+      ],
+      "targets": [
+        {
+          "alias": "server1",
+          "expr": "",
+          "format": "table",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0,20,10"
+        },
+        {
+          "alias": "server2",
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0"
+        }
+      ],
+      "title": "Time series aggregations",
+      "transform": "timeseries_aggregations",
+      "type": "table"
+    },
+    {
+      "columns": [],
+      "datasource": "gdev-testdata",
+      "fontSize": "100%",
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 11
+      },
+      "id": 5,
+      "links": [],
+      "pageSize": null,
+      "scroll": true,
+      "showHeader": true,
+      "sort": {
+        "col": 0,
+        "desc": true
+      },
+      "styles": [
+        {
+          "alias": "Time",
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "pattern": "Time",
+          "type": "date"
+        },
+        {
+          "alias": "",
+          "colorMode": "row",
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "mappingType": 1,
+          "pattern": "/Color/",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "currencyUSD"
+        },
+        {
+          "alias": "",
+          "colorMode": null,
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "decimals": 2,
+          "pattern": "/.*/",
+          "thresholds": [],
+          "type": "number",
+          "unit": "short"
+        }
+      ],
+      "targets": [
+        {
+          "alias": "ColorValue",
+          "expr": "",
+          "format": "table",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0,20,10"
+        }
+      ],
+      "title": "color row by threshold",
+      "transform": "timeseries_to_columns",
+      "type": "table"
+    },
+    {
+      "columns": [],
+      "datasource": "gdev-testdata",
+      "fontSize": "100%",
+      "gridPos": {
+        "h": 8,
+        "w": 24,
+        "x": 0,
+        "y": 18
+      },
+      "id": 2,
+      "links": [],
+      "pageSize": null,
+      "scroll": true,
+      "showHeader": true,
+      "sort": {
+        "col": 0,
+        "desc": true
+      },
+      "styles": [
+        {
+          "alias": "Time",
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "pattern": "Time",
+          "type": "date"
+        },
+        {
+          "alias": "",
+          "colorMode": "cell",
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "mappingType": 1,
+          "pattern": "ColorCell",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "currencyUSD"
+        },
+        {
+          "alias": "",
+          "colorMode": "value",
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "dateFormat": "YYYY-MM-DD HH:mm:ss",
+          "decimals": 2,
+          "mappingType": 1,
+          "pattern": "ColorValue",
+          "thresholds": [
+            "5",
+            "10"
+          ],
+          "type": "number",
+          "unit": "Bps"
+        },
+        {
+          "alias": "",
+          "colorMode": null,
+          "colors": [
+            "rgba(245, 54, 54, 0.9)",
+            "rgba(237, 129, 40, 0.89)",
+            "rgba(50, 172, 45, 0.97)"
+          ],
+          "decimals": 2,
+          "pattern": "/.*/",
+          "thresholds": [],
+          "type": "number",
+          "unit": "short"
+        }
+      ],
+      "targets": [
+        {
+          "alias": "ColorValue",
+          "expr": "",
+          "format": "table",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "1,20,90,30,5,0,20,10"
+        },
+        {
+          "alias": "ColorCell",
+          "refId": "B",
+          "scenarioId": "csv_metric_values",
+          "stringInput": "5,1,2,3,4,5,10,20"
+        }
+      ],
+      "title": "Column style thresholds & units",
+      "transform": "timeseries_to_columns",
+      "type": "table"
+    }
+  ],
+  "refresh": false,
+  "revision": 8,
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "gdev",
+    "panel-tests"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "browser",
+  "title": "Panel Tests - Table",
+  "uid": "pttable",
+  "version": 1
+}

+ 3 - 3
public/app/plugins/app/testdata/dashboards/alerts.json → devenv/dev-dashboards/testdata_alerts.json

@@ -1,6 +1,6 @@
 {
   "revision": 2,
-  "title": "TestData - Alerts",
+  "title": "Alerting with TestData",
   "tags": [
     "grafana-test"
   ],
@@ -48,7 +48,7 @@
           },
           "aliasColors": {},
           "bars": false,
-          "datasource": "Grafana TestData",
+          "datasource": "gdev-testdata",
           "editable": true,
           "error": false,
           "fill": 1,
@@ -161,7 +161,7 @@
           },
           "aliasColors": {},
           "bars": false,
-          "datasource": "Grafana TestData",
+          "datasource": "gdev-testdata",
           "editable": true,
           "error": false,
           "fill": 1,

+ 13 - 7
devenv/setup.sh

@@ -1,4 +1,4 @@
-#/bin/bash
+#!/bin/bash
 
 bulkDashboard() {
 
@@ -22,31 +22,37 @@ requiresJsonnet() {
 		fi
 }
 
-defaultDashboards() {
+devDashboards() {
+		echo -e "\xE2\x9C\x94 Setting up all dev dashboards using provisioning"
 		ln -s -f ../../../devenv/dashboards.yaml ../conf/provisioning/dashboards/dev.yaml
 }
 
-defaultDatasources() {
-		echo "setting up all default datasources using provisioning"
+devDatasources() {
+		echo -e "\xE2\x9C\x94 Setting up all dev datasources using provisioning"
 
 		ln -s -f ../../../devenv/datasources.yaml ../conf/provisioning/datasources/dev.yaml
 }
 
 usage() {
-	echo -e "install.sh\n\tThis script setups dev provision for datasources and dashboards"
+	echo -e "\n"
 	echo "Usage:"
 	echo "  bulk-dashboards                     - create and provisioning 400 dashboards"
 	echo "  no args                             - provisiong core datasources and dev dashboards"
 }
 
 main() {
+	echo -e "------------------------------------------------------------------"
+	echo -e "This script setups provisioning for dev datasources and dashboards"
+	echo -e "------------------------------------------------------------------"
+	echo -e "\n"
+
 	local cmd=$1
 
 	if [[ $cmd == "bulk-dashboards" ]]; then
 		bulkDashboard
 	else
-		defaultDashboards
-		defaultDatasources
+		devDashboards
+		devDatasources
 	fi
 
   if [[ -z "$cmd" ]]; then

+ 2 - 1
docker/blocks/nginx_proxy/Dockerfile

@@ -1,3 +1,4 @@
 FROM nginx:alpine
 
-COPY nginx.conf /etc/nginx/nginx.conf
+COPY nginx.conf /etc/nginx/nginx.conf
+COPY htpasswd /etc/nginx/htpasswd

+ 3 - 0
docker/blocks/nginx_proxy/htpasswd

@@ -0,0 +1,3 @@
+user1:$apr1$1odeeQb.$kwV8D/VAAGUDU7pnHuKoV0
+user2:$apr1$A2kf25r.$6S0kp3C7vIuixS5CL0XA9.
+admin:$apr1$IWn4DoRR$E2ol7fS/dkI18eU4bXnBO1

+ 20 - 1
docker/blocks/nginx_proxy/nginx.conf

@@ -13,7 +13,26 @@ http {
     listen 10080;
 
     location /grafana/ {
+      ################################################################
+      # Enable these settings to test with basic auth and an auth proxy header
+      # the htpasswd file contains an admin user with password admin and
+      # user1: grafana and user2: grafana
+      ################################################################
+
+      # auth_basic "Restricted Content";
+      # auth_basic_user_file /etc/nginx/htpasswd;
+
+      ################################################################
+      # To use the auth proxy header, set the following in custom.ini:
+      # [auth.proxy]
+      # enabled = true
+      # header_name = X-WEBAUTH-USER
+      # header_property = username
+      ################################################################
+
+      # proxy_set_header X-WEBAUTH-USER $remote_user;
+
       proxy_pass http://localhost:3000/;
     }
   }
-}
+}

+ 85 - 0
docker/blocks/openldap/ldap_dev.toml

@@ -0,0 +1,85 @@
+# To troubleshoot and get more log info enable ldap debug logging in grafana.ini
+# [log]
+# filters = ldap:debug
+
+[[servers]]
+# Ldap server host (specify multiple hosts space separated)
+host = "127.0.0.1"
+# Default port is 389 or 636 if use_ssl = true
+port = 389
+# Set to true if ldap server supports TLS
+use_ssl = false
+# Set to true if connect ldap server with STARTTLS pattern (create connection in insecure, then upgrade to secure connection with TLS)
+start_tls = false
+# set to true if you want to skip ssl cert validation
+ssl_skip_verify = false
+# set to the path to your root CA certificate or leave unset to use system defaults
+# root_ca_cert = "/path/to/certificate.crt"
+
+# Search user bind dn
+bind_dn = "cn=admin,dc=grafana,dc=org"
+# Search user bind password
+# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
+bind_password = 'grafana'
+
+# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)"
+search_filter = "(cn=%s)"
+
+# An array of base dns to search through
+search_base_dns = ["dc=grafana,dc=org"]
+
+# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups.
+# This is done by enabling group_search_filter below. You must also set member_of= "cn"
+# in [servers.attributes] below.
+
+# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN
+# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of
+# below in such a way that the user's recursive group membership is considered.
+#
+# Nested Groups + Active Directory (AD) Example:
+#
+#   AD groups store the Distinguished Names (DNs) of members, so your filter must
+#   recursively search your groups for the authenticating user's DN. For example:
+#
+#     group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)"
+#     group_search_filter_user_attribute = "distinguishedName"
+#     group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
+#
+#     [servers.attributes]
+#     ...
+#     member_of = "distinguishedName"
+
+## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available)
+# group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))"
+## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter.
+## Defaults to the value of username in [server.attributes]
+## Valid options are any of your values in [servers.attributes]
+## If you are using nested groups you probably want to set this and member_of in
+## [servers.attributes] to "distinguishedName"
+# group_search_filter_user_attribute = "distinguishedName"
+## An array of the base DNs to search through for groups. Typically uses ou=groups
+# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
+
+# Specify names of the ldap attributes your ldap uses
+[servers.attributes]
+name = "givenName"
+surname = "sn"
+username = "cn"
+member_of = "memberOf"
+email =  "email"
+
+# Map ldap groups to grafana org roles
+[[servers.group_mappings]]
+group_dn = "cn=admins,ou=groups,dc=grafana,dc=org"
+org_role = "Admin"
+# The Grafana organization database id, optional, if left out the default org (id 1) will be used
+# org_id = 1
+
+[[servers.group_mappings]]
+group_dn = "cn=editors,ou=groups,dc=grafana,dc=org"
+org_role = "Editor"
+
+[[servers.group_mappings]]
+# If you want to match all (or no ldap groups) then you can use wildcard
+group_dn = "*"
+org_role = "Viewer"

+ 2 - 5
docker/blocks/openldap/notes.md

@@ -14,12 +14,12 @@ After adding ldif files to `prepopulate`:
 
 ## Enabling LDAP in Grafana
 
-The default `ldap.toml` file in `conf` has host set to `127.0.0.1` and port to set to 389 so all you need to do is enable it in the .ini file to get Grafana to use this block:
+Copy the ldap_dev.toml file in this folder into your `conf` folder (it is gitignored already). To enable it in the .ini file to get Grafana to use this block:
 
 ```ini
 [auth.ldap]
 enabled = true
-config_file = conf/ldap.toml
+config_file = conf/ldap_dev.toml
 ; allow_sign_up = true
 ```
 
@@ -43,6 +43,3 @@ editors
 
 no groups
   ldap-viewer
-
-
-

+ 26 - 0
docs/sources/features/datasources/prometheus.md

@@ -75,6 +75,32 @@ Name | Description
 
 For details of *metric names*, *label names* and *label values* are please refer to the [Prometheus documentation](http://prometheus.io/docs/concepts/data_model/#metric-names-and-labels).
 
+
+#### Using interval and range variables
+
+> Support for `$__range` and `$__range_ms` only available from Grafana v5.3
+
+It's possible to use some global built-in variables in query variables; `$__interval`, `$__interval_ms`, `$__range` and `$__range_ms`, see [Global built-in variables](/reference/templating/#global-built-in-variables) for more information. These can be convenient to use in conjunction with the `query_result` function when you need to filter variable queries since
+`label_values` function doesn't support queries.
+
+Make sure to set the variable's `refresh` trigger to be `On Time Range Change` to get the correct instances when changing the time range on the dashboard.
+
+**Example usage:**
+
+Populate a variable with the the busiest 5 request instances based on average QPS over the time range shown in the dashboard:
+
+```
+Query: query_result(topk(5, sum(rate(http_requests_total[$__range])) by (instance)))
+Regex: /"([^"]+)"/
+```
+
+Populate a variable with the instances having a certain state over the time range shown in the dashboard:
+
+```
+Query: query_result(max_over_time(<metric>[$__range]) != <state>)
+Regex:
+```
+
 ### Using variables in queries
 
 There are two syntaxes:

+ 286 - 0
docs/sources/http_api/playlist.md

@@ -0,0 +1,286 @@
++++
+title = "Playlist HTTP API "
+description = "Playlist Admin HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "playlist"]
+aliases = ["/http_api/playlist/"]
+type = "docs"
+[menu.docs]
+name = "Playlist"
+parent = "http_api"
++++
+
+# Playlist API
+
+## Search Playlist
+
+`GET /api/playlists`
+
+Get all existing playlist for the current organization using pagination
+
+**Example Request**:
+
+```bash
+GET /api/playlists HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+  Querystring Parameters:
+
+  These parameters are used as querystring parameters.
+  
+  - **query** - Limit response to playlist having a name like this value.
+  - **limit** - Limit response to *X* number of playlist.
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+[
+  {
+    "id": 1,
+    "name": "my playlist",
+    "interval": "5m"
+  }
+]
+```
+
+## Get one playlist
+
+`GET /api/playlists/:id`
+
+**Example Request**:
+
+```bash
+GET /api/playlists/1 HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+{
+  "id" : 1,
+  "name": "my playlist",
+  "interval": "5m",
+  "orgId": "my org",
+  "items": [
+    {
+      "id": 1,
+      "playlistId": 1,
+      "type": "dashboard_by_id",
+      "value": "3",
+      "order": 1,
+      "title":"my third dasboard"
+    },
+    {
+      "id": 2,
+      "playlistId": 1,
+      "type": "dashboard_by_tag",
+      "value": "myTag",
+      "order": 2,
+      "title":"my other dasboard"
+    }
+  ]
+}
+```
+
+## Get Playlist items
+
+`GET /api/playlists/:id/items`
+
+**Example Request**:
+
+```bash
+GET /api/playlists/1/items HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+[
+  {
+    "id": 1,
+    "playlistId": 1,
+    "type": "dashboard_by_id",
+    "value": "3",
+    "order": 1,
+    "title":"my third dasboard"
+  },
+  {
+    "id": 2,
+    "playlistId": 1,
+    "type": "dashboard_by_tag",
+    "value": "myTag",
+    "order": 2,
+    "title":"my other dasboard"
+  }
+]
+```
+
+## Get Playlist dashboards
+
+`GET /api/playlists/:id/dashboards`
+
+**Example Request**:
+
+```bash
+GET /api/playlists/1/dashboards HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+[
+  {
+    "id": 3,
+    "title": "my third dasboard",
+    "order": 1,
+  },
+  {
+    "id": 5,
+    "title":"my other dasboard"
+    "order": 2,
+    
+  }
+]
+```
+
+## Create a playlist
+
+`POST /api/playlists/`
+
+**Example Request**:
+
+```bash
+PUT /api/playlists/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+  {
+    "name": "my playlist",
+    "interval": "5m",
+    "items": [
+      {
+        "type": "dashboard_by_id",
+        "value": "3",
+        "order": 1,
+        "title":"my third dasboard"
+      },
+      {
+        "type": "dashboard_by_tag",
+        "value": "myTag",
+        "order": 2,
+        "title":"my other dasboard"
+      }
+    ]
+  }
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+  {
+    "id": 1,
+    "name": "my playlist",
+    "interval": "5m"
+  }
+```
+
+## Update a playlist
+
+`PUT /api/playlists/:id`
+
+**Example Request**:
+
+```bash
+PUT /api/playlists/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+  {
+    "name": "my playlist",
+    "interval": "5m",
+    "items": [
+      {
+        "playlistId": 1,
+        "type": "dashboard_by_id",
+        "value": "3",
+        "order": 1,
+        "title":"my third dasboard"
+      },
+      {
+        "playlistId": 1,
+        "type": "dashboard_by_tag",
+        "value": "myTag",
+        "order": 2,
+        "title":"my other dasboard"
+      }
+    ]
+  }
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+{
+  "id" : 1,
+  "name": "my playlist",
+  "interval": "5m",
+  "orgId": "my org",
+  "items": [
+    {
+      "id": 1,
+      "playlistId": 1,
+      "type": "dashboard_by_id",
+      "value": "3",
+      "order": 1,
+      "title":"my third dasboard"
+    },
+    {
+      "id": 2,
+      "playlistId": 1,
+      "type": "dashboard_by_tag",
+      "value": "myTag",
+      "order": 2,
+      "title":"my other dasboard"
+    }
+  ]
+}
+```
+
+## Delete a playlist
+
+`DELETE /api/playlists/:id`
+
+**Example Request**:
+
+```bash
+DELETE /api/playlists/1 HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+{}
+```

+ 9 - 1
docs/sources/installation/configuration.md

@@ -15,6 +15,8 @@ weight = 1
 The Grafana back-end has a number of configuration options that can be
 specified in a `.ini` configuration file or specified using environment variables.
 
+> **Note.** Grafana needs to be restarted for any configuration changes to take effect.
+
 ## Comments In .ini Files
 
 Semicolons (the `;` char) are the standard way to comment out lines in a `.ini` file.
@@ -296,6 +298,12 @@ Set to `true` to automatically add new users to the main organization
 (id 1). When set to `false`, new users will automatically cause a new
 organization to be created for that new user.
 
+### auto_assign_org_id
+
+Set this value to automatically add new users to the provided org.
+This requires `auto_assign_org` to be set to `true`. Please make sure
+that this organization does already exists.
+
 ### auto_assign_org_role
 
 The role new users will be assigned for the main organization (if the
@@ -857,7 +865,7 @@ Secret key. e.g. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
 Url to where Grafana will send PUT request with images
 
 ### public_url
-Optional parameter. Url to send to users in notifications, directly appended with the resulting uploaded file name.
+Optional parameter. Url to send to users in notifications. If the string contains the sequence ${file}, it will be replaced with the uploaded filename. Otherwise, the file name will be appended to the path part of the url, leaving any query string unchanged.
 
 ### username
 basic auth username

+ 9 - 2
docs/sources/installation/ldap.md

@@ -23,8 +23,9 @@ specific configuration file (default: `/etc/grafana/ldap.toml`).
 ### Example config
 
 ```toml
-# Set to true to log user information returned from LDAP
-verbose_logging = false
+# To troubleshoot and get more log info enable ldap debug logging in grafana.ini
+# [log]
+# filters = ldap:debug
 
 [[servers]]
 # Ldap server host (specify multiple hosts space separated)
@@ -73,6 +74,8 @@ email =  "email"
 [[servers.group_mappings]]
 group_dn = "cn=admins,dc=grafana,dc=org"
 org_role = "Admin"
+# To make user an instance admin  (Grafana Admin) uncomment line below
+# grafana_admin = true
 # The Grafana organization database id, optional, if left out the default org (id 1) will be used.  Setting this allows for multiple group_dn's to be assigned to the same org_role provided the org_id differs
 # org_id = 1
 
@@ -132,6 +135,10 @@ Users page, this change will be reset the next time the user logs in. If you
 change the LDAP groups of a user, the change will take effect the next
 time the user logs in.
 
+### Grafana Admin
+with a servers.group_mappings section you can set grafana_admin = true or false to sync Grafana Admin permission. A Grafana server admin has admin access over all orgs &
+users.
+
 ### Priority
 The first group mapping that an LDAP user is matched to will be used for the sync. If you have LDAP users that fit multiple mappings, the topmost mapping in the TOML config will be used.
 

+ 7 - 1
docs/sources/reference/templating.md

@@ -11,7 +11,7 @@ weight = 1
 # Variables
 
 Variables allows for more interactive and dynamic dashboards. Instead of hard-coding things like server, application
-and sensor name in you metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
+and sensor name in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
 the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
 
 {{< docs-imagebox img="/img/docs/v50/variables_dashboard.png" >}}
@@ -273,6 +273,12 @@ The `$__timeFilter` is used in the MySQL data source.
 
 This variable is only available in the Singlestat panel and can be used in the prefix or suffix fields on the Options tab. The variable will be replaced with the series name or alias.
 
+### The $__range Variable
+
+> Only available in Grafana v5.3+
+
+Currently only supported for Prometheus data sources. This variable represents the range for the current dashboard. It is calculated by `to - from`. It has a millisecond representation called `$__range_ms`.
+
 ## Repeating Panels
 
 Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want

+ 8 - 8
package.json

@@ -34,7 +34,7 @@
     "expose-loader": "^0.7.3",
     "extract-text-webpack-plugin": "^4.0.0-beta.0",
     "file-loader": "^1.1.11",
-    "fork-ts-checker-webpack-plugin": "^0.4.1",
+    "fork-ts-checker-webpack-plugin": "^0.4.2",
     "gaze": "^1.1.2",
     "glob": "~7.0.0",
     "grunt": "1.0.1",
@@ -71,12 +71,14 @@
     "karma-webpack": "^3.0.0",
     "lint-staged": "^6.0.0",
     "load-grunt-tasks": "3.5.2",
+    "mini-css-extract-plugin": "^0.4.0",
     "mobx-react-devtools": "^4.2.15",
     "mocha": "^4.0.1",
     "ng-annotate-loader": "^0.6.1",
     "ng-annotate-webpack-plugin": "^0.2.1-pre",
     "ngtemplate-loader": "^2.0.1",
     "npm": "^5.4.2",
+    "optimize-css-assets-webpack-plugin": "^4.0.2",
     "phantomjs-prebuilt": "^2.1.15",
     "postcss-browser-reporter": "^0.5.0",
     "postcss-loader": "^2.0.6",
@@ -90,15 +92,16 @@
     "style-loader": "^0.21.0",
     "systemjs": "0.20.19",
     "systemjs-plugin-css": "^0.1.36",
-    "ts-loader": "^4.3.0",
     "ts-jest": "^22.4.6",
+    "ts-loader": "^4.3.0",
+    "tslib": "^1.9.3",
     "tslint": "^5.8.0",
     "tslint-loader": "^3.5.3",
     "typescript": "^2.6.2",
+    "uglifyjs-webpack-plugin": "^1.2.7",
     "webpack": "^4.8.0",
     "webpack-bundle-analyzer": "^2.9.0",
     "webpack-cleanup-plugin": "^0.5.1",
-    "fork-ts-checker-webpack-plugin": "^0.4.2",
     "webpack-cli": "^2.1.4",
     "webpack-dev-server": "^3.1.0",
     "webpack-merge": "^4.1.0",
@@ -155,14 +158,12 @@
     "immutable": "^3.8.2",
     "jquery": "^3.2.1",
     "lodash": "^4.17.10",
-    "mini-css-extract-plugin": "^0.4.0",
     "mobx": "^3.4.1",
     "mobx-react": "^4.3.5",
     "mobx-state-tree": "^1.3.1",
     "moment": "^2.22.2",
     "mousetrap": "^1.6.0",
     "mousetrap-global-bind": "^1.1.0",
-    "optimize-css-assets-webpack-plugin": "^4.0.2",
     "prismjs": "^1.6.0",
     "prop-types": "^15.6.0",
     "react": "^16.2.0",
@@ -181,10 +182,9 @@
     "slate-react": "^0.12.4",
     "tether": "^1.4.0",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
-    "tinycolor2": "^1.4.1",
-    "uglifyjs-webpack-plugin": "^1.2.7"
+    "tinycolor2": "^1.4.1"
   },
   "resolutions": {
     "caniuse-db": "1.0.30000772"
   }
-}
+}

+ 1 - 1
pkg/api/alerting_test.go

@@ -31,7 +31,7 @@ func TestAlertingApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 

+ 1 - 1
pkg/api/annotations_test.go

@@ -119,7 +119,7 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 

+ 1 - 2
pkg/api/api.go

@@ -73,8 +73,7 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
 
-	r.Get("/explore/", reqEditorRole, Index)
-	r.Get("/explore/*", reqEditorRole, Index)
+	r.Get("/explore", reqEditorRole, Index)
 
 	r.Get("/playlists/", reqSignedIn, Index)
 	r.Get("/playlists/*", reqSignedIn, Index)

+ 1 - 1
pkg/api/dashboard_snapshot_test.go

@@ -39,7 +39,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 			return nil
 		})
 
-		teamResp := []*m.Team{}
+		teamResp := []*m.TeamDTO{}
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
 			query.Result = teamResp
 			return nil

+ 2 - 2
pkg/api/dashboard_test.go

@@ -61,7 +61,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 
@@ -230,7 +230,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		})
 
 		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
-			query.Result = []*m.Team{}
+			query.Result = []*m.TeamDTO{}
 			return nil
 		})
 

+ 1 - 1
pkg/api/metrics.go

@@ -52,7 +52,7 @@ func QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response {
 		if res.Error != nil {
 			res.ErrorString = res.Error.Error()
 			resp.Message = res.ErrorString
-			statusCode = 500
+			statusCode = 400
 		}
 	}
 

+ 1 - 0
pkg/api/playlist.go

@@ -160,6 +160,7 @@ func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response {
 
 func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response {
 	cmd.OrgId = c.OrgId
+	cmd.Id = c.ParamsInt64(":id")
 
 	if err := bus.Dispatch(&cmd); err != nil {
 		return Error(500, "Failed to save playlist", err)

+ 1 - 0
pkg/api/team.go

@@ -93,5 +93,6 @@ func GetTeamByID(c *m.ReqContext) Response {
 		return Error(500, "Failed to get Team", err)
 	}
 
+	query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
 	return JSON(200, &query.Result)
 }

+ 1 - 1
pkg/api/team_test.go

@@ -13,7 +13,7 @@ import (
 func TestTeamApiEndpoint(t *testing.T) {
 	Convey("Given two teams", t, func() {
 		mockResult := models.SearchTeamQueryResult{
-			Teams: []*models.SearchTeamDto{
+			Teams: []*models.TeamDTO{
 				{Name: "team1"},
 				{Name: "team2"},
 			},

+ 12 - 3
pkg/components/imguploader/webdavuploader.go

@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"net/url"
 	"path"
+	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/util"
@@ -35,6 +36,16 @@ var netClient = &http.Client{
 	Transport: netTransport,
 }
 
+func (u *WebdavUploader) PublicURL(filename string) string {
+	if strings.Contains(u.public_url, "${file}") {
+		return strings.Replace(u.public_url, "${file}", filename, -1)
+	} else {
+		publicURL, _ := url.Parse(u.public_url)
+		publicURL.Path = path.Join(publicURL.Path, filename)
+		return publicURL.String()
+	}
+}
+
 func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) {
 	url, _ := url.Parse(u.url)
 	filename := util.GetRandomString(20) + ".png"
@@ -65,9 +76,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error)
 	}
 
 	if u.public_url != "" {
-		publicURL, _ := url.Parse(u.public_url)
-		publicURL.Path = path.Join(publicURL.Path, filename)
-		return publicURL.String(), nil
+		return u.PublicURL(filename), nil
 	}
 
 	return url.String(), nil

+ 13 - 0
pkg/components/imguploader/webdavuploader_test.go

@@ -2,6 +2,7 @@ package imguploader
 
 import (
 	"context"
+	"net/url"
 	"testing"
 
 	. "github.com/smartystreets/goconvey/convey"
@@ -26,3 +27,15 @@ func TestUploadToWebdav(t *testing.T) {
 		So(path, ShouldStartWith, "http://publicurl:8888/webdav/")
 	})
 }
+
+func TestPublicURL(t *testing.T) {
+	Convey("Given a public URL with parameters, and no template", t, func() {
+		webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=")
+		parsed, _ := url.Parse(webdavUploader.PublicURL("fileyfile.png"))
+		So(parsed.Path, ShouldEndWith, "fileyfile.png")
+	})
+	Convey("Given a public URL with parameters, and a template", t, func() {
+		webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=${file}")
+		So(webdavUploader.PublicURL("fileyfile.png"), ShouldEndWith, "fileyfile.png")
+	})
+}

+ 7 - 0
pkg/login/ext_user.go

@@ -72,6 +72,13 @@ func UpsertUser(cmd *m.UpsertUserCommand) error {
 		return err
 	}
 
+	// Sync isGrafanaAdmin permission
+	if extUser.IsGrafanaAdmin != nil && *extUser.IsGrafanaAdmin != cmd.Result.IsAdmin {
+		if err := bus.Dispatch(&m.UpdateUserPermissionsCommand{UserId: cmd.Result.Id, IsGrafanaAdmin: *extUser.IsGrafanaAdmin}); err != nil {
+			return err
+		}
+	}
+
 	err = bus.Dispatch(&m.SyncTeamsCommand{
 		User:         cmd.Result,
 		ExternalUser: extUser,

+ 4 - 3
pkg/login/ldap.go

@@ -175,6 +175,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
 
 		if ldapUser.isMemberOf(group.GroupDN) {
 			extUser.OrgRoles[group.OrgId] = group.OrgRole
+			extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
 		}
 	}
 
@@ -190,18 +191,18 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
 	}
 
 	// add/update user in grafana
-	userQuery := &m.UpsertUserCommand{
+	upsertUserCmd := &m.UpsertUserCommand{
 		ReqContext:    ctx,
 		ExternalUser:  extUser,
 		SignupAllowed: setting.LdapAllowSignup,
 	}
 
-	err := bus.Dispatch(userQuery)
+	err := bus.Dispatch(upsertUserCmd)
 	if err != nil {
 		return nil, err
 	}
 
-	return userQuery.Result, nil
+	return upsertUserCmd.Result, nil
 }
 
 func (a *ldapAuther) serverBind() error {

+ 4 - 3
pkg/login/ldap_settings.go

@@ -44,9 +44,10 @@ type LdapAttributeMap struct {
 }
 
 type LdapGroupToOrgRole struct {
-	GroupDN string     `toml:"group_dn"`
-	OrgId   int64      `toml:"org_id"`
-	OrgRole m.RoleType `toml:"org_role"`
+	GroupDN        string     `toml:"group_dn"`
+	OrgId          int64      `toml:"org_id"`
+	IsGrafanaAdmin *bool      `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatability)
+	OrgRole        m.RoleType `toml:"org_role"`
 }
 
 var LdapCfg LdapConfig

+ 42 - 8
pkg/login/ldap_test.go

@@ -98,6 +98,10 @@ func TestLdapAuther(t *testing.T) {
 				So(result.Login, ShouldEqual, "torkelo")
 			})
 
+			Convey("Should set isGrafanaAdmin to false by default", func() {
+				So(result.IsAdmin, ShouldBeFalse)
+			})
+
 		})
 
 	})
@@ -223,8 +227,32 @@ func TestLdapAuther(t *testing.T) {
 				So(sc.addOrgUserCmd.Role, ShouldEqual, m.ROLE_ADMIN)
 				So(sc.setUsingOrgCmd.OrgId, ShouldEqual, 1)
 			})
+
+			Convey("Should not update permissions unless specified", func() {
+				So(err, ShouldBeNil)
+				So(sc.updateUserPermissionsCmd, ShouldBeNil)
+			})
 		})
 
+		ldapAutherScenario("given ldap groups with grafana_admin=true", func(sc *scenarioContext) {
+			trueVal := true
+
+			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
+				LdapGroups: []*LdapGroupToOrgRole{
+					{GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin", IsGrafanaAdmin: &trueVal},
+				},
+			})
+
+			sc.userOrgsQueryReturns([]*m.UserOrgDTO{})
+			_, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{
+				MemberOf: []string{"cn=admins"},
+			})
+
+			Convey("Should create user with admin set to true", func() {
+				So(err, ShouldBeNil)
+				So(sc.updateUserPermissionsCmd.IsGrafanaAdmin, ShouldBeTrue)
+			})
+		})
 	})
 
 	Convey("When calling SyncUser", t, func() {
@@ -332,6 +360,11 @@ func ldapAutherScenario(desc string, fn scenarioFunc) {
 			return nil
 		})
 
+		bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.UpdateUserPermissionsCommand) error {
+			sc.updateUserPermissionsCmd = cmd
+			return nil
+		})
+
 		bus.AddHandler("test", func(cmd *m.GetUserByAuthInfoQuery) error {
 			sc.getUserByAuthInfoQuery = cmd
 			sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login}
@@ -379,14 +412,15 @@ func ldapAutherScenario(desc string, fn scenarioFunc) {
 }
 
 type scenarioContext struct {
-	getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery
-	getUserOrgListQuery    *m.GetUserOrgListQuery
-	createUserCmd          *m.CreateUserCommand
-	addOrgUserCmd          *m.AddOrgUserCommand
-	updateOrgUserCmd       *m.UpdateOrgUserCommand
-	removeOrgUserCmd       *m.RemoveOrgUserCommand
-	updateUserCmd          *m.UpdateUserCommand
-	setUsingOrgCmd         *m.SetUsingOrgCommand
+	getUserByAuthInfoQuery   *m.GetUserByAuthInfoQuery
+	getUserOrgListQuery      *m.GetUserOrgListQuery
+	createUserCmd            *m.CreateUserCommand
+	addOrgUserCmd            *m.AddOrgUserCommand
+	updateOrgUserCmd         *m.UpdateOrgUserCommand
+	removeOrgUserCmd         *m.RemoveOrgUserCommand
+	updateUserCmd            *m.UpdateUserCommand
+	setUsingOrgCmd           *m.SetUsingOrgCommand
+	updateUserPermissionsCmd *m.UpdateUserPermissionsCommand
 }
 
 func (sc *scenarioContext) userQueryReturns(user *m.User) {

+ 8 - 0
pkg/metrics/metrics.go

@@ -44,6 +44,7 @@ var (
 	M_Alerting_Notification_Sent         *prometheus.CounterVec
 	M_Aws_CloudWatch_GetMetricStatistics prometheus.Counter
 	M_Aws_CloudWatch_ListMetrics         prometheus.Counter
+	M_Aws_CloudWatch_GetMetricData       prometheus.Counter
 	M_DB_DataSource_QueryById            prometheus.Counter
 
 	// Timers
@@ -218,6 +219,12 @@ func init() {
 		Namespace: exporterName,
 	})
 
+	M_Aws_CloudWatch_GetMetricData = prometheus.NewCounter(prometheus.CounterOpts{
+		Name:      "aws_cloudwatch_get_metric_data_total",
+		Help:      "counter for getting metric data time series from aws",
+		Namespace: exporterName,
+	})
+
 	M_DB_DataSource_QueryById = prometheus.NewCounter(prometheus.CounterOpts{
 		Name:      "db_datasource_query_by_id_total",
 		Help:      "counter for getting datasource by id",
@@ -307,6 +314,7 @@ func initMetricVars() {
 		M_Alerting_Notification_Sent,
 		M_Aws_CloudWatch_GetMetricStatistics,
 		M_Aws_CloudWatch_ListMetrics,
+		M_Aws_CloudWatch_GetMetricData,
 		M_DB_DataSource_QueryById,
 		M_Alerting_Active_Alerts,
 		M_StatTotal_Dashboards,

+ 1 - 1
pkg/models/playlist.go

@@ -63,7 +63,7 @@ type PlaylistDashboards []*PlaylistDashboard
 
 type UpdatePlaylistCommand struct {
 	OrgId    int64             `json:"-"`
-	Id       int64             `json:"id" binding:"Required"`
+	Id       int64             `json:"id"`
 	Name     string            `json:"name" binding:"Required"`
 	Interval string            `json:"interval"`
 	Items    []PlaylistItemDTO `json:"items"`

+ 8 - 8
pkg/models/team.go

@@ -49,13 +49,13 @@ type DeleteTeamCommand struct {
 type GetTeamByIdQuery struct {
 	OrgId  int64
 	Id     int64
-	Result *Team
+	Result *TeamDTO
 }
 
 type GetTeamsByUserQuery struct {
 	OrgId  int64
-	UserId int64   `json:"userId"`
-	Result []*Team `json:"teams"`
+	UserId int64      `json:"userId"`
+	Result []*TeamDTO `json:"teams"`
 }
 
 type SearchTeamsQuery struct {
@@ -68,7 +68,7 @@ type SearchTeamsQuery struct {
 	Result SearchTeamQueryResult
 }
 
-type SearchTeamDto struct {
+type TeamDTO struct {
 	Id          int64  `json:"id"`
 	OrgId       int64  `json:"orgId"`
 	Name        string `json:"name"`
@@ -78,8 +78,8 @@ type SearchTeamDto struct {
 }
 
 type SearchTeamQueryResult struct {
-	TotalCount int64            `json:"totalCount"`
-	Teams      []*SearchTeamDto `json:"teams"`
-	Page       int              `json:"page"`
-	PerPage    int              `json:"perPage"`
+	TotalCount int64      `json:"totalCount"`
+	Teams      []*TeamDTO `json:"teams"`
+	Page       int        `json:"page"`
+	PerPage    int        `json:"perPage"`
 }

+ 9 - 8
pkg/models/user_auth.go

@@ -13,14 +13,15 @@ type UserAuth struct {
 }
 
 type ExternalUserInfo struct {
-	AuthModule string
-	AuthId     string
-	UserId     int64
-	Email      string
-	Login      string
-	Name       string
-	Groups     []string
-	OrgRoles   map[int64]RoleType
+	AuthModule     string
+	AuthId         string
+	UserId         int64
+	Email          string
+	Login          string
+	Name           string
+	Groups         []string
+	OrgRoles       map[int64]RoleType
+	IsGrafanaAdmin *bool // This is a pointer to know if we should sync this or not (nil = ignore sync)
 }
 
 // ---------------------

+ 3 - 0
pkg/plugins/datasource_plugin.go

@@ -17,11 +17,14 @@ import (
 	plugin "github.com/hashicorp/go-plugin"
 )
 
+// DataSourcePlugin contains all metadata about a datasource plugin
 type DataSourcePlugin struct {
 	FrontendPluginBase
 	Annotations  bool              `json:"annotations"`
 	Metrics      bool              `json:"metrics"`
 	Alerting     bool              `json:"alerting"`
+	Explore      bool              `json:"explore"`
+	Logs         bool              `json:"logs"`
 	QueryOptions map[string]bool   `json:"queryOptions,omitempty"`
 	BuiltIn      bool              `json:"builtIn,omitempty"`
 	Mixed        bool              `json:"mixed,omitempty"`

+ 5 - 5
pkg/services/guardian/guardian.go

@@ -30,7 +30,7 @@ type dashboardGuardianImpl struct {
 	dashId int64
 	orgId  int64
 	acl    []*m.DashboardAclInfoDTO
-	groups []*m.Team
+	teams  []*m.TeamDTO
 	log    log.Logger
 }
 
@@ -186,15 +186,15 @@ func (g *dashboardGuardianImpl) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
 	return g.acl, nil
 }
 
-func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) {
-	if g.groups != nil {
-		return g.groups, nil
+func (g *dashboardGuardianImpl) getTeams() ([]*m.TeamDTO, error) {
+	if g.teams != nil {
+		return g.teams, nil
 	}
 
 	query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId}
 	err := bus.Dispatch(&query)
 
-	g.groups = query.Result
+	g.teams = query.Result
 	return query.Result, err
 }
 

+ 3 - 3
pkg/services/guardian/guardian_util_test.go

@@ -19,7 +19,7 @@ type scenarioContext struct {
 	givenUser          *m.SignedInUser
 	givenDashboardID   int64
 	givenPermissions   []*m.DashboardAclInfoDTO
-	givenTeams         []*m.Team
+	givenTeams         []*m.TeamDTO
 	updatePermissions  []*m.DashboardAcl
 	expectedFlags      permissionFlags
 	callerFile         string
@@ -84,11 +84,11 @@ func permissionScenario(desc string, dashboardID int64, sc *scenarioContext, per
 		return nil
 	})
 
-	teams := []*m.Team{}
+	teams := []*m.TeamDTO{}
 
 	for _, p := range permissions {
 		if p.TeamId > 0 {
-			teams = append(teams, &m.Team{Id: p.TeamId})
+			teams = append(teams, &m.TeamDTO{Id: p.TeamId})
 		}
 	}
 

+ 1 - 0
pkg/services/sqlstore/alert.go

@@ -73,6 +73,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 		alert.name,
 		alert.state,
 		alert.new_state_date,
+		alert.eval_data,
 		alert.eval_date,
 		alert.execution_error,
 		dashboard.uid as dashboard_uid,

+ 13 - 2
pkg/services/sqlstore/alert_test.go

@@ -13,7 +13,7 @@ func mockTimeNow() {
 	var timeSeed int64
 	timeNow = func() time.Time {
 		fakeNow := time.Unix(timeSeed, 0)
-		timeSeed += 1
+		timeSeed++
 		return fakeNow
 	}
 }
@@ -30,7 +30,7 @@ func TestAlertingDataAccess(t *testing.T) {
 		InitTestDB(t)
 
 		testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
-
+		evalData, _ := simplejson.NewJson([]byte(`{"test": "test"}`))
 		items := []*m.Alert{
 			{
 				PanelId:     1,
@@ -40,6 +40,7 @@ func TestAlertingDataAccess(t *testing.T) {
 				Message:     "Alerting message",
 				Settings:    simplejson.New(),
 				Frequency:   1,
+				EvalData:    evalData,
 			},
 		}
 
@@ -104,8 +105,18 @@ func TestAlertingDataAccess(t *testing.T) {
 
 			alert := alertQuery.Result[0]
 			So(err2, ShouldBeNil)
+			So(alert.Id, ShouldBeGreaterThan, 0)
+			So(alert.DashboardId, ShouldEqual, testDash.Id)
+			So(alert.PanelId, ShouldEqual, 1)
 			So(alert.Name, ShouldEqual, "Alerting title")
 			So(alert.State, ShouldEqual, "pending")
+			So(alert.NewStateDate, ShouldNotBeNil)
+			So(alert.EvalData, ShouldNotBeNil)
+			So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test")
+			So(alert.EvalDate, ShouldNotBeNil)
+			So(alert.ExecutionError, ShouldEqual, "")
+			So(alert.DashboardUid, ShouldNotBeNil)
+			So(alert.DashboardSlug, ShouldEqual, "dashboard-with-alerts")
 		})
 
 		Convey("Viewer cannot read alerts", func() {

+ 2 - 1
pkg/services/sqlstore/dashboard_test.go

@@ -181,7 +181,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldBeNil)
 				So(query.Result.FolderId, ShouldEqual, 0)
 				So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy)
-				So(query.Result.Created, ShouldEqual, savedDash.Created.Truncate(time.Second))
+				So(query.Result.Created, ShouldHappenWithin, 3*time.Second, savedDash.Created)
 				So(query.Result.UpdatedBy, ShouldEqual, 100)
 				So(query.Result.Updated.IsZero(), ShouldBeFalse)
 			})
@@ -387,6 +387,7 @@ func insertTestDashboardForPlugin(title string, orgId int64, folderId int64, isF
 
 func createUser(name string, role string, isAdmin bool) m.User {
 	setting.AutoAssignOrg = true
+	setting.AutoAssignOrgId = 1
 	setting.AutoAssignOrgRole = role
 
 	currentUserCmd := m.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin}

+ 1 - 0
pkg/services/sqlstore/org_test.go

@@ -17,6 +17,7 @@ func TestAccountDataAccess(t *testing.T) {
 
 		Convey("Given single org mode", func() {
 			setting.AutoAssignOrg = true
+			setting.AutoAssignOrgId = 1
 			setting.AutoAssignOrgRole = "Viewer"
 
 			Convey("Users should be added to default organization", func() {

+ 28 - 16
pkg/services/sqlstore/team.go

@@ -22,6 +22,16 @@ func init() {
 	bus.AddHandler("sql", GetTeamMembers)
 }
 
+func getTeamSelectSqlBase() string {
+	return `SELECT
+		team.id as id,
+		team.org_id,
+		team.name as name,
+		team.email as email,
+		(SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count
+		FROM team as team `
+}
+
 func CreateTeam(cmd *m.CreateTeamCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 
@@ -130,21 +140,15 @@ func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession
 
 func SearchTeams(query *m.SearchTeamsQuery) error {
 	query.Result = m.SearchTeamQueryResult{
-		Teams: make([]*m.SearchTeamDto, 0),
+		Teams: make([]*m.TeamDTO, 0),
 	}
 	queryWithWildcards := "%" + query.Query + "%"
 
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 
-	sql.WriteString(`select
-		team.id as id,
-		team.org_id,
-		team.name as name,
-		team.email as email,
-		(select count(*) from team_member where team_member.team_id = team.id) as member_count
-		from team as team
-		where team.org_id = ?`)
+	sql.WriteString(getTeamSelectSqlBase())
+	sql.WriteString(` WHERE team.org_id = ?`)
 
 	params = append(params, query.OrgId)
 
@@ -186,8 +190,14 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 }
 
 func GetTeamById(query *m.GetTeamByIdQuery) error {
-	var team m.Team
-	exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team)
+	var sql bytes.Buffer
+
+	sql.WriteString(getTeamSelectSqlBase())
+	sql.WriteString(` WHERE team.org_id = ? and team.id = ?`)
+
+	var team m.TeamDTO
+	exists, err := x.Sql(sql.String(), query.OrgId, query.Id).Get(&team)
+
 	if err != nil {
 		return err
 	}
@@ -202,13 +212,15 @@ func GetTeamById(query *m.GetTeamByIdQuery) error {
 
 // GetTeamsByUser is used by the Guardian when checking a users' permissions
 func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
-	query.Result = make([]*m.Team, 0)
+	query.Result = make([]*m.TeamDTO, 0)
+
+	var sql bytes.Buffer
 
-	sess := x.Table("team")
-	sess.Join("INNER", "team_member", "team.id=team_member.team_id")
-	sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId)
+	sql.WriteString(getTeamSelectSqlBase())
+	sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`)
+	sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`)
 
-	err := sess.Find(&query.Result)
+	err := x.Sql(sql.String(), query.OrgId, query.UserId).Find(&query.Result)
 	return err
 }
 

+ 11 - 4
pkg/services/sqlstore/user.go

@@ -42,16 +42,23 @@ func getOrgIdForNewUser(cmd *m.CreateUserCommand, sess *DBSession) (int64, error
 	var org m.Org
 
 	if setting.AutoAssignOrg {
-		// right now auto assign to org with id 1
-		has, err := sess.Where("id=?", 1).Get(&org)
+		has, err := sess.Where("id=?", setting.AutoAssignOrgId).Get(&org)
 		if err != nil {
 			return 0, err
 		}
 		if has {
 			return org.Id, nil
+		} else {
+			if setting.AutoAssignOrgId == 1 {
+				org.Name = "Main Org."
+				org.Id = int64(setting.AutoAssignOrgId)
+			} else {
+				sqlog.Info("Could not create user: organization id %v does not exist",
+					setting.AutoAssignOrgId)
+				return 0, fmt.Errorf("Could not create user: organization id %v does not exist",
+					setting.AutoAssignOrgId)
+			}
 		}
-		org.Name = "Main Org."
-		org.Id = 1
 	} else {
 		org.Name = cmd.OrgName
 		if len(org.Name) == 0 {

+ 2 - 0
pkg/setting/setting.go

@@ -100,6 +100,7 @@ var (
 	AllowUserSignUp         bool
 	AllowUserOrgCreate      bool
 	AutoAssignOrg           bool
+	AutoAssignOrgId         int
 	AutoAssignOrgRole       string
 	VerifyEmailEnabled      bool
 	LoginHint               string
@@ -592,6 +593,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	AllowUserSignUp = users.Key("allow_sign_up").MustBool(true)
 	AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true)
 	AutoAssignOrg = users.Key("auto_assign_org").MustBool(true)
+	AutoAssignOrgId = users.Key("auto_assign_org_id").MustInt(1)
 	AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"})
 	VerifyEmailEnabled = users.Key("verify_email_enabled").MustBool(false)
 	LoginHint = users.Key("login_hint").String()

+ 205 - 26
pkg/tsdb/cloudwatch/cloudwatch.go

@@ -14,8 +14,10 @@ import (
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/tsdb"
+	"golang.org/x/sync/errgroup"
 
 	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/aws/request"
 	"github.com/aws/aws-sdk-go/service/cloudwatch"
 	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
@@ -88,48 +90,80 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
 		Results: make(map[string]*tsdb.QueryResult),
 	}
 
-	errCh := make(chan error, 1)
-	resCh := make(chan *tsdb.QueryResult, 1)
+	eg, ectx := errgroup.WithContext(ctx)
 
-	currentlyExecuting := 0
+	getMetricDataQueries := make(map[string]map[string]*CloudWatchQuery)
 	for i, model := range queryContext.Queries {
 		queryType := model.Model.Get("type").MustString()
 		if queryType != "timeSeriesQuery" && queryType != "" {
 			continue
 		}
-		currentlyExecuting++
-		go func(refId string, index int) {
-			queryRes, err := e.executeQuery(ctx, queryContext.Queries[index].Model, queryContext)
-			currentlyExecuting--
+
+		RefId := queryContext.Queries[i].RefId
+		query, err := parseQuery(queryContext.Queries[i].Model)
+		if err != nil {
+			result.Results[RefId] = &tsdb.QueryResult{
+				Error: err,
+			}
+			return result, nil
+		}
+		query.RefId = RefId
+
+		if query.Id != "" {
+			if _, ok := getMetricDataQueries[query.Region]; !ok {
+				getMetricDataQueries[query.Region] = make(map[string]*CloudWatchQuery)
+			}
+			getMetricDataQueries[query.Region][query.Id] = query
+			continue
+		}
+
+		if query.Id == "" && query.Expression != "" {
+			result.Results[query.RefId] = &tsdb.QueryResult{
+				Error: fmt.Errorf("Invalid query: id should be set if using expression"),
+			}
+			return result, nil
+		}
+
+		eg.Go(func() error {
+			queryRes, err := e.executeQuery(ectx, query, queryContext)
+			if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
+				return err
+			}
+			result.Results[queryRes.RefId] = queryRes
 			if err != nil {
-				errCh <- err
-			} else {
-				queryRes.RefId = refId
-				resCh <- queryRes
+				result.Results[queryRes.RefId].Error = err
 			}
-		}(model.RefId, i)
+			return nil
+		})
 	}
 
-	for currentlyExecuting != 0 {
-		select {
-		case res := <-resCh:
-			result.Results[res.RefId] = res
-		case err := <-errCh:
-			return result, err
-		case <-ctx.Done():
-			return result, ctx.Err()
+	if len(getMetricDataQueries) > 0 {
+		for region, getMetricDataQuery := range getMetricDataQueries {
+			q := getMetricDataQuery
+			eg.Go(func() error {
+				queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext)
+				if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
+					return err
+				}
+				for _, queryRes := range queryResponses {
+					result.Results[queryRes.RefId] = queryRes
+					if err != nil {
+						result.Results[queryRes.RefId].Error = err
+					}
+				}
+				return nil
+			})
 		}
 	}
 
-	return result, nil
-}
-
-func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
-	query, err := parseQuery(parameters)
-	if err != nil {
+	if err := eg.Wait(); err != nil {
 		return nil, err
 	}
 
+	return result, nil
+}
+
+func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
 	client, err := e.getClient(query.Region)
 	if err != nil {
 		return nil, err
@@ -201,6 +235,139 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simpl
 	return queryRes, nil
 }
 
+func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, region string, queries map[string]*CloudWatchQuery, queryContext *tsdb.TsdbQuery) ([]*tsdb.QueryResult, error) {
+	queryResponses := make([]*tsdb.QueryResult, 0)
+
+	// validate query
+	for _, query := range queries {
+		if !(len(query.Statistics) == 1 && len(query.ExtendedStatistics) == 0) &&
+			!(len(query.Statistics) == 0 && len(query.ExtendedStatistics) == 1) {
+			return queryResponses, errors.New("Statistics count should be 1")
+		}
+	}
+
+	client, err := e.getClient(region)
+	if err != nil {
+		return queryResponses, err
+	}
+
+	startTime, err := queryContext.TimeRange.ParseFrom()
+	if err != nil {
+		return queryResponses, err
+	}
+
+	endTime, err := queryContext.TimeRange.ParseTo()
+	if err != nil {
+		return queryResponses, err
+	}
+
+	params := &cloudwatch.GetMetricDataInput{
+		StartTime: aws.Time(startTime),
+		EndTime:   aws.Time(endTime),
+		ScanBy:    aws.String("TimestampAscending"),
+	}
+	for _, query := range queries {
+		// 1 minutes resolutin metrics is stored for 15 days, 15 * 24 * 60 = 21600
+		if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) {
+			return nil, errors.New("too long query period")
+		}
+
+		mdq := &cloudwatch.MetricDataQuery{
+			Id:         aws.String(query.Id),
+			ReturnData: aws.Bool(query.ReturnData),
+		}
+		if query.Expression != "" {
+			mdq.Expression = aws.String(query.Expression)
+		} else {
+			mdq.MetricStat = &cloudwatch.MetricStat{
+				Metric: &cloudwatch.Metric{
+					Namespace:  aws.String(query.Namespace),
+					MetricName: aws.String(query.MetricName),
+				},
+				Period: aws.Int64(int64(query.Period)),
+			}
+			for _, d := range query.Dimensions {
+				mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions,
+					&cloudwatch.Dimension{
+						Name:  d.Name,
+						Value: d.Value,
+					})
+			}
+			if len(query.Statistics) == 1 {
+				mdq.MetricStat.Stat = query.Statistics[0]
+			} else {
+				mdq.MetricStat.Stat = query.ExtendedStatistics[0]
+			}
+		}
+		params.MetricDataQueries = append(params.MetricDataQueries, mdq)
+	}
+
+	nextToken := ""
+	mdr := make(map[string]*cloudwatch.MetricDataResult)
+	for {
+		if nextToken != "" {
+			params.NextToken = aws.String(nextToken)
+		}
+		resp, err := client.GetMetricDataWithContext(ctx, params)
+		if err != nil {
+			return queryResponses, err
+		}
+		metrics.M_Aws_CloudWatch_GetMetricData.Add(float64(len(params.MetricDataQueries)))
+
+		for _, r := range resp.MetricDataResults {
+			if _, ok := mdr[*r.Id]; !ok {
+				mdr[*r.Id] = r
+			} else {
+				mdr[*r.Id].Timestamps = append(mdr[*r.Id].Timestamps, r.Timestamps...)
+				mdr[*r.Id].Values = append(mdr[*r.Id].Values, r.Values...)
+			}
+		}
+
+		if resp.NextToken == nil || *resp.NextToken == "" {
+			break
+		}
+		nextToken = *resp.NextToken
+	}
+
+	for i, r := range mdr {
+		if *r.StatusCode != "Complete" {
+			return queryResponses, fmt.Errorf("Part of query is failed: %s", *r.StatusCode)
+		}
+
+		queryRes := tsdb.NewQueryResult()
+		queryRes.RefId = queries[i].RefId
+		query := queries[*r.Id]
+
+		series := tsdb.TimeSeries{
+			Tags:   map[string]string{},
+			Points: make([]tsdb.TimePoint, 0),
+		}
+		for _, d := range query.Dimensions {
+			series.Tags[*d.Name] = *d.Value
+		}
+		s := ""
+		if len(query.Statistics) == 1 {
+			s = *query.Statistics[0]
+		} else {
+			s = *query.ExtendedStatistics[0]
+		}
+		series.Name = formatAlias(query, s, series.Tags)
+
+		for j, t := range r.Timestamps {
+			expectedTimestamp := r.Timestamps[j].Add(time.Duration(query.Period) * time.Second)
+			if j > 0 && expectedTimestamp.Before(*t) {
+				series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(expectedTimestamp.Unix()*1000)))
+			}
+			series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(*r.Values[j]), float64((*t).Unix())*1000))
+		}
+
+		queryRes.Series = append(queryRes.Series, &series)
+		queryResponses = append(queryResponses, queryRes)
+	}
+
+	return queryResponses, nil
+}
+
 func parseDimensions(model *simplejson.Json) ([]*cloudwatch.Dimension, error) {
 	var result []*cloudwatch.Dimension
 
@@ -257,6 +424,9 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
 		return nil, err
 	}
 
+	id := model.Get("id").MustString("")
+	expression := model.Get("expression").MustString("")
+
 	dimensions, err := parseDimensions(model)
 	if err != nil {
 		return nil, err
@@ -295,6 +465,7 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
 		alias = "{{metric}}_{{stat}}"
 	}
 
+	returnData := model.Get("returnData").MustBool(false)
 	highResolution := model.Get("highResolution").MustBool(false)
 
 	return &CloudWatchQuery{
@@ -306,11 +477,18 @@ func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) {
 		ExtendedStatistics: aws.StringSlice(extendedStatistics),
 		Period:             period,
 		Alias:              alias,
+		Id:                 id,
+		Expression:         expression,
+		ReturnData:         returnData,
 		HighResolution:     highResolution,
 	}, nil
 }
 
 func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string {
+	if len(query.Id) > 0 && len(query.Expression) > 0 {
+		return query.Id
+	}
+
 	data := map[string]string{}
 	data["region"] = query.Region
 	data["namespace"] = query.Namespace
@@ -338,6 +516,7 @@ func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]stri
 func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatchQuery) (*tsdb.QueryResult, error) {
 	queryRes := tsdb.NewQueryResult()
 
+	queryRes.RefId = query.RefId
 	var value float64
 	for _, s := range append(query.Statistics, query.ExtendedStatistics...) {
 		series := tsdb.TimeSeries{

+ 4 - 0
pkg/tsdb/cloudwatch/types.go

@@ -5,6 +5,7 @@ import (
 )
 
 type CloudWatchQuery struct {
+	RefId              string
 	Region             string
 	Namespace          string
 	MetricName         string
@@ -13,5 +14,8 @@ type CloudWatchQuery struct {
 	ExtendedStatistics []*string
 	Period             int
 	Alias              string
+	Id                 string
+	Expression         string
+	ReturnData         bool
 	HighResolution     bool
 }

+ 27 - 8
pkg/tsdb/elasticsearch/client/index_pattern.go

@@ -248,13 +248,28 @@ var datePatternReplacements = map[string]string{
 
 func formatDate(t time.Time, pattern string) string {
 	var datePattern string
-	parts := strings.Split(strings.TrimLeft(pattern, "["), "]")
-	base := parts[0]
-	if len(parts) == 2 {
-		datePattern = parts[1]
-	} else {
-		datePattern = base
-		base = ""
+	base := ""
+	ltr := false
+
+	if strings.HasPrefix(pattern, "[") {
+		parts := strings.Split(strings.TrimLeft(pattern, "["), "]")
+		base = parts[0]
+		if len(parts) == 2 {
+			datePattern = parts[1]
+		} else {
+			datePattern = base
+			base = ""
+		}
+		ltr = true
+	} else if strings.HasSuffix(pattern, "]") {
+		parts := strings.Split(strings.TrimRight(pattern, "]"), "[")
+		datePattern = parts[0]
+		if len(parts) == 2 {
+			base = parts[1]
+		} else {
+			base = ""
+		}
+		ltr = false
 	}
 
 	formatted := t.Format(patternToLayout(datePattern))
@@ -293,7 +308,11 @@ func formatDate(t time.Time, pattern string) string {
 		formatted = strings.Replace(formatted, "<stdHourNoZero>", fmt.Sprintf("%d", t.Hour()), -1)
 	}
 
-	return base + formatted
+	if ltr {
+		return base + formatted
+	}
+
+	return formatted + base
 }
 
 func patternToLayout(pattern string) string {

+ 26 - 1
pkg/tsdb/elasticsearch/client/index_pattern_test.go

@@ -28,29 +28,54 @@ func TestIndexPattern(t *testing.T) {
 		to := fmt.Sprintf("%d", time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond))
 
 		indexPatternScenario(intervalHourly, "[data-]YYYY.MM.DD.HH", tsdb.NewTimeRange(from, to), func(indices []string) {
-			//So(indices, ShouldHaveLength, 1)
+			So(indices, ShouldHaveLength, 1)
 			So(indices[0], ShouldEqual, "data-2018.05.15.17")
 		})
 
+		indexPatternScenario(intervalHourly, "YYYY.MM.DD.HH[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
+			So(indices, ShouldHaveLength, 1)
+			So(indices[0], ShouldEqual, "2018.05.15.17-data")
+		})
+
 		indexPatternScenario(intervalDaily, "[data-]YYYY.MM.DD", tsdb.NewTimeRange(from, to), func(indices []string) {
 			So(indices, ShouldHaveLength, 1)
 			So(indices[0], ShouldEqual, "data-2018.05.15")
 		})
 
+		indexPatternScenario(intervalDaily, "YYYY.MM.DD[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
+			So(indices, ShouldHaveLength, 1)
+			So(indices[0], ShouldEqual, "2018.05.15-data")
+		})
+
 		indexPatternScenario(intervalWeekly, "[data-]GGGG.WW", tsdb.NewTimeRange(from, to), func(indices []string) {
 			So(indices, ShouldHaveLength, 1)
 			So(indices[0], ShouldEqual, "data-2018.20")
 		})
 
+		indexPatternScenario(intervalWeekly, "GGGG.WW[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
+			So(indices, ShouldHaveLength, 1)
+			So(indices[0], ShouldEqual, "2018.20-data")
+		})
+
 		indexPatternScenario(intervalMonthly, "[data-]YYYY.MM", tsdb.NewTimeRange(from, to), func(indices []string) {
 			So(indices, ShouldHaveLength, 1)
 			So(indices[0], ShouldEqual, "data-2018.05")
 		})
 
+		indexPatternScenario(intervalMonthly, "YYYY.MM[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
+			So(indices, ShouldHaveLength, 1)
+			So(indices[0], ShouldEqual, "2018.05-data")
+		})
+
 		indexPatternScenario(intervalYearly, "[data-]YYYY", tsdb.NewTimeRange(from, to), func(indices []string) {
 			So(indices, ShouldHaveLength, 1)
 			So(indices[0], ShouldEqual, "data-2018")
 		})
+
+		indexPatternScenario(intervalYearly, "YYYY[-data]", tsdb.NewTimeRange(from, to), func(indices []string) {
+			So(indices, ShouldHaveLength, 1)
+			So(indices[0], ShouldEqual, "2018-data")
+		})
 	})
 
 	Convey("Hourly interval", t, func() {

+ 7 - 1
pkg/tsdb/postgres/postgres.go

@@ -53,7 +53,13 @@ func generateConnectionString(datasource *models.DataSource) string {
 	}
 
 	sslmode := datasource.JsonData.Get("sslmode").MustString("verify-full")
-	u := &url.URL{Scheme: "postgres", User: url.UserPassword(datasource.User, password), Host: datasource.Url, Path: datasource.Database, RawQuery: "sslmode=" + sslmode}
+	u := &url.URL{
+		Scheme: "postgres",
+		User:   url.UserPassword(datasource.User, password),
+		Host:   datasource.Url, Path: datasource.Database,
+		RawQuery: "sslmode=" + url.QueryEscape(sslmode),
+	}
+
 	return u.String()
 }
 

+ 1 - 0
pkg/tsdb/sql_engine.go

@@ -68,6 +68,7 @@ func (e *DefaultSqlEngine) InitEngine(driverName string, dsInfo *models.DataSour
 	engine.SetMaxOpenConns(10)
 	engine.SetMaxIdleConns(10)
 
+	engineCache.versions[dsInfo.Id] = dsInfo.Version
 	engineCache.cache[dsInfo.Id] = engine
 	e.XormEngine = engine
 

+ 1 - 1
pkg/tsdb/testdata/testdata.go

@@ -21,7 +21,7 @@ func NewTestDataExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, err
 }
 
 func init() {
-	tsdb.RegisterTsdbQueryEndpoint("grafana-testdata-datasource", NewTestDataExecutor)
+	tsdb.RegisterTsdbQueryEndpoint("testdata", NewTestDataExecutor)
 }
 
 func (e *TestDataExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {

+ 209 - 53
public/app/containers/Explore/Explore.tsx

@@ -1,16 +1,20 @@
 import React from 'react';
 import { hot } from 'react-hot-loader';
+import Select from 'react-select';
+
+import kbn from 'app/core/utils/kbn';
 import colors from 'app/core/utils/colors';
 import TimeSeries from 'app/core/time_series2';
+import { decodePathComponent } from 'app/core/utils/location_util';
+import { parse as parseDate } from 'app/core/utils/datemath';
 
 import ElapsedTime from './ElapsedTime';
 import QueryRows from './QueryRows';
 import Graph from './Graph';
+import Logs from './Logs';
 import Table from './Table';
 import TimePicker, { DEFAULT_RANGE } from './TimePicker';
-import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
-import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
-import { decodePathComponent } from 'app/core/utils/location_util';
+import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
 
 function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
@@ -30,74 +34,136 @@ function makeTimeSeriesList(dataList, options) {
   });
 }
 
-function parseInitialState(initial) {
-  try {
-    const parsed = JSON.parse(decodePathComponent(initial));
-    return {
-      queries: parsed.queries.map(q => q.query),
-      range: parsed.range,
-    };
-  } catch (e) {
-    console.error(e);
-    return { queries: [], range: DEFAULT_RANGE };
+function parseInitialState(initial: string | undefined) {
+  if (initial) {
+    try {
+      const parsed = JSON.parse(decodePathComponent(initial));
+      return {
+        datasource: parsed.datasource,
+        queries: parsed.queries.map(q => q.query),
+        range: parsed.range,
+      };
+    } catch (e) {
+      console.error(e);
+    }
   }
+  return { datasource: null, queries: [], range: DEFAULT_RANGE };
 }
 
 interface IExploreState {
   datasource: any;
   datasourceError: any;
-  datasourceLoading: any;
+  datasourceLoading: boolean | null;
+  datasourceMissing: boolean;
   graphResult: any;
+  initialDatasource?: string;
   latency: number;
   loading: any;
+  logsResult: any;
   queries: any;
   queryError: any;
   range: any;
   requestOptions: any;
   showingGraph: boolean;
+  showingLogs: boolean;
   showingTable: boolean;
+  supportsGraph: boolean | null;
+  supportsLogs: boolean | null;
+  supportsTable: boolean | null;
   tableResult: any;
 }
 
-// @observer
 export class Explore extends React.Component<any, IExploreState> {
-  datasourceSrv: DatasourceSrv;
+  el: any;
 
   constructor(props) {
     super(props);
-    const { range, queries } = parseInitialState(props.routeParams.initial);
+    const { datasource, queries, range } = parseInitialState(props.routeParams.state);
     this.state = {
       datasource: null,
       datasourceError: null,
-      datasourceLoading: true,
+      datasourceLoading: null,
+      datasourceMissing: false,
       graphResult: null,
+      initialDatasource: datasource,
       latency: 0,
       loading: false,
+      logsResult: null,
       queries: ensureQueries(queries),
       queryError: null,
       range: range || { ...DEFAULT_RANGE },
       requestOptions: null,
       showingGraph: true,
+      showingLogs: true,
       showingTable: true,
+      supportsGraph: null,
+      supportsLogs: null,
+      supportsTable: null,
       tableResult: null,
       ...props.initialState,
     };
   }
 
   async componentDidMount() {
-    const datasource = await this.props.datasourceSrv.get();
-    const testResult = await datasource.testDatasource();
-    if (testResult.status === 'success') {
-      this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit());
+    const { datasourceSrv } = this.props;
+    const { initialDatasource } = this.state;
+    if (!datasourceSrv) {
+      throw new Error('No datasource service passed as props.');
+    }
+    const datasources = datasourceSrv.getExploreSources();
+    if (datasources.length > 0) {
+      this.setState({ datasourceLoading: true });
+      // Priority: datasource in url, default datasource, first explore datasource
+      let datasource;
+      if (initialDatasource) {
+        datasource = await datasourceSrv.get(initialDatasource);
+      } else {
+        datasource = await datasourceSrv.get();
+      }
+      if (!datasource.meta.explore) {
+        datasource = await datasourceSrv.get(datasources[0].name);
+      }
+      this.setDatasource(datasource);
     } else {
-      this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
+      this.setState({ datasourceMissing: true });
     }
   }
 
   componentDidCatch(error) {
+    this.setState({ datasourceError: error });
     console.error(error);
   }
 
+  async setDatasource(datasource) {
+    const supportsGraph = datasource.meta.metrics;
+    const supportsLogs = datasource.meta.logs;
+    const supportsTable = datasource.meta.metrics;
+    let datasourceError = null;
+
+    try {
+      const testResult = await datasource.testDatasource();
+      datasourceError = testResult.status === 'success' ? null : testResult.message;
+    } catch (error) {
+      datasourceError = (error && error.statusText) || error;
+    }
+
+    this.setState(
+      {
+        datasource,
+        datasourceError,
+        supportsGraph,
+        supportsLogs,
+        supportsTable,
+        datasourceLoading: false,
+      },
+      () => datasourceError === null && this.handleSubmit()
+    );
+  }
+
+  getRef = el => {
+    this.el = el;
+  };
+
   handleAddQueryRow = index => {
     const { queries } = this.state;
     const nextQueries = [
@@ -108,6 +174,19 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState({ queries: nextQueries });
   };
 
+  handleChangeDatasource = async option => {
+    this.setState({
+      datasource: null,
+      datasourceError: null,
+      datasourceLoading: true,
+      graphResult: null,
+      logsResult: null,
+      tableResult: null,
+    });
+    const datasource = await this.props.datasourceSrv.get(option.value);
+    this.setDatasource(datasource);
+  };
+
   handleChangeQuery = (query, index) => {
     const { queries } = this.state;
     const nextQuery = {
@@ -138,6 +217,10 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState(state => ({ showingGraph: !state.showingGraph }));
   };
 
+  handleClickLogsButton = () => {
+    this.setState(state => ({ showingLogs: !state.showingLogs }));
+  };
+
   handleClickSplit = () => {
     const { onChangeSplit } = this.props;
     if (onChangeSplit) {
@@ -159,29 +242,45 @@ export class Explore extends React.Component<any, IExploreState> {
   };
 
   handleSubmit = () => {
-    const { showingGraph, showingTable } = this.state;
-    if (showingTable) {
+    const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
+    if (showingTable && supportsTable) {
       this.runTableQuery();
     }
-    if (showingGraph) {
+    if (showingGraph && supportsGraph) {
       this.runGraphQuery();
     }
+    if (showingLogs && supportsLogs) {
+      this.runLogsQuery();
+    }
   };
 
-  async runGraphQuery() {
+  buildQueryOptions(targetOptions: { format: string; instant?: boolean }) {
     const { datasource, queries, range } = this.state;
+    const resolution = this.el.offsetWidth;
+    const absoluteRange = {
+      from: parseDate(range.from, false),
+      to: parseDate(range.to, true),
+    };
+    const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
+    const targets = queries.map(q => ({
+      ...targetOptions,
+      expr: q.query,
+    }));
+    return {
+      interval,
+      range,
+      targets,
+    };
+  }
+
+  async runGraphQuery() {
+    const { datasource, queries } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
     this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
     const now = Date.now();
-    const options = buildQueryOptions({
-      format: 'time_series',
-      interval: datasource.interval,
-      instant: false,
-      range,
-      queries: queries.map(q => q.query),
-    });
+    const options = this.buildQueryOptions({ format: 'time_series', instant: false });
     try {
       const res = await datasource.query(options);
       const result = makeTimeSeriesList(res.data, options);
@@ -195,18 +294,15 @@ export class Explore extends React.Component<any, IExploreState> {
   }
 
   async runTableQuery() {
-    const { datasource, queries, range } = this.state;
+    const { datasource, queries } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
     this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
     const now = Date.now();
-    const options = buildQueryOptions({
+    const options = this.buildQueryOptions({
       format: 'table',
-      interval: datasource.interval,
       instant: true,
-      range,
-      queries: queries.map(q => q.query),
     });
     try {
       const res = await datasource.query(options);
@@ -220,35 +316,71 @@ export class Explore extends React.Component<any, IExploreState> {
     }
   }
 
+  async runLogsQuery() {
+    const { datasource, queries } = this.state;
+    if (!hasQuery(queries)) {
+      return;
+    }
+    this.setState({ latency: 0, loading: true, queryError: null, logsResult: null });
+    const now = Date.now();
+    const options = this.buildQueryOptions({
+      format: 'logs',
+    });
+
+    try {
+      const res = await datasource.query(options);
+      const logsData = res.data;
+      const latency = Date.now() - now;
+      this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
+    } catch (response) {
+      console.error(response);
+      const queryError = response.data ? response.data.error : response;
+      this.setState({ loading: false, queryError });
+    }
+  }
+
   request = url => {
     const { datasource } = this.state;
     return datasource.metadataRequest(url);
   };
 
   render() {
-    const { position, split } = this.props;
+    const { datasourceSrv, position, split } = this.props;
     const {
       datasource,
       datasourceError,
       datasourceLoading,
+      datasourceMissing,
       graphResult,
       latency,
       loading,
+      logsResult,
       queries,
       queryError,
       range,
       requestOptions,
       showingGraph,
+      showingLogs,
       showingTable,
+      supportsGraph,
+      supportsLogs,
+      supportsTable,
       tableResult,
     } = this.state;
     const showingBoth = showingGraph && showingTable;
     const graphHeight = showingBoth ? '200px' : '400px';
     const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
+    const logsButtonActive = showingLogs ? 'active' : '';
     const tableButtonActive = showingBoth || showingTable ? 'active' : '';
     const exploreClass = split ? 'explore explore-split' : 'explore';
+    const datasources = datasourceSrv.getExploreSources().map(ds => ({
+      value: ds.name,
+      label: ds.name,
+    }));
+    const selectedDatasource = datasource ? datasource.name : undefined;
+
     return (
-      <div className={exploreClass}>
+      <div className={exploreClass} ref={this.getRef}>
         <div className="navbar">
           {position === 'left' ? (
             <div>
@@ -264,6 +396,18 @@ export class Explore extends React.Component<any, IExploreState> {
               </button>
             </div>
           )}
+          {!datasourceMissing ? (
+            <div className="navbar-buttons">
+              <Select
+                className="datasource-picker"
+                clearable={false}
+                onChange={this.handleChangeDatasource}
+                options={datasources}
+                placeholder="Loading datasources..."
+                value={selectedDatasource}
+              />
+            </div>
+          ) : null}
           <div className="navbar__spacer" />
           {position === 'left' && !split ? (
             <div className="navbar-buttons">
@@ -273,12 +417,21 @@ export class Explore extends React.Component<any, IExploreState> {
             </div>
           ) : null}
           <div className="navbar-buttons">
-            <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
-              Graph
-            </button>
-            <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
-              Table
-            </button>
+            {supportsGraph ? (
+              <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
+                Graph
+              </button>
+            ) : null}
+            {supportsTable ? (
+              <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
+                Table
+              </button>
+            ) : null}
+            {supportsLogs ? (
+              <button className={`btn navbar-button ${logsButtonActive}`} onClick={this.handleClickLogsButton}>
+                Logs
+              </button>
+            ) : null}
           </div>
           <TimePicker range={range} onChangeTime={this.handleChangeTime} />
           <div className="navbar-buttons relative">
@@ -291,13 +444,15 @@ export class Explore extends React.Component<any, IExploreState> {
 
         {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
 
+        {datasourceMissing ? (
+          <div className="explore-container">Please add a datasource that supports Explore (e.g., Prometheus).</div>
+        ) : null}
+
         {datasourceError ? (
-          <div className="explore-container" title={datasourceError}>
-            Error connecting to datasource.
-          </div>
+          <div className="explore-container">Error connecting to datasource. [{datasourceError}]</div>
         ) : null}
 
-        {datasource ? (
+        {datasource && !datasourceError ? (
           <div className="explore-container">
             <QueryRows
               queries={queries}
@@ -309,7 +464,7 @@ export class Explore extends React.Component<any, IExploreState> {
             />
             {queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
             <main className="m-t-2">
-              {showingGraph ? (
+              {supportsGraph && showingGraph ? (
                 <Graph
                   data={graphResult}
                   id={`explore-graph-${position}`}
@@ -318,7 +473,8 @@ export class Explore extends React.Component<any, IExploreState> {
                   split={split}
                 />
               ) : null}
-              {showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
+              {supportsTable && showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
+              {supportsLogs && showingLogs ? <Logs data={logsResult} /> : null}
             </main>
           </div>
         ) : null}

+ 9 - 0
public/app/containers/Explore/JSONViewer.tsx

@@ -0,0 +1,9 @@
+import React from 'react';
+
+export default function({ value }) {
+  return (
+    <div>
+      <pre>{JSON.stringify(value, undefined, 2)}</pre>
+    </div>
+  );
+}

+ 66 - 0
public/app/containers/Explore/Logs.tsx

@@ -0,0 +1,66 @@
+import React, { Fragment, PureComponent } from 'react';
+
+import { LogsModel, LogRow } from 'app/core/logs_model';
+
+interface LogsProps {
+  className?: string;
+  data: LogsModel;
+}
+
+const EXAMPLE_QUERY = '{job="default/prometheus"}';
+
+const Entry: React.SFC<LogRow> = props => {
+  const { entry, searchMatches } = props;
+  if (searchMatches && searchMatches.length > 0) {
+    let lastMatchEnd = 0;
+    const spans = searchMatches.reduce((acc, match, i) => {
+      // Insert non-match
+      if (match.start !== lastMatchEnd) {
+        acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
+      }
+      // Match
+      acc.push(
+        <span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
+          {entry.substr(match.start, match.length)}
+        </span>
+      );
+      lastMatchEnd = match.start + match.length;
+      // Non-matching end
+      if (i === searchMatches.length - 1) {
+        acc.push(<>{entry.slice(lastMatchEnd)}</>);
+      }
+      return acc;
+    }, []);
+    return <>{spans}</>;
+  }
+  return <>{props.entry}</>;
+};
+
+export default class Logs extends PureComponent<LogsProps, any> {
+  render() {
+    const { className = '', data } = this.props;
+    const hasData = data && data.rows && data.rows.length > 0;
+    return (
+      <div className={`${className} logs`}>
+        {hasData ? (
+          <div className="logs-entries panel-container">
+            {data.rows.map(row => (
+              <Fragment key={row.key}>
+                <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
+                <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
+                <div>
+                  <Entry {...row} />
+                </div>
+              </Fragment>
+            ))}
+          </div>
+        ) : null}
+        {!hasData ? (
+          <div className="panel-container">
+            Enter a query like <code>{EXAMPLE_QUERY}</code>
+          </div>
+        ) : null}
+      </div>
+    );
+  }
+}

+ 125 - 0
public/app/containers/Explore/PromQueryField.jest.tsx

@@ -0,0 +1,125 @@
+import React from 'react';
+import Enzyme, { shallow } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+Enzyme.configure({ adapter: new Adapter() });
+
+import PromQueryField from './PromQueryField';
+
+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 result = instance.getTypeahead({ text: 'j', prefix: 'j', wrapperClasses: ['context-labels'] });
+      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={{ foo: ['bar'] }} />
+      ).instance() as PromQueryField;
+      const result = instance.getTypeahead({
+        text: 'job',
+        prefix: 'job',
+        wrapperClasses: ['context-labels'],
+        metric: 'foo',
+      });
+      expect(result.context).toBe('context-labels');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+    });
+
+    it('returns a refresher on label context and unavailable metric', () => {
+      const instance = shallow(
+        <PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
+      ).instance() as PromQueryField;
+      const result = instance.getTypeahead({
+        text: 'job',
+        prefix: 'job',
+        wrapperClasses: ['context-labels'],
+        metric: 'xxx',
+      });
+      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={{ foo: ['bar'] }} labelValues={{ foo: { bar: ['baz'] } }} />
+      ).instance() as PromQueryField;
+      const result = instance.getTypeahead({
+        text: '=ba',
+        prefix: 'ba',
+        wrapperClasses: ['context-labels'],
+        metric: 'foo',
+        labelKey: 'bar',
+      });
+      expect(result.context).toBe('context-label-values');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values' }]);
+    });
+
+    it('returns label suggestions on aggregation context and metric', () => {
+      const instance = shallow(
+        <PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
+      ).instance() as PromQueryField;
+      const result = instance.getTypeahead({
+        text: 'job',
+        prefix: 'job',
+        wrapperClasses: ['context-aggregation'],
+        metric: 'foo',
+      });
+      expect(result.context).toBe('context-aggregation');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+    });
+  });
+});

+ 340 - 0
public/app/containers/Explore/PromQueryField.tsx

@@ -0,0 +1,340 @@
+import _ from 'lodash';
+import React from 'react';
+
+// dom also includes Element polyfills
+import { getNextCharacter, getPreviousCousin } from './utils/dom';
+import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
+import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
+import RunnerPlugin from './slate-plugins/runner';
+import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
+
+import TypeaheadField, {
+  Suggestion,
+  SuggestionGroup,
+  TypeaheadInput,
+  TypeaheadFieldState,
+  TypeaheadOutput,
+} from './QueryField';
+
+const EMPTY_METRIC = '';
+const METRIC_MARK = 'metric';
+const PRISM_LANGUAGE = 'promql';
+
+export const wrapLabel = label => ({ label });
+export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
+  suggestion.move = -1;
+  return suggestion;
+};
+
+export function willApplySuggestion(
+  suggestion: string,
+  { typeaheadContext, typeaheadText }: TypeaheadFieldState
+): string {
+  // Modify suggestion based on context
+  switch (typeaheadContext) {
+    case 'context-labels': {
+      const nextChar = getNextCharacter();
+      if (!nextChar || nextChar === '}' || nextChar === ',') {
+        suggestion += '=';
+      }
+      break;
+    }
+
+    case 'context-label-values': {
+      // Always add quotes and remove existing ones instead
+      if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
+        suggestion = `"${suggestion}`;
+      }
+      if (getNextCharacter() !== '"') {
+        suggestion = `${suggestion}"`;
+      }
+      break;
+    }
+
+    default:
+  }
+  return suggestion;
+}
+
+interface PromQueryFieldProps {
+  initialQuery?: string | null;
+  labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
+  labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
+  metrics?: string[];
+  onPressEnter?: () => void;
+  onQueryChange?: (value: string) => void;
+  portalPrefix?: string;
+  request?: (url: string) => any;
+}
+
+interface PromQueryFieldState {
+  labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
+  labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
+  metrics: string[];
+}
+
+interface PromTypeaheadInput {
+  text: string;
+  prefix: string;
+  wrapperClasses: string[];
+  metric?: string;
+  labelKey?: string;
+}
+
+class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
+  plugins: any[];
+
+  constructor(props, context) {
+    super(props, context);
+
+    this.plugins = [
+      RunnerPlugin({ handler: props.onPressEnter }),
+      PluginPrism({ definition: PrismPromql, language: PRISM_LANGUAGE }),
+    ];
+
+    this.state = {
+      labelKeys: props.labelKeys || {},
+      labelValues: props.labelValues || {},
+      metrics: props.metrics || [],
+    };
+  }
+
+  componentDidMount() {
+    this.fetchMetricNames();
+  }
+
+  onChangeQuery = value => {
+    // Send text change to parent
+    const { onQueryChange } = this.props;
+    if (onQueryChange) {
+      onQueryChange(value);
+    }
+  };
+
+  onReceiveMetrics = () => {
+    if (!this.state.metrics) {
+      return;
+    }
+    setPrismTokens(PRISM_LANGUAGE, METRIC_MARK, this.state.metrics);
+  };
+
+  onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+    const { editorNode, prefix, text, wrapperNode } = typeahead;
+
+    // Get DOM-dependent context
+    const wrapperClasses = Array.from(wrapperNode.classList);
+    // Take first metric as lucky guess
+    const metricNode = editorNode.querySelector(`.${METRIC_MARK}`);
+    const metric = metricNode && metricNode.textContent;
+    const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
+    const labelKey = labelKeyNode && labelKeyNode.textContent;
+
+    const result = this.getTypeahead({ text, prefix, wrapperClasses, metric, labelKey });
+
+    console.log('handleTypeahead', wrapperClasses, text, prefix, result.context);
+
+    return result;
+  };
+
+  // Keep this DOM-free for testing
+  getTypeahead({ prefix, wrapperClasses, metric, text }: PromTypeaheadInput): TypeaheadOutput {
+    // 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 (metric && _.includes(wrapperClasses, 'context-aggregation')) {
+      return this.getAggregationTypeahead.apply(this, arguments);
+    } else if (
+      // Non-empty but not inside known token unless it's a metric
+      (prefix && !_.includes(wrapperClasses, 'token')) ||
+      prefix === metric ||
+      (prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
+      text.match(/[+\-*/^%]/) // After binary operator
+    ) {
+      return this.getEmptyTypeahead();
+    }
+
+    return {
+      suggestions: [],
+    };
+  }
+
+  getEmptyTypeahead(): TypeaheadOutput {
+    const suggestions: SuggestionGroup[] = [];
+    suggestions.push({
+      prefixMatch: true,
+      label: 'Functions',
+      items: FUNCTIONS.map(setFunctionMove),
+    });
+
+    if (this.state.metrics) {
+      suggestions.push({
+        label: 'Metrics',
+        items: this.state.metrics.map(wrapLabel),
+      });
+    }
+    return { suggestions };
+  }
+
+  getRangeTypeahead(): TypeaheadOutput {
+    return {
+      context: 'context-range',
+      suggestions: [
+        {
+          label: 'Range vector',
+          items: [...RATE_RANGES].map(wrapLabel),
+        },
+      ],
+    };
+  }
+
+  getAggregationTypeahead({ metric }: PromTypeaheadInput): TypeaheadOutput {
+    let refresher: Promise<any> = null;
+    const suggestions: SuggestionGroup[] = [];
+    const labelKeys = this.state.labelKeys[metric];
+    if (labelKeys) {
+      suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
+    } else {
+      refresher = this.fetchMetricLabels(metric);
+    }
+
+    return {
+      refresher,
+      suggestions,
+      context: 'context-aggregation',
+    };
+  }
+
+  getLabelTypeahead({ metric, text, wrapperClasses, labelKey }: PromTypeaheadInput): TypeaheadOutput {
+    let context: string;
+    let refresher: Promise<any> = null;
+    const suggestions: SuggestionGroup[] = [];
+    if (metric) {
+      const labelKeys = this.state.labelKeys[metric];
+      if (labelKeys) {
+        if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
+          // Label values
+          if (labelKey) {
+            const labelValues = this.state.labelValues[metric][labelKey];
+            context = 'context-label-values';
+            suggestions.push({
+              label: 'Label values',
+              items: labelValues.map(wrapLabel),
+            });
+          }
+        } else {
+          // Label keys
+          context = 'context-labels';
+          suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
+        }
+      } else {
+        refresher = this.fetchMetricLabels(metric);
+      }
+    } else {
+      // Metric-independent label queries
+      const defaultKeys = ['job', 'instance'];
+      // Munge all keys that we have seen together
+      const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
+        return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
+      }, defaultKeys);
+      if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
+        // Label values
+        if (labelKey) {
+          if (this.state.labelValues[EMPTY_METRIC]) {
+            const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
+            context = 'context-label-values';
+            suggestions.push({
+              label: 'Label values',
+              items: labelValues.map(wrapLabel),
+            });
+          } else {
+            // Can only query label values for now (API to query keys is under development)
+            refresher = this.fetchLabelValues(labelKey);
+          }
+        }
+      } else {
+        // Label keys
+        context = 'context-labels';
+        suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
+      }
+    }
+    return { context, refresher, suggestions };
+  }
+
+  request = url => {
+    if (this.props.request) {
+      return this.props.request(url);
+    }
+    return fetch(url);
+  };
+
+  async fetchLabelValues(key) {
+    const url = `/api/v1/label/${key}/values`;
+    try {
+      const res = await this.request(url);
+      const body = await (res.data || res.json());
+      const pairs = this.state.labelValues[EMPTY_METRIC];
+      const values = {
+        ...pairs,
+        [key]: body.data,
+      };
+      const labelValues = {
+        ...this.state.labelValues,
+        [EMPTY_METRIC]: values,
+      };
+      this.setState({ labelValues });
+    } catch (e) {
+      console.error(e);
+    }
+  }
+
+  async fetchMetricLabels(name) {
+    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);
+      const labelKeys = {
+        ...this.state.labelKeys,
+        [name]: keys,
+      };
+      const labelValues = {
+        ...this.state.labelValues,
+        [name]: values,
+      };
+      this.setState({ labelKeys, labelValues });
+    } 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());
+      this.setState({ metrics: body.data }, this.onReceiveMetrics);
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  render() {
+    return (
+      <TypeaheadField
+        additionalPlugins={this.plugins}
+        cleanText={cleanText}
+        initialValue={this.props.initialQuery}
+        onTypeahead={this.onTypeahead}
+        onWillApplySuggestion={willApplySuggestion}
+        onValueChanged={this.onChangeQuery}
+        placeholder="Enter a PromQL query"
+      />
+    );
+  }
+}
+
+export default PromQueryField;

+ 231 - 312
public/app/containers/Explore/QueryField.tsx

@@ -1,105 +1,163 @@
+import _ from 'lodash';
 import React from 'react';
 import ReactDOM from 'react-dom';
-import { Value } from 'slate';
+import { Block, Change, Document, Text, Value } from 'slate';
 import { Editor } from 'slate-react';
 import Plain from 'slate-plain-serializer';
 
-// dom also includes Element polyfills
-import { getNextCharacter, getPreviousCousin } from './utils/dom';
 import BracesPlugin from './slate-plugins/braces';
 import ClearPlugin from './slate-plugins/clear';
 import NewlinePlugin from './slate-plugins/newline';
-import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
-import RunnerPlugin from './slate-plugins/runner';
-import debounce from './utils/debounce';
-import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
 
 import Typeahead from './Typeahead';
 
-const EMPTY_METRIC = '';
 export const TYPEAHEAD_DEBOUNCE = 300;
 
-function flattenSuggestions(s) {
+function flattenSuggestions(s: any[]): any[] {
   return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
 }
 
-export const getInitialValue = query =>
-  Value.fromJSON({
-    document: {
-      nodes: [
-        {
-          object: 'block',
-          type: 'paragraph',
-          nodes: [
-            {
-              object: 'text',
-              leaves: [
-                {
-                  text: query,
-                },
-              ],
-            },
-          ],
-        },
-      ],
-    },
+export const makeFragment = (text: string): Document => {
+  const lines = text.split('\n').map(line =>
+    Block.create({
+      type: 'paragraph',
+      nodes: [Text.create(line)],
+    })
+  );
+
+  const fragment = Document.create({
+    nodes: lines,
   });
+  return fragment;
+};
+
+export const getInitialValue = (value: string): Value => Value.create({ document: makeFragment(value) });
+
+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;
+}
 
-class Portal extends React.Component<any, any> {
-  node: any;
+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;
+}
 
-  constructor(props) {
-    super(props);
-    const { index = 0, prefix = 'query' } = props;
-    this.node = document.createElement('div');
-    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
-    document.body.appendChild(this.node);
-  }
+interface TypeaheadFieldProps {
+  additionalPlugins?: any[];
+  cleanText?: (text: string) => string;
+  initialValue: string | null;
+  onBlur?: () => void;
+  onFocus?: () => void;
+  onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
+  onValueChanged?: (value: Value) => void;
+  onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
+  placeholder?: string;
+  portalPrefix?: string;
+}
 
-  componentWillUnmount() {
-    document.body.removeChild(this.node);
-  }
+export interface TypeaheadFieldState {
+  suggestions: SuggestionGroup[];
+  typeaheadContext: string | null;
+  typeaheadIndex: number;
+  typeaheadPrefix: string;
+  typeaheadText: string;
+  value: Value;
+}
 
-  render() {
-    return ReactDOM.createPortal(this.props.children, this.node);
-  }
+export interface TypeaheadInput {
+  editorNode: Element;
+  prefix: string;
+  selection?: Selection;
+  text: string;
+  wrapperNode: Element;
+}
+
+export interface TypeaheadOutput {
+  context?: string;
+  refresher?: Promise<{}>;
+  suggestions: SuggestionGroup[];
 }
 
-class QueryField extends React.Component<any, any> {
-  menuEl: any;
-  plugins: any;
+class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
+  menuEl: HTMLElement | null;
+  plugins: any[];
   resetTimer: any;
 
   constructor(props, context) {
     super(props, context);
 
-    const { prismDefinition = {}, prismLanguage = 'promql' } = props;
-
-    this.plugins = [
-      BracesPlugin(),
-      ClearPlugin(),
-      RunnerPlugin({ handler: props.onPressEnter }),
-      NewlinePlugin(),
-      PluginPrism({ definition: prismDefinition, language: prismLanguage }),
-    ];
+    // Base plugins
+    this.plugins = [BracesPlugin(), ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
 
     this.state = {
-      labelKeys: {},
-      labelValues: {},
-      metrics: props.metrics || [],
       suggestions: [],
+      typeaheadContext: null,
       typeaheadIndex: 0,
       typeaheadPrefix: '',
-      value: getInitialValue(props.initialQuery || ''),
+      typeaheadText: '',
+      value: getInitialValue(props.initialValue || ''),
     };
   }
 
   componentDidMount() {
     this.updateMenu();
-
-    if (this.props.metrics === undefined) {
-      this.fetchMetricNames();
-    }
   }
 
   componentWillUnmount() {
@@ -111,12 +169,9 @@ class QueryField extends React.Component<any, any> {
   }
 
   componentWillReceiveProps(nextProps) {
-    if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
-      this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
-    }
-    // initialQuery is null in case the user typed
-    if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
-      this.setState({ value: getInitialValue(nextProps.initialQuery) });
+    // initialValue is null in case the user typed
+    if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
+      this.setState({ value: getInitialValue(nextProps.initialValue) });
     }
   }
 
@@ -124,48 +179,28 @@ class QueryField extends React.Component<any, any> {
     const changed = value.document !== this.state.value.document;
     this.setState({ value }, () => {
       if (changed) {
-        this.handleChangeQuery();
+        this.handleChangeValue();
       }
     });
 
-    window.requestAnimationFrame(this.handleTypeahead);
-  };
-
-  onMetricsReceived = () => {
-    if (!this.state.metrics) {
-      return;
-    }
-    setPrismTokens(this.props.prismLanguage, 'metrics', this.state.metrics);
-
-    // Trigger re-render
-    window.requestAnimationFrame(() => {
-      // Bogus edit to trigger highlighting
-      const change = this.state.value
-        .change()
-        .insertText(' ')
-        .deleteBackward(1);
-      this.onChange(change);
-    });
-  };
-
-  request = url => {
-    if (this.props.request) {
-      return this.props.request(url);
+    if (changed) {
+      window.requestAnimationFrame(this.handleTypeahead);
     }
-    return fetch(url);
   };
 
-  handleChangeQuery = () => {
+  handleChangeValue = () => {
     // Send text change to parent
-    const { onQueryChange } = this.props;
-    if (onQueryChange) {
-      onQueryChange(Plain.serialize(this.state.value));
+    const { onValueChanged } = this.props;
+    if (onValueChanged) {
+      onValueChanged(Plain.serialize(this.state.value));
     }
   };
 
-  handleTypeahead = debounce(() => {
+  handleTypeahead = _.debounce(async () => {
     const selection = window.getSelection();
-    if (selection.anchorNode) {
+    const { cleanText, onTypeahead } = this.props;
+
+    if (onTypeahead && selection.anchorNode) {
       const wrapperNode = selection.anchorNode.parentElement;
       const editorNode = wrapperNode.closest('.slate-query-field');
       if (!editorNode || this.state.value.isBlurred) {
@@ -174,164 +209,96 @@ class QueryField extends React.Component<any, any> {
       }
 
       const range = selection.getRangeAt(0);
-      const text = selection.anchorNode.textContent;
       const offset = range.startOffset;
-      const prefix = cleanText(text.substr(0, offset));
-
-      // Determine candidates by context
-      const suggestionGroups = [];
-      const wrapperClasses = wrapperNode.classList;
-      let typeaheadContext = null;
-
-      // Take first metric as lucky guess
-      const metricNode = editorNode.querySelector('.metric');
-
-      if (wrapperClasses.contains('context-range')) {
-        // Rate ranges
-        typeaheadContext = 'context-range';
-        suggestionGroups.push({
-          label: 'Range vector',
-          items: [...RATE_RANGES],
-        });
-      } else if (wrapperClasses.contains('context-labels') && metricNode) {
-        const metric = metricNode.textContent;
-        const labelKeys = this.state.labelKeys[metric];
-        if (labelKeys) {
-          if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
-            // Label values
-            const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
-            if (labelKeyNode) {
-              const labelKey = labelKeyNode.textContent;
-              const labelValues = this.state.labelValues[metric][labelKey];
-              typeaheadContext = 'context-label-values';
-              suggestionGroups.push({
-                label: 'Label values',
-                items: labelValues,
-              });
-            }
-          } else {
-            // Label keys
-            typeaheadContext = 'context-labels';
-            suggestionGroups.push({ label: 'Labels', items: labelKeys });
-          }
-        } else {
-          this.fetchMetricLabels(metric);
-        }
-      } else if (wrapperClasses.contains('context-labels') && !metricNode) {
-        // Empty name queries
-        const defaultKeys = ['job', 'instance'];
-        // Munge all keys that we have seen together
-        const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
-          return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
-        }, defaultKeys);
-        if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
-          // Label values
-          const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
-          if (labelKeyNode) {
-            const labelKey = labelKeyNode.textContent;
-            if (this.state.labelValues[EMPTY_METRIC]) {
-              const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
-              typeaheadContext = 'context-label-values';
-              suggestionGroups.push({
-                label: 'Label values',
-                items: labelValues,
-              });
-            } else {
-              // Can only query label values for now (API to query keys is under development)
-              this.fetchLabelValues(labelKey);
-            }
-          }
-        } else {
-          // Label keys
-          typeaheadContext = 'context-labels';
-          suggestionGroups.push({ label: 'Labels', items: labelKeys });
-        }
-      } else if (metricNode && wrapperClasses.contains('context-aggregation')) {
-        typeaheadContext = 'context-aggregation';
-        const metric = metricNode.textContent;
-        const labelKeys = this.state.labelKeys[metric];
-        if (labelKeys) {
-          suggestionGroups.push({ label: 'Labels', items: labelKeys });
-        } else {
-          this.fetchMetricLabels(metric);
-        }
-      } else if (
-        (this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
-        wrapperClasses.contains('context-function')
-      ) {
-        // Need prefix for metrics
-        typeaheadContext = 'context-metrics';
-        suggestionGroups.push({
-          label: 'Metrics',
-          items: this.state.metrics,
-        });
+      const text = selection.anchorNode.textContent;
+      let prefix = text.substr(0, offset);
+      if (cleanText) {
+        prefix = cleanText(prefix);
       }
 
-      let results = 0;
-      const filteredSuggestions = suggestionGroups.map(group => {
-        if (group.items) {
-          group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
-          results += group.items.length;
-        }
-        return group;
+      const { suggestions, context, refresher } = onTypeahead({
+        editorNode,
+        prefix,
+        selection,
+        text,
+        wrapperNode,
       });
 
-      console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
-
-      this.setState({
-        typeaheadPrefix: prefix,
-        typeaheadContext,
-        typeaheadText: text,
-        suggestions: results > 0 ? filteredSuggestions : [],
-      });
-    }
-  }, TYPEAHEAD_DEBOUNCE);
+      const filteredSuggestions = suggestions
+        .map(group => {
+          if (group.items) {
+            if (prefix) {
+              // Filter groups based on prefix
+              if (!group.skipFilter) {
+                group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
+                if (group.prefixMatch) {
+                  group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
+                } else {
+                  group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
+                }
+              }
+              // Filter out the already typed value (prefix) unless it inserts custom text
+              group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
+            }
 
-  applyTypeahead(change, suggestion) {
-    const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
+            group.items = _.sortBy(group.items, item => item.sortText || item.label);
+          }
+          return group;
+        })
+        .filter(group => group.items && group.items.length > 0); // Filter out empty groups
 
-    // Modify suggestion based on context
-    switch (typeaheadContext) {
-      case 'context-labels': {
-        const nextChar = getNextCharacter();
-        if (!nextChar || nextChar === '}' || nextChar === ',') {
-          suggestion += '=';
+      this.setState(
+        {
+          suggestions: filteredSuggestions,
+          typeaheadPrefix: prefix,
+          typeaheadContext: context,
+          typeaheadText: text,
+        },
+        () => {
+          if (refresher) {
+            refresher.then(this.handleTypeahead).catch(e => console.error(e));
+          }
         }
-        break;
-      }
+      );
+    }
+  }, TYPEAHEAD_DEBOUNCE);
 
-      case 'context-label-values': {
-        // Always add quotes and remove existing ones instead
-        if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
-          suggestion = `"${suggestion}`;
-        }
-        if (getNextCharacter() !== '"') {
-          suggestion = `${suggestion}"`;
-        }
-        break;
-      }
+  applyTypeahead(change: Change, suggestion: Suggestion): Change {
+    const { cleanText, onWillApplySuggestion } = this.props;
+    const { typeaheadPrefix, typeaheadText } = this.state;
+    let suggestionText = suggestion.insertText || suggestion.label;
+    const move = suggestion.move || 0;
 
-      default:
+    if (onWillApplySuggestion) {
+      suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
     }
 
     this.resetTypeahead();
 
     // Remove the current, incomplete text and replace it with the selected suggestion
-    let backward = typeaheadPrefix.length;
-    const text = cleanText(typeaheadText);
+    const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
+    const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
     const suffixLength = text.length - typeaheadPrefix.length;
     const offset = typeaheadText.indexOf(typeaheadPrefix);
-    const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
+    const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
     const forward = midWord ? suffixLength + offset : 0;
 
-    return (
-      change
-        // TODO this line breaks if cursor was moved left and length is longer than whole prefix
+    // If new-lines, apply suggestion as block
+    if (suggestionText.match(/\n/)) {
+      const fragment = makeFragment(suggestionText);
+      return change
         .deleteBackward(backward)
         .deleteForward(forward)
-        .insertText(suggestion)
-        .focus()
-    );
+        .insertFragment(fragment)
+        .focus();
+    }
+
+    return change
+      .deleteBackward(backward)
+      .deleteForward(forward)
+      .insertText(suggestionText)
+      .move(move)
+      .focus();
   }
 
   onKeyDown = (event, change) => {
@@ -412,73 +379,6 @@ class QueryField extends React.Component<any, any> {
     });
   };
 
-  async fetchLabelValues(key) {
-    const url = `/api/v1/label/${key}/values`;
-    try {
-      const res = await this.request(url);
-      const body = await (res.data || res.json());
-      const pairs = this.state.labelValues[EMPTY_METRIC];
-      const values = {
-        ...pairs,
-        [key]: body.data,
-      };
-      // const labelKeys = {
-      //   ...this.state.labelKeys,
-      //   [EMPTY_METRIC]: keys,
-      // };
-      const labelValues = {
-        ...this.state.labelValues,
-        [EMPTY_METRIC]: values,
-      };
-      this.setState({ labelValues }, this.handleTypeahead);
-    } catch (e) {
-      if (this.props.onRequestError) {
-        this.props.onRequestError(e);
-      } else {
-        console.error(e);
-      }
-    }
-  }
-
-  async fetchMetricLabels(name) {
-    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);
-      const labelKeys = {
-        ...this.state.labelKeys,
-        [name]: keys,
-      };
-      const labelValues = {
-        ...this.state.labelValues,
-        [name]: values,
-      };
-      this.setState({ labelKeys, labelValues }, this.handleTypeahead);
-    } catch (e) {
-      if (this.props.onRequestError) {
-        this.props.onRequestError(e);
-      } else {
-        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());
-      this.setState({ metrics: body.data }, this.onMetricsReceived);
-    } catch (error) {
-      if (this.props.onRequestError) {
-        this.props.onRequestError(error);
-      } else {
-        console.error(error);
-      }
-    }
-  }
-
   handleBlur = () => {
     const { onBlur } = this.props;
     // If we dont wait here, menu clicks wont work because the menu
@@ -496,7 +396,7 @@ class QueryField extends React.Component<any, any> {
     }
   };
 
-  handleClickMenu = item => {
+  onClickMenu = (item: Suggestion) => {
     // Manually triggering change
     const change = this.applyTypeahead(this.state.value.change(), item);
     this.onChange(change);
@@ -529,7 +429,7 @@ class QueryField extends React.Component<any, any> {
 
       // Write DOM
       requestAnimationFrame(() => {
-        menu.style.opacity = 1;
+        menu.style.opacity = '1';
         menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
         menu.style.left = `${rect.left + scrollX - 2}px`;
       });
@@ -552,17 +452,16 @@ class QueryField extends React.Component<any, any> {
     let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
     const flattenedSuggestions = flattenSuggestions(suggestions);
     selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
-    const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
-      i => (typeof i === 'object' ? i.text : i)
-    );
+    const selectedItem: Suggestion | null =
+      flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
 
     // Create typeahead in DOM root so we can later position it absolutely
     return (
       <Portal prefix={portalPrefix}>
         <Typeahead
           menuRef={this.menuRef}
-          selectedItems={selectedKeys}
-          onClickItem={this.handleClickMenu}
+          selectedItem={selectedItem}
+          onClickItem={this.onClickMenu}
           groupedItems={suggestions}
         />
       </Portal>
@@ -589,4 +488,24 @@ class QueryField extends React.Component<any, any> {
   }
 }
 
+class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
+  node: HTMLElement;
+
+  constructor(props) {
+    super(props);
+    const { index = 0, prefix = 'query' } = props;
+    this.node = document.createElement('div');
+    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
+    document.body.appendChild(this.node);
+  }
+
+  componentWillUnmount() {
+    document.body.removeChild(this.node);
+  }
+
+  render() {
+    return ReactDOM.createPortal(this.props.children, this.node);
+  }
+}
+
 export default QueryField;

+ 1 - 5
public/app/containers/Explore/QueryRows.tsx

@@ -1,7 +1,6 @@
 import React, { PureComponent } from 'react';
 
-import promql from './slate-plugins/prism/promql';
-import QueryField from './QueryField';
+import QueryField from './PromQueryField';
 
 class QueryRow extends PureComponent<any, any> {
   constructor(props) {
@@ -62,9 +61,6 @@ class QueryRow extends PureComponent<any, any> {
             portalPrefix="explore"
             onPressEnter={this.handlePressEnter}
             onQueryChange={this.handleChangeQuery}
-            placeholder="Enter a PromQL query"
-            prismLanguage="promql"
-            prismDefinition={promql}
             request={request}
           />
         </div>

+ 39 - 22
public/app/containers/Explore/Typeahead.tsx

@@ -1,17 +1,26 @@
 import React from 'react';
 
-function scrollIntoView(el) {
+import { Suggestion, SuggestionGroup } from './QueryField';
+
+function scrollIntoView(el: HTMLElement) {
   if (!el || !el.offsetParent) {
     return;
   }
-  const container = el.offsetParent;
+  const container = el.offsetParent as HTMLElement;
   if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
     container.scrollTop = el.offsetTop - container.offsetTop;
   }
 }
 
-class TypeaheadItem extends React.PureComponent<any, any> {
-  el: any;
+interface TypeaheadItemProps {
+  isSelected: boolean;
+  item: Suggestion;
+  onClickItem: (Suggestion) => void;
+}
+
+class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
+  el: HTMLElement;
+
   componentDidUpdate(prevProps) {
     if (this.props.isSelected && !prevProps.isSelected) {
       scrollIntoView(this.el);
@@ -22,20 +31,30 @@ class TypeaheadItem extends React.PureComponent<any, any> {
     this.el = el;
   };
 
+  onClick = () => {
+    this.props.onClickItem(this.props.item);
+  };
+
   render() {
-    const { hint, isSelected, label, onClickItem } = this.props;
+    const { isSelected, item } = this.props;
     const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
-    const onClick = () => onClickItem(label);
     return (
-      <li ref={this.getRef} className={className} onClick={onClick}>
-        {label}
-        {hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null}
+      <li ref={this.getRef} className={className} onClick={this.onClick}>
+        {item.detail || item.label}
+        {item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
       </li>
     );
   }
 }
 
-class TypeaheadGroup extends React.PureComponent<any, any> {
+interface TypeaheadGroupProps {
+  items: Suggestion[];
+  label: string;
+  onClickItem: (Suggestion) => void;
+  selected: Suggestion;
+}
+
+class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
   render() {
     const { items, label, selected, onClickItem } = this.props;
     return (
@@ -43,16 +62,8 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
         <div className="typeahead-group__title">{label}</div>
         <ul className="typeahead-group__list">
           {items.map(item => {
-            const text = typeof item === 'object' ? item.text : item;
-            const label = typeof item === 'object' ? item.display || item.text : item;
             return (
-              <TypeaheadItem
-                key={text}
-                onClickItem={onClickItem}
-                isSelected={selected.indexOf(text) > -1}
-                hint={item.hint}
-                label={label}
-              />
+              <TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
             );
           })}
         </ul>
@@ -61,13 +72,19 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
   }
 }
 
-class Typeahead extends React.PureComponent<any, any> {
+interface TypeaheadProps {
+  groupedItems: SuggestionGroup[];
+  menuRef: any;
+  selectedItem: Suggestion | null;
+  onClickItem: (Suggestion) => void;
+}
+class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
   render() {
-    const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
+    const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
     return (
       <ul className="typeahead" ref={menuRef}>
         {groupedItems.map(g => (
-          <TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
+          <TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
         ))}
       </ul>
     );

+ 359 - 58
public/app/containers/Explore/slate-plugins/prism/promql.ts

@@ -1,67 +1,368 @@
+/* tslint:disable max-line-length */
+
 export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
 
 const AGGREGATION_OPERATORS = [
-  'sum',
-  'min',
-  'max',
-  'avg',
-  'stddev',
-  'stdvar',
-  'count',
-  'count_values',
-  'bottomk',
-  'topk',
-  'quantile',
+  {
+    label: 'sum',
+    insertText: 'sum()',
+    documentation: 'Calculate sum over dimensions',
+  },
+  {
+    label: 'min',
+    insertText: 'min()',
+    documentation: 'Select minimum over dimensions',
+  },
+  {
+    label: 'max',
+    insertText: 'max()',
+    documentation: 'Select maximum over dimensions',
+  },
+  {
+    label: 'avg',
+    insertText: 'avg()',
+    documentation: 'Calculate the average over dimensions',
+  },
+  {
+    label: 'stddev',
+    insertText: 'stddev()',
+    documentation: 'Calculate population standard deviation over dimensions',
+  },
+  {
+    label: 'stdvar',
+    insertText: 'stdvar()',
+    documentation: 'Calculate population standard variance over dimensions',
+  },
+  {
+    label: 'count',
+    insertText: 'count()',
+    documentation: 'Count number of elements in the vector',
+  },
+  {
+    label: 'count_values',
+    insertText: 'count_values()',
+    documentation: 'Count number of elements with the same value',
+  },
+  {
+    label: 'bottomk',
+    insertText: 'bottomk()',
+    documentation: 'Smallest k elements by sample value',
+  },
+  {
+    label: 'topk',
+    insertText: 'topk()',
+    documentation: 'Largest k elements by sample value',
+  },
+  {
+    label: 'quantile',
+    insertText: 'quantile()',
+    documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions',
+  },
 ];
 
 export const FUNCTIONS = [
   ...AGGREGATION_OPERATORS,
-  'abs',
-  'absent',
-  'ceil',
-  'changes',
-  'clamp_max',
-  'clamp_min',
-  'count_scalar',
-  'day_of_month',
-  'day_of_week',
-  'days_in_month',
-  'delta',
-  'deriv',
-  'drop_common_labels',
-  'exp',
-  'floor',
-  'histogram_quantile',
-  'holt_winters',
-  'hour',
-  'idelta',
-  'increase',
-  'irate',
-  'label_replace',
-  'ln',
-  'log2',
-  'log10',
-  'minute',
-  'month',
-  'predict_linear',
-  'rate',
-  'resets',
-  'round',
-  'scalar',
-  'sort',
-  'sort_desc',
-  'sqrt',
-  'time',
-  'vector',
-  'year',
-  'avg_over_time',
-  'min_over_time',
-  'max_over_time',
-  'sum_over_time',
-  'count_over_time',
-  'quantile_over_time',
-  'stddev_over_time',
-  'stdvar_over_time',
+  {
+    insertText: 'abs()',
+    label: 'abs',
+    detail: 'abs(v instant-vector)',
+    documentation: 'Returns the input vector with all sample values converted to their absolute value.',
+  },
+  {
+    insertText: 'absent()',
+    label: 'absent',
+    detail: 'absent(v instant-vector)',
+    documentation:
+      'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.',
+  },
+  {
+    insertText: 'ceil()',
+    label: 'ceil',
+    detail: 'ceil(v instant-vector)',
+    documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.',
+  },
+  {
+    insertText: 'changes()',
+    label: 'changes',
+    detail: 'changes(v range-vector)',
+    documentation:
+      'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.',
+  },
+  {
+    insertText: 'clamp_max()',
+    label: 'clamp_max',
+    detail: 'clamp_max(v instant-vector, max scalar)',
+    documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.',
+  },
+  {
+    insertText: 'clamp_min()',
+    label: 'clamp_min',
+    detail: 'clamp_min(v instant-vector, min scalar)',
+    documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.',
+  },
+  {
+    insertText: 'count_scalar()',
+    label: 'count_scalar',
+    detail: 'count_scalar(v instant-vector)',
+    documentation:
+      'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.',
+  },
+  {
+    insertText: 'day_of_month()',
+    label: 'day_of_month',
+    detail: 'day_of_month(v=vector(time()) instant-vector)',
+    documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.',
+  },
+  {
+    insertText: 'day_of_week()',
+    label: 'day_of_week',
+    detail: 'day_of_week(v=vector(time()) instant-vector)',
+    documentation:
+      'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.',
+  },
+  {
+    insertText: 'days_in_month()',
+    label: 'days_in_month',
+    detail: 'days_in_month(v=vector(time()) instant-vector)',
+    documentation:
+      'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.',
+  },
+  {
+    insertText: 'delta()',
+    label: 'delta',
+    detail: 'delta(v range-vector)',
+    documentation:
+      'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.',
+  },
+  {
+    insertText: 'deriv()',
+    label: 'deriv',
+    detail: 'deriv(v range-vector)',
+    documentation:
+      'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.',
+  },
+  {
+    insertText: 'drop_common_labels()',
+    label: 'drop_common_labels',
+    detail: 'drop_common_labels(instant-vector)',
+    documentation: 'Drops all labels that have the same name and value across all series in the input vector.',
+  },
+  {
+    insertText: 'exp()',
+    label: 'exp',
+    detail: 'exp(v instant-vector)',
+    documentation:
+      'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`',
+  },
+  {
+    insertText: 'floor()',
+    label: 'floor',
+    detail: 'floor(v instant-vector)',
+    documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.',
+  },
+  {
+    insertText: 'histogram_quantile()',
+    label: 'histogram_quantile',
+    detail: 'histogram_quantile(φ float, b instant-vector)',
+    documentation:
+      'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.',
+  },
+  {
+    insertText: 'holt_winters()',
+    label: 'holt_winters',
+    detail: 'holt_winters(v range-vector, sf scalar, tf scalar)',
+    documentation:
+      'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.',
+  },
+  {
+    insertText: 'hour()',
+    label: 'hour',
+    detail: 'hour(v=vector(time()) instant-vector)',
+    documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.',
+  },
+  {
+    insertText: 'idelta()',
+    label: 'idelta',
+    detail: 'idelta(v range-vector)',
+    documentation:
+      'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.',
+  },
+  {
+    insertText: 'increase()',
+    label: 'increase',
+    detail: 'increase(v range-vector)',
+    documentation:
+      'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.',
+  },
+  {
+    insertText: 'irate()',
+    label: 'irate',
+    detail: 'irate(v range-vector)',
+    documentation:
+      'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.',
+  },
+  {
+    insertText: 'label_replace()',
+    label: 'label_replace',
+    detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)',
+    documentation:
+      "For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)`  matches the regular expression `regex` against the label `src_label`.  If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.",
+  },
+  {
+    insertText: 'ln()',
+    label: 'ln',
+    detail: 'ln(v instant-vector)',
+    documentation:
+      'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`',
+  },
+  {
+    insertText: 'log2()',
+    label: 'log2',
+    detail: 'log2(v instant-vector)',
+    documentation:
+      'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
+  },
+  {
+    insertText: 'log10()',
+    label: 'log10',
+    detail: 'log10(v instant-vector)',
+    documentation:
+      'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
+  },
+  {
+    insertText: 'minute()',
+    label: 'minute',
+    detail: 'minute(v=vector(time()) instant-vector)',
+    documentation:
+      'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.',
+  },
+  {
+    insertText: 'month()',
+    label: 'month',
+    detail: 'month(v=vector(time()) instant-vector)',
+    documentation:
+      'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.',
+  },
+  {
+    insertText: 'predict_linear()',
+    label: 'predict_linear',
+    detail: 'predict_linear(v range-vector, t scalar)',
+    documentation:
+      'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.',
+  },
+  {
+    insertText: 'rate()',
+    label: 'rate',
+    detail: 'rate(v range-vector)',
+    documentation:
+      "Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.",
+  },
+  {
+    insertText: 'resets()',
+    label: 'resets',
+    detail: 'resets(v range-vector)',
+    documentation:
+      'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.',
+  },
+  {
+    insertText: 'round()',
+    label: 'round',
+    detail: 'round(v instant-vector, to_nearest=1 scalar)',
+    documentation:
+      'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.',
+  },
+  {
+    insertText: 'scalar()',
+    label: 'scalar',
+    detail: 'scalar(v instant-vector)',
+    documentation:
+      'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.',
+  },
+  {
+    insertText: 'sort()',
+    label: 'sort',
+    detail: 'sort(v instant-vector)',
+    documentation: 'Returns vector elements sorted by their sample values, in ascending order.',
+  },
+  {
+    insertText: 'sort_desc()',
+    label: 'sort_desc',
+    detail: 'sort_desc(v instant-vector)',
+    documentation: 'Returns vector elements sorted by their sample values, in descending order.',
+  },
+  {
+    insertText: 'sqrt()',
+    label: 'sqrt',
+    detail: 'sqrt(v instant-vector)',
+    documentation: 'Calculates the square root of all elements in `v`.',
+  },
+  {
+    insertText: 'time()',
+    label: 'time',
+    detail: 'time()',
+    documentation:
+      'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.',
+  },
+  {
+    insertText: 'vector()',
+    label: 'vector',
+    detail: 'vector(s scalar)',
+    documentation: 'Returns the scalar `s` as a vector with no labels.',
+  },
+  {
+    insertText: 'year()',
+    label: 'year',
+    detail: 'year(v=vector(time()) instant-vector)',
+    documentation: 'Returns the year for each of the given times in UTC.',
+  },
+  {
+    insertText: 'avg_over_time()',
+    label: 'avg_over_time',
+    detail: 'avg_over_time(range-vector)',
+    documentation: 'The average value of all points in the specified interval.',
+  },
+  {
+    insertText: 'min_over_time()',
+    label: 'min_over_time',
+    detail: 'min_over_time(range-vector)',
+    documentation: 'The minimum value of all points in the specified interval.',
+  },
+  {
+    insertText: 'max_over_time()',
+    label: 'max_over_time',
+    detail: 'max_over_time(range-vector)',
+    documentation: 'The maximum value of all points in the specified interval.',
+  },
+  {
+    insertText: 'sum_over_time()',
+    label: 'sum_over_time',
+    detail: 'sum_over_time(range-vector)',
+    documentation: 'The sum of all values in the specified interval.',
+  },
+  {
+    insertText: 'count_over_time()',
+    label: 'count_over_time',
+    detail: 'count_over_time(range-vector)',
+    documentation: 'The count of all values in the specified interval.',
+  },
+  {
+    insertText: 'quantile_over_time()',
+    label: 'quantile_over_time',
+    detail: 'quantile_over_time(scalar, range-vector)',
+    documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.',
+  },
+  {
+    insertText: 'stddev_over_time()',
+    label: 'stddev_over_time',
+    detail: 'stddev_over_time(range-vector)',
+    documentation: 'The population standard deviation of the values in the specified interval.',
+  },
+  {
+    insertText: 'stdvar_over_time()',
+    label: 'stdvar_over_time',
+    detail: 'stdvar_over_time(range-vector)',
+    documentation: 'The population standard variance of the values in the specified interval.',
+  },
 ];
 
 const tokenizer = {
@@ -93,7 +394,7 @@ const tokenizer = {
       },
     },
   },
-  function: new RegExp(`\\b(?:${FUNCTIONS.join('|')})(?=\\s*\\()`, 'i'),
+  function: new RegExp(`\\b(?:${FUNCTIONS.map(f => f.label).join('|')})(?=\\s*\\()`, 'i'),
   'context-range': [
     {
       pattern: /\[[^\]]*(?=])/, // [1m]

+ 0 - 12
public/app/containers/Explore/utils/query.ts

@@ -1,15 +1,3 @@
-export function buildQueryOptions({ format, interval, instant, range, queries }) {
-  return {
-    interval,
-    range,
-    targets: queries.map(expr => ({
-      expr,
-      format,
-      instant,
-    })),
-  };
-}
-
 export function generateQueryKey(index = 0) {
   return `Q-${Date.now()}-${Math.random()}-${index}`;
 }

+ 2 - 2
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -54,7 +54,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
         <PageHeader model={nav as any} />
         <div className="page-container page-body">
           <div className="page-action-bar">
-            <h2 className="d-inline-block">Folder Permissions</h2>
+            <h3 className="page-sub-heading">Folder Permissions</h3>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
               <i className="gicon gicon-question gicon--has-hover" />
             </Tooltip>
@@ -68,7 +68,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
             </button>
           </div>
           <SlideDown in={permissions.isAddPermissionsVisible}>
-            <AddPermissions permissions={permissions} backendSrv={backendSrv} />
+            <AddPermissions permissions={permissions} />
           </SlideDown>
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
         </div>

+ 149 - 0
public/app/containers/Teams/TeamGroupSync.tsx

@@ -0,0 +1,149 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { observer } from 'mobx-react';
+import { ITeam, ITeamGroup } from 'app/stores/TeamsStore/TeamsStore';
+import SlideDown from 'app/core/components/Animations/SlideDown';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
+
+interface Props {
+  team: ITeam;
+}
+
+interface State {
+  isAdding: boolean;
+  newGroupId?: string;
+}
+
+const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`;
+
+@observer
+export class TeamGroupSync extends React.Component<Props, State> {
+  constructor(props) {
+    super(props);
+    this.state = { isAdding: false, newGroupId: '' };
+  }
+
+  componentDidMount() {
+    this.props.team.loadGroups();
+  }
+
+  renderGroup(group: ITeamGroup) {
+    return (
+      <tr key={group.groupId}>
+        <td>{group.groupId}</td>
+        <td style={{ width: '1%' }}>
+          <a className="btn btn-danger btn-mini" onClick={() => this.onRemoveGroup(group)}>
+            <i className="fa fa-remove" />
+          </a>
+        </td>
+      </tr>
+    );
+  }
+
+  onToggleAdding = () => {
+    this.setState({ isAdding: !this.state.isAdding });
+  };
+
+  onNewGroupIdChanged = evt => {
+    this.setState({ newGroupId: evt.target.value });
+  };
+
+  onAddGroup = () => {
+    this.props.team.addGroup(this.state.newGroupId);
+    this.setState({ isAdding: false, newGroupId: '' });
+  };
+
+  onRemoveGroup = (group: ITeamGroup) => {
+    this.props.team.removeGroup(group.groupId);
+  };
+
+  isNewGroupValid() {
+    return this.state.newGroupId.length > 1;
+  }
+
+  render() {
+    const { isAdding, newGroupId } = this.state;
+    const groups = this.props.team.groups.values();
+
+    return (
+      <div>
+        <div className="page-action-bar">
+          <h3 className="page-sub-heading">External group sync</h3>
+          <Tooltip className="page-sub-heading-icon" placement="auto" content={headerTooltip}>
+            <i className="gicon gicon-question gicon--has-hover" />
+          </Tooltip>
+          <div className="page-action-bar__spacer" />
+          {groups.length > 0 && (
+            <button className="btn btn-success pull-right" onClick={this.onToggleAdding}>
+              <i className="fa fa-plus" /> Add group
+            </button>
+          )}
+        </div>
+
+        <SlideDown in={isAdding}>
+          <div className="cta-form">
+            <button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
+              <i className="fa fa-close" />
+            </button>
+            <h5>Add External Group</h5>
+            <div className="gf-form-inline">
+              <div className="gf-form">
+                <input
+                  type="text"
+                  className="gf-form-input width-30"
+                  value={newGroupId}
+                  onChange={this.onNewGroupIdChanged}
+                  placeholder="cn=ops,ou=groups,dc=grafana,dc=org"
+                />
+              </div>
+
+              <div className="gf-form">
+                <button
+                  className="btn btn-success gf-form-btn"
+                  onClick={this.onAddGroup}
+                  type="submit"
+                  disabled={!this.isNewGroupValid()}
+                >
+                  Add group
+                </button>
+              </div>
+            </div>
+          </div>
+        </SlideDown>
+
+        {groups.length === 0 &&
+          !isAdding && (
+            <div className="empty-list-cta">
+              <div className="empty-list-cta__title">There are no external groups to sync with</div>
+              <button onClick={this.onToggleAdding} className="empty-list-cta__button btn btn-xlarge btn-success">
+                <i className="gicon gicon-add-team" />
+                Add Group
+              </button>
+              <div className="empty-list-cta__pro-tip">
+                <i className="fa fa-rocket" /> {headerTooltip}
+                <a className="text-link empty-list-cta__pro-tip-link" href="asd" target="_blank">
+                  Learn more
+                </a>
+              </div>
+            </div>
+          )}
+
+        {groups.length > 0 && (
+          <div className="admin-list-table">
+            <table className="filter-table filter-table--hover form-inline">
+              <thead>
+                <tr>
+                  <th>External Group ID</th>
+                  <th style={{ width: '1%' }} />
+                </tr>
+              </thead>
+              <tbody>{groups.map(group => this.renderGroup(group))}</tbody>
+            </table>
+          </div>
+        )}
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamGroupSync);

+ 125 - 0
public/app/containers/Teams/TeamList.tsx

@@ -0,0 +1,125 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { inject, observer } from 'mobx-react';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import { NavStore } from 'app/stores/NavStore/NavStore';
+import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore';
+import { BackendSrv } from 'app/core/services/backend_srv';
+import appEvents from 'app/core/app_events';
+
+interface Props {
+  nav: typeof NavStore.Type;
+  teams: typeof TeamsStore.Type;
+  backendSrv: BackendSrv;
+}
+
+@inject('nav', 'teams')
+@observer
+export class TeamList extends React.Component<Props, any> {
+  constructor(props) {
+    super(props);
+
+    this.props.nav.load('cfg', 'teams');
+    this.fetchTeams();
+  }
+
+  fetchTeams() {
+    this.props.teams.loadTeams();
+  }
+
+  deleteTeam(team: ITeam) {
+    appEvents.emit('confirm-modal', {
+      title: 'Delete',
+      text: 'Are you sure you want to delete Team ' + team.name + '?',
+      yesText: 'Delete',
+      icon: 'fa-warning',
+      onConfirm: () => {
+        this.deleteTeamConfirmed(team);
+      },
+    });
+  }
+
+  deleteTeamConfirmed(team) {
+    this.props.backendSrv.delete('/api/teams/' + team.id).then(this.fetchTeams.bind(this));
+  }
+
+  onSearchQueryChange = evt => {
+    this.props.teams.setSearchQuery(evt.target.value);
+  };
+
+  renderTeamMember(team: ITeam): JSX.Element {
+    let teamUrl = `org/teams/edit/${team.id}`;
+
+    return (
+      <tr key={team.id}>
+        <td className="width-4 text-center link-td">
+          <a href={teamUrl}>
+            <img className="filter-table__avatar" src={team.avatarUrl} />
+          </a>
+        </td>
+        <td className="link-td">
+          <a href={teamUrl}>{team.name}</a>
+        </td>
+        <td className="link-td">
+          <a href={teamUrl}>{team.email}</a>
+        </td>
+        <td className="link-td">
+          <a href={teamUrl}>{team.memberCount}</a>
+        </td>
+        <td className="text-right">
+          <a onClick={() => this.deleteTeam(team)} className="btn btn-danger btn-small">
+            <i className="fa fa-remove" />
+          </a>
+        </td>
+      </tr>
+    );
+  }
+
+  render() {
+    const { nav, teams } = this.props;
+    return (
+      <div>
+        <PageHeader model={nav as any} />
+        <div className="page-container page-body">
+          <div className="page-action-bar">
+            <div className="gf-form gf-form--grow">
+              <label className="gf-form--has-input-icon gf-form--grow">
+                <input
+                  type="text"
+                  className="gf-form-input"
+                  placeholder="Search teams"
+                  value={teams.search}
+                  onChange={this.onSearchQueryChange}
+                />
+                <i className="gf-form-input-icon fa fa-search" />
+              </label>
+            </div>
+
+            <div className="page-action-bar__spacer" />
+
+            <a className="btn btn-success" href="org/teams/new">
+              <i className="fa fa-plus" /> New team
+            </a>
+          </div>
+
+          <div className="admin-list-table">
+            <table className="filter-table filter-table--hover form-inline">
+              <thead>
+                <tr>
+                  <th />
+                  <th>Name</th>
+                  <th>Email</th>
+                  <th>Members</th>
+                  <th style={{ width: '1%' }} />
+                </tr>
+              </thead>
+              <tbody>{teams.filteredTeams.map(team => this.renderTeamMember(team))}</tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamList);

+ 144 - 0
public/app/containers/Teams/TeamMembers.tsx

@@ -0,0 +1,144 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { observer } from 'mobx-react';
+import { ITeam, ITeamMember } from 'app/stores/TeamsStore/TeamsStore';
+import appEvents from 'app/core/app_events';
+import SlideDown from 'app/core/components/Animations/SlideDown';
+import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
+
+interface Props {
+  team: ITeam;
+}
+
+interface State {
+  isAdding: boolean;
+  newTeamMember?: User;
+}
+
+@observer
+export class TeamMembers extends React.Component<Props, State> {
+  constructor(props) {
+    super(props);
+    this.state = { isAdding: false, newTeamMember: null };
+  }
+
+  componentDidMount() {
+    this.props.team.loadMembers();
+  }
+
+  onSearchQueryChange = evt => {
+    this.props.team.setSearchQuery(evt.target.value);
+  };
+
+  removeMember(member: ITeamMember) {
+    appEvents.emit('confirm-modal', {
+      title: 'Remove Member',
+      text: 'Are you sure you want to remove ' + member.login + ' from this group?',
+      yesText: 'Remove',
+      icon: 'fa-warning',
+      onConfirm: () => {
+        this.removeMemberConfirmed(member);
+      },
+    });
+  }
+
+  removeMemberConfirmed(member: ITeamMember) {
+    this.props.team.removeMember(member);
+  }
+
+  renderMember(member: ITeamMember) {
+    return (
+      <tr key={member.userId}>
+        <td className="width-4 text-center">
+          <img className="filter-table__avatar" src={member.avatarUrl} />
+        </td>
+        <td>{member.login}</td>
+        <td>{member.email}</td>
+        <td style={{ width: '1%' }}>
+          <a onClick={() => this.removeMember(member)} className="btn btn-danger btn-mini">
+            <i className="fa fa-remove" />
+          </a>
+        </td>
+      </tr>
+    );
+  }
+
+  onToggleAdding = () => {
+    this.setState({ isAdding: !this.state.isAdding });
+  };
+
+  onUserSelected = (user: User) => {
+    this.setState({ newTeamMember: user });
+  };
+
+  onAddUserToTeam = async () => {
+    await this.props.team.addMember(this.state.newTeamMember.id);
+    await this.props.team.loadMembers();
+    this.setState({ newTeamMember: null });
+  };
+
+  render() {
+    const { newTeamMember, isAdding } = this.state;
+    const members = this.props.team.members.values();
+    const newTeamMemberValue = newTeamMember && newTeamMember.id.toString();
+
+    return (
+      <div>
+        <div className="page-action-bar">
+          <div className="gf-form gf-form--grow">
+            <label className="gf-form--has-input-icon gf-form--grow">
+              <input
+                type="text"
+                className="gf-form-input"
+                placeholder="Search members"
+                value={''}
+                onChange={this.onSearchQueryChange}
+              />
+              <i className="gf-form-input-icon fa fa-search" />
+            </label>
+          </div>
+
+          <div className="page-action-bar__spacer" />
+
+          <button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
+            <i className="fa fa-plus" /> Add a member
+          </button>
+        </div>
+
+        <SlideDown in={isAdding}>
+          <div className="cta-form">
+            <button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
+              <i className="fa fa-close" />
+            </button>
+            <h5>Add Team Member</h5>
+            <div className="gf-form-inline">
+              <UserPicker onSelected={this.onUserSelected} className="width-30" value={newTeamMemberValue} />
+
+              {this.state.newTeamMember && (
+                <button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
+                  Add to team
+                </button>
+              )}
+            </div>
+          </div>
+        </SlideDown>
+
+        <div className="admin-list-table">
+          <table className="filter-table filter-table--hover form-inline">
+            <thead>
+              <tr>
+                <th />
+                <th>Name</th>
+                <th>Email</th>
+                <th style={{ width: '1%' }} />
+              </tr>
+            </thead>
+            <tbody>{members.map(member => this.renderMember(member))}</tbody>
+          </table>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamMembers);

+ 77 - 0
public/app/containers/Teams/TeamPages.tsx

@@ -0,0 +1,77 @@
+import React from 'react';
+import _ from 'lodash';
+import { hot } from 'react-hot-loader';
+import { inject, observer } from 'mobx-react';
+import config from 'app/core/config';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import { NavStore } from 'app/stores/NavStore/NavStore';
+import { TeamsStore, ITeam } from 'app/stores/TeamsStore/TeamsStore';
+import { ViewStore } from 'app/stores/ViewStore/ViewStore';
+import TeamMembers from './TeamMembers';
+import TeamSettings from './TeamSettings';
+import TeamGroupSync from './TeamGroupSync';
+
+interface Props {
+  nav: typeof NavStore.Type;
+  teams: typeof TeamsStore.Type;
+  view: typeof ViewStore.Type;
+}
+
+@inject('nav', 'teams', 'view')
+@observer
+export class TeamPages extends React.Component<Props, any> {
+  isSyncEnabled: boolean;
+  currentPage: string;
+
+  constructor(props) {
+    super(props);
+
+    this.isSyncEnabled = config.buildInfo.isEnterprise;
+    this.currentPage = this.getCurrentPage();
+
+    this.loadTeam();
+  }
+
+  async loadTeam() {
+    const { teams, nav, view } = this.props;
+
+    await teams.loadById(view.routeParams.get('id'));
+
+    nav.initTeamPage(this.getCurrentTeam(), this.currentPage, this.isSyncEnabled);
+  }
+
+  getCurrentTeam(): ITeam {
+    const { teams, view } = this.props;
+    return teams.map.get(view.routeParams.get('id'));
+  }
+
+  getCurrentPage() {
+    const pages = ['members', 'settings', 'groupsync'];
+    const currentPage = this.props.view.routeParams.get('page');
+    return _.includes(pages, currentPage) ? currentPage : pages[0];
+  }
+
+  render() {
+    const { nav } = this.props;
+    const currentTeam = this.getCurrentTeam();
+
+    if (!nav.main) {
+      return null;
+    }
+
+    return (
+      <div>
+        <PageHeader model={nav as any} />
+        {currentTeam && (
+          <div className="page-container page-body">
+            {this.currentPage === 'members' && <TeamMembers team={currentTeam} />}
+            {this.currentPage === 'settings' && <TeamSettings team={currentTeam} />}
+            {this.currentPage === 'groupsync' && this.isSyncEnabled && <TeamGroupSync team={currentTeam} />}
+          </div>
+        )}
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamPages);

+ 69 - 0
public/app/containers/Teams/TeamSettings.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import { hot } from 'react-hot-loader';
+import { observer } from 'mobx-react';
+import { ITeam } from 'app/stores/TeamsStore/TeamsStore';
+import { Label } from 'app/core/components/Forms/Forms';
+
+interface Props {
+  team: ITeam;
+}
+
+@observer
+export class TeamSettings extends React.Component<Props, any> {
+  constructor(props) {
+    super(props);
+  }
+
+  onChangeName = evt => {
+    this.props.team.setName(evt.target.value);
+  };
+
+  onChangeEmail = evt => {
+    this.props.team.setEmail(evt.target.value);
+  };
+
+  onUpdate = evt => {
+    evt.preventDefault();
+    this.props.team.update();
+  };
+
+  render() {
+    return (
+      <div>
+        <h3 className="page-sub-heading">Team Settings</h3>
+        <form name="teamDetailsForm" className="gf-form-group">
+          <div className="gf-form max-width-30">
+            <Label>Name</Label>
+            <input
+              type="text"
+              required
+              value={this.props.team.name}
+              className="gf-form-input max-width-22"
+              onChange={this.onChangeName}
+            />
+          </div>
+          <div className="gf-form max-width-30">
+            <Label tooltip="This is optional and is primarily used to set the team profile avatar (via gravatar service)">
+              Email
+            </Label>
+            <input
+              type="email"
+              className="gf-form-input max-width-22"
+              value={this.props.team.email}
+              placeholder="team@email.com"
+              onChange={this.onChangeEmail}
+            />
+          </div>
+
+          <div className="gf-form-button-row">
+            <button type="submit" className="btn btn-success" onClick={this.onUpdate}>
+              Update
+            </button>
+          </div>
+        </form>
+      </div>
+    );
+  }
+}
+
+export default hot(module)(TeamSettings);

+ 0 - 2
public/app/core/angular_wrappers.ts

@@ -5,7 +5,6 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
 import LoginBackground from './components/Login/LoginBackground';
 import { SearchResult } from './components/search/SearchResult';
 import { TagFilter } from './components/TagFilter/TagFilter';
-import UserPicker from './components/Picker/UserPicker';
 import DashboardPermissions from './components/Permissions/DashboardPermissions';
 
 export function registerAngularDirectives() {
@@ -19,6 +18,5 @@ export function registerAngularDirectives() {
     ['onSelect', { watchDepth: 'reference' }],
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
-  react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
   react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']);
 }

+ 21 - 0
public/app/core/components/Forms/Forms.tsx

@@ -0,0 +1,21 @@
+import React, { SFC, ReactNode } from 'react';
+import Tooltip from '../Tooltip/Tooltip';
+
+interface Props {
+  tooltip?: string;
+  for?: string;
+  children: ReactNode;
+}
+
+export const Label: SFC<Props> = props => {
+  return (
+    <span className="gf-form-label width-10">
+      <span>{props.children}</span>
+      {props.tooltip && (
+        <Tooltip className="gf-form-help-icon--right-normal" placement="auto" content="hello">
+          <i className="gicon gicon-question gicon--has-hover" />
+        </Tooltip>
+      )}
+    </span>
+  );
+};

+ 24 - 24
public/app/core/components/Permissions/AddPermissions.jest.tsx

@@ -1,32 +1,32 @@
-import React from 'react';
+import React from 'react';
+import { shallow } from 'enzyme';
 import AddPermissions from './AddPermissions';
 import { RootStore } from 'app/stores/RootStore/RootStore';
-import { backendSrv } from 'test/mocks/common';
-import { shallow } from 'enzyme';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+
+jest.mock('app/core/services/backend_srv', () => ({
+  getBackendSrv: () => {
+    return {
+      get: () => {
+        return Promise.resolve([
+          { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
+          { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
+        ]);
+      },
+      post: jest.fn(() => Promise.resolve({})),
+    };
+  },
+}));
 
 describe('AddPermissions', () => {
   let wrapper;
   let store;
   let instance;
+  let backendSrv: any = getBackendSrv();
 
   beforeAll(() => {
-    backendSrv.get.mockReturnValue(
-      Promise.resolve([
-        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
-        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
-      ])
-    );
-
-    backendSrv.post = jest.fn(() => Promise.resolve({}));
-
-    store = RootStore.create(
-      {},
-      {
-        backendSrv: backendSrv,
-      }
-    );
-
-    wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} />);
+    store = RootStore.create({}, { backendSrv: backendSrv });
+    wrapper = shallow(<AddPermissions permissions={store.permissions} />);
     instance = wrapper.instance();
     return store.permissions.load(1, true, false);
   });
@@ -43,8 +43,8 @@ describe('AddPermissions', () => {
         login: 'user2',
       };
 
-      instance.typeChanged(evt);
-      instance.userPicked(userItem);
+      instance.onTypeChanged(evt);
+      instance.onUserSelected(userItem);
 
       wrapper.update();
 
@@ -70,8 +70,8 @@ describe('AddPermissions', () => {
         name: 'ug1',
       };
 
-      instance.typeChanged(evt);
-      instance.teamPicked(teamItem);
+      instance.onTypeChanged(evt);
+      instance.onTeamSelected(teamItem);
 
       wrapper.update();
 

+ 23 - 38
public/app/core/components/Permissions/AddPermissions.tsx

@@ -1,24 +1,19 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 import { observer } from 'mobx-react';
 import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
-import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
-import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
+import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
+import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
 import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
 import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
 
-export interface IProps {
+export interface Props {
   permissions: any;
-  backendSrv: any;
 }
+
 @observer
-class AddPermissions extends Component<IProps, any> {
+class AddPermissions extends Component<Props, any> {
   constructor(props) {
     super(props);
-    this.userPicked = this.userPicked.bind(this);
-    this.teamPicked = this.teamPicked.bind(this);
-    this.permissionPicked = this.permissionPicked.bind(this);
-    this.typeChanged = this.typeChanged.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
   }
 
   componentWillMount() {
@@ -26,49 +21,49 @@ class AddPermissions extends Component<IProps, any> {
     permissions.resetNewType();
   }
 
-  typeChanged(evt) {
+  onTypeChanged = evt => {
     const { value } = evt.target;
     const { permissions } = this.props;
 
     permissions.setNewType(value);
-  }
+  };
 
-  userPicked(user: User) {
+  onUserSelected = (user: User) => {
     const { permissions } = this.props;
     if (!user) {
       permissions.newItem.setUser(null, null);
       return;
     }
     return permissions.newItem.setUser(user.id, user.login, user.avatarUrl);
-  }
+  };
 
-  teamPicked(team: Team) {
+  onTeamSelected = (team: Team) => {
     const { permissions } = this.props;
     if (!team) {
       permissions.newItem.setTeam(null, null);
       return;
     }
     return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl);
-  }
+  };
 
-  permissionPicked(permission: OptionWithDescription) {
+  onPermissionChanged = (permission: OptionWithDescription) => {
     const { permissions } = this.props;
     return permissions.newItem.setPermission(permission.value);
-  }
+  };
 
   resetNewType() {
     const { permissions } = this.props;
     return permissions.resetNewType();
   }
 
-  handleSubmit(evt) {
+  onSubmit = evt => {
     evt.preventDefault();
     const { permissions } = this.props;
     permissions.addStoreItem();
-  }
+  };
 
   render() {
-    const { permissions, backendSrv } = this.props;
+    const { permissions } = this.props;
     const newItem = permissions.newItem;
     const pickerClassName = 'width-20';
 
@@ -79,12 +74,12 @@ class AddPermissions extends Component<IProps, any> {
         <button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
           <i className="fa fa-close" />
         </button>
-        <form name="addPermission" onSubmit={this.handleSubmit}>
-          <h6>Add Permission For</h6>
+        <form name="addPermission" onSubmit={this.onSubmit}>
+          <h5>Add Permission For</h5>
           <div className="gf-form-inline">
             <div className="gf-form">
               <div className="gf-form-select-wrapper">
-                <select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.typeChanged}>
+                <select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.onTypeChanged}>
                   {aclTypes.map((option, idx) => {
                     return (
                       <option key={idx} value={option.value}>
@@ -98,30 +93,20 @@ class AddPermissions extends Component<IProps, any> {
 
             {newItem.type === 'User' ? (
               <div className="gf-form">
-                <UserPicker
-                  backendSrv={backendSrv}
-                  handlePicked={this.userPicked}
-                  value={newItem.userId}
-                  className={pickerClassName}
-                />
+                <UserPicker onSelected={this.onUserSelected} value={newItem.userId} className={pickerClassName} />
               </div>
             ) : null}
 
             {newItem.type === 'Group' ? (
               <div className="gf-form">
-                <TeamPicker
-                  backendSrv={backendSrv}
-                  handlePicked={this.teamPicked}
-                  value={newItem.teamId}
-                  className={pickerClassName}
-                />
+                <TeamPicker onSelected={this.onTeamSelected} value={newItem.teamId} className={pickerClassName} />
               </div>
             ) : null}
 
             <div className="gf-form">
               <DescriptionPicker
                 optionsWithDesc={permissionOptions}
-                handlePicked={this.permissionPicked}
+                onSelected={this.onPermissionChanged}
                 value={newItem.permission}
                 disabled={false}
                 className={'gf-form-input--form-dropdown-right'}

+ 4 - 3
public/app/core/components/Permissions/DashboardPermissions.tsx

@@ -8,13 +8,14 @@ import AddPermissions from 'app/core/components/Permissions/AddPermissions';
 import SlideDown from 'app/core/components/Animations/SlideDown';
 import { FolderInfo } from './FolderInfo';
 
-export interface IProps {
+export interface Props {
   dashboardId: number;
   folder?: FolderInfo;
   backendSrv: any;
 }
+
 @observer
-class DashboardPermissions extends Component<IProps, any> {
+class DashboardPermissions extends Component<Props, any> {
   permissions: any;
 
   constructor(props) {
@@ -53,7 +54,7 @@ class DashboardPermissions extends Component<IProps, any> {
           </div>
         </div>
         <SlideDown in={this.permissions.isAddPermissionsVisible}>
-          <AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
+          <AddPermissions permissions={this.permissions} />
         </SlideDown>
         <Permissions
           permissions={this.permissions}

+ 1 - 1
public/app/core/components/Permissions/DisabledPermissionsListItem.tsx

@@ -25,7 +25,7 @@ export default class DisabledPermissionListItem extends Component<IProps, any> {
           <div className="gf-form">
             <DescriptionPicker
               optionsWithDesc={permissionOptions}
-              handlePicked={() => {}}
+              onSelected={() => {}}
               value={item.permission}
               disabled={true}
               className={'gf-form-input--form-dropdown-right'}

+ 1 - 1
public/app/core/components/Permissions/PermissionsListItem.tsx

@@ -68,7 +68,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
         <div className="gf-form">
           <DescriptionPicker
             optionsWithDesc={permissionOptions}
-            handlePicked={handleChangePermission}
+            onSelected={handleChangePermission}
             value={item.permission}
             disabled={item.inherited}
             className={'gf-form-input--form-dropdown-right'}

+ 5 - 5
public/app/core/components/Picker/DescriptionPicker.tsx

@@ -2,9 +2,9 @@ import React, { Component } from 'react';
 import Select from 'react-select';
 import DescriptionOption from './DescriptionOption';
 
-export interface IProps {
+export interface Props {
   optionsWithDesc: OptionWithDescription[];
-  handlePicked: (permission) => void;
+  onSelected: (permission) => void;
   value: number;
   disabled: boolean;
   className?: string;
@@ -16,14 +16,14 @@ export interface OptionWithDescription {
   description: string;
 }
 
-class DescriptionPicker extends Component<IProps, any> {
+class DescriptionPicker extends Component<Props, any> {
   constructor(props) {
     super(props);
     this.state = {};
   }
 
   render() {
-    const { optionsWithDesc, handlePicked, value, disabled, className } = this.props;
+    const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
 
     return (
       <div className="permissions-picker">
@@ -34,7 +34,7 @@ class DescriptionPicker extends Component<IProps, any> {
           clearable={false}
           labelKey="label"
           options={optionsWithDesc}
-          onChange={handlePicked}
+          onChange={onSelected}
           className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={DescriptionOption}
           placeholder="Choose"

+ 14 - 10
public/app/core/components/Picker/TeamPicker.jest.tsx

@@ -1,19 +1,23 @@
-import React from 'react';
+import React from 'react';
 import renderer from 'react-test-renderer';
-import TeamPicker from './TeamPicker';
+import { TeamPicker } from './TeamPicker';
 
-const model = {
-  backendSrv: {
-    get: () => {
-      return new Promise((resolve, reject) => {});
-    },
+jest.mock('app/core/services/backend_srv', () => ({
+  getBackendSrv: () => {
+    return {
+      get: () => {
+        return Promise.resolve([]);
+      },
+    };
   },
-  handlePicked: () => {},
-};
+}));
 
 describe('TeamPicker', () => {
   it('renders correctly', () => {
-    const tree = renderer.create(<TeamPicker {...model} />).toJSON();
+    const props = {
+      onSelected: () => {},
+    };
+    const tree = renderer.create(<TeamPicker {...props} />).toJSON();
     expect(tree).toMatchSnapshot();
   });
 });

+ 18 - 20
public/app/core/components/Picker/TeamPicker.tsx

@@ -1,18 +1,19 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 import Select from 'react-select';
 import PickerOption from './PickerOption';
-import withPicker from './withPicker';
 import { debounce } from 'lodash';
+import { getBackendSrv } from 'app/core/services/backend_srv';
 
-export interface IProps {
-  backendSrv: any;
-  isLoading: boolean;
-  toggleLoading: any;
-  handlePicked: (user) => void;
+export interface Props {
+  onSelected: (team: Team) => void;
   value?: string;
   className?: string;
 }
 
+export interface State {
+  isLoading;
+}
+
 export interface Team {
   id: number;
   label: string;
@@ -20,13 +21,12 @@ export interface Team {
   avatarUrl: string;
 }
 
-class TeamPicker extends Component<IProps, any> {
+export class TeamPicker extends Component<Props, State> {
   debouncedSearch: any;
-  backendSrv: any;
 
   constructor(props) {
     super(props);
-    this.state = {};
+    this.state = { isLoading: false };
     this.search = this.search.bind(this);
 
     this.debouncedSearch = debounce(this.search, 300, {
@@ -36,9 +36,9 @@ class TeamPicker extends Component<IProps, any> {
   }
 
   search(query?: string) {
-    const { toggleLoading, backendSrv } = this.props;
+    const backendSrv = getBackendSrv();
+    this.setState({ isLoading: true });
 
-    toggleLoading(true);
     return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
       const teams = result.teams.map(team => {
         return {
@@ -49,18 +49,18 @@ class TeamPicker extends Component<IProps, any> {
         };
       });
 
-      toggleLoading(false);
+      this.setState({ isLoading: false });
       return { options: teams };
     });
   }
 
   render() {
-    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked, value, className } = this.props;
+    const { onSelected, value, className } = this.props;
+    const { isLoading } = this.state;
 
     return (
       <div className="user-picker">
-        <AsyncComponent
+        <Select.Async
           valueKey="id"
           multi={false}
           labelKey="label"
@@ -69,10 +69,10 @@ class TeamPicker extends Component<IProps, any> {
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
           noResultsText="No teams found"
-          onChange={handlePicked}
+          onChange={onSelected}
           className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
-          placeholder="Choose"
+          placeholder="Select a team"
           value={value}
           autosize={true}
         />
@@ -80,5 +80,3 @@ class TeamPicker extends Component<IProps, any> {
     );
   }
 }
-
-export default withPicker(TeamPicker);

+ 11 - 10
public/app/core/components/Picker/UserPicker.jest.tsx

@@ -1,19 +1,20 @@
-import React from 'react';
+import React from 'react';
 import renderer from 'react-test-renderer';
-import UserPicker from './UserPicker';
+import { UserPicker } from './UserPicker';
 
-const model = {
-  backendSrv: {
-    get: () => {
-      return new Promise((resolve, reject) => {});
-    },
+jest.mock('app/core/services/backend_srv', () => ({
+  getBackendSrv: () => {
+    return {
+      get: () => {
+        return Promise.resolve([]);
+      },
+    };
   },
-  handlePicked: () => {},
-};
+}));
 
 describe('UserPicker', () => {
   it('renders correctly', () => {
-    const tree = renderer.create(<UserPicker {...model} />).toJSON();
+    const tree = renderer.create(<UserPicker onSelected={() => {}} />).toJSON();
     expect(tree).toMatchSnapshot();
   });
 });

+ 30 - 27
public/app/core/components/Picker/UserPicker.tsx

@@ -1,18 +1,19 @@
 import React, { Component } from 'react';
 import Select from 'react-select';
 import PickerOption from './PickerOption';
-import withPicker from './withPicker';
 import { debounce } from 'lodash';
+import { getBackendSrv } from 'app/core/services/backend_srv';
 
-export interface IProps {
-  backendSrv: any;
-  isLoading: boolean;
-  toggleLoading: any;
-  handlePicked: (user) => void;
+export interface Props {
+  onSelected: (user: User) => void;
   value?: string;
   className?: string;
 }
 
+export interface State {
+  isLoading: boolean;
+}
+
 export interface User {
   id: number;
   label: string;
@@ -20,13 +21,12 @@ export interface User {
   login: string;
 }
 
-class UserPicker extends Component<IProps, any> {
+export class UserPicker extends Component<Props, State> {
   debouncedSearch: any;
-  backendSrv: any;
 
   constructor(props) {
     super(props);
-    this.state = {};
+    this.state = { isLoading: false };
     this.search = this.search.bind(this);
 
     this.debouncedSearch = debounce(this.search, 300, {
@@ -36,29 +36,34 @@ class UserPicker extends Component<IProps, any> {
   }
 
   search(query?: string) {
-    const { toggleLoading, backendSrv } = this.props;
+    const backendSrv = getBackendSrv();
 
-    toggleLoading(true);
-    return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => {
-      const users = result.map(user => {
+    this.setState({ isLoading: true });
+
+    return backendSrv
+      .get(`/api/org/users?query=${query}&limit=10`)
+      .then(result => {
         return {
-          id: user.userId,
-          label: `${user.login} - ${user.email}`,
-          avatarUrl: user.avatarUrl,
-          login: user.login,
+          options: result.map(user => ({
+            id: user.userId,
+            label: `${user.login} - ${user.email}`,
+            avatarUrl: user.avatarUrl,
+            login: user.login,
+          })),
         };
+      })
+      .finally(() => {
+        this.setState({ isLoading: false });
       });
-      toggleLoading(false);
-      return { options: users };
-    });
   }
 
   render() {
-    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked, value, className } = this.props;
+    const { value, className } = this.props;
+    const { isLoading } = this.state;
+
     return (
       <div className="user-picker">
-        <AsyncComponent
+        <Select.Async
           valueKey="id"
           multi={false}
           labelKey="label"
@@ -67,10 +72,10 @@ class UserPicker extends Component<IProps, any> {
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
           noResultsText="No users found"
-          onChange={handlePicked}
+          onChange={this.props.onSelected}
           className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
-          placeholder="Choose"
+          placeholder="Select user"
           value={value}
           autosize={true}
         />
@@ -78,5 +83,3 @@ class UserPicker extends Component<IProps, any> {
     );
   }
 }
-
-export default withPicker(UserPicker);

+ 0 - 34
public/app/core/components/Picker/withPicker.tsx

@@ -1,34 +0,0 @@
-import React, { Component } from 'react';
-
-export interface IProps {
-  backendSrv: any;
-  handlePicked: (data) => void;
-  value?: string;
-  className?: string;
-}
-
-export default function withPicker(WrappedComponent) {
-  return class WithPicker extends Component<IProps, any> {
-    constructor(props) {
-      super(props);
-      this.toggleLoading = this.toggleLoading.bind(this);
-
-      this.state = {
-        isLoading: false,
-      };
-    }
-
-    toggleLoading(isLoading) {
-      this.setState(prevState => {
-        return {
-          ...prevState,
-          isLoading: isLoading,
-        };
-      });
-    }
-
-    render() {
-      return <WrappedComponent toggleLoading={this.toggleLoading} isLoading={this.state.isLoading} {...this.props} />;
-    }
-  };
-}

+ 3 - 1
public/app/core/components/grafana_app.ts

@@ -8,7 +8,7 @@ import appEvents from 'app/core/app_events';
 import Drop from 'tether-drop';
 import { createStore } from 'app/stores/store';
 import colors from 'app/core/utils/colors';
-import { BackendSrv } from 'app/core/services/backend_srv';
+import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 
 export class GrafanaCtrl {
@@ -24,6 +24,8 @@ export class GrafanaCtrl {
     backendSrv: BackendSrv,
     datasourceSrv: DatasourceSrv
   ) {
+    // sets singleston instances for angular services so react components can access them
+    setBackendSrv(backendSrv);
     createStore({ backendSrv, datasourceSrv });
 
     $scope.init = function() {

+ 4 - 2
public/app/core/components/scroll/page_scroll.ts

@@ -29,11 +29,13 @@ export function pageScrollbar() {
       scope.$on('$routeChangeSuccess', () => {
         lastPos = 0;
         elem[0].scrollTop = 0;
-        elem[0].focus();
+        // Focus page to enable scrolling by keyboard
+        elem[0].focus({ preventScroll: true });
       });
 
       elem[0].tabIndex = -1;
-      elem[0].focus();
+      // Focus page to enable scrolling by keyboard
+      elem[0].focus({ preventScroll: true });
     },
   };
 }

+ 0 - 64
public/app/core/components/team_picker.ts

@@ -1,64 +0,0 @@
-import coreModule from 'app/core/core_module';
-import _ from 'lodash';
-
-const template = `
-<div class="dropdown">
-  <gf-form-dropdown model="ctrl.group"
-                    get-options="ctrl.debouncedSearchGroups($query)"
-                    css-class="gf-size-auto"
-                    on-change="ctrl.onChange($option)"
-  </gf-form-dropdown>
-</div>
-`;
-export class TeamPickerCtrl {
-  group: any;
-  teamPicked: any;
-  debouncedSearchGroups: any;
-
-  /** @ngInject */
-  constructor(private backendSrv) {
-    this.debouncedSearchGroups = _.debounce(this.searchGroups, 500, {
-      leading: true,
-      trailing: false,
-    });
-    this.reset();
-  }
-
-  reset() {
-    this.group = { text: 'Choose', value: null };
-  }
-
-  searchGroups(query: string) {
-    return Promise.resolve(
-      this.backendSrv.get('/api/teams/search?perpage=10&page=1&query=' + query).then(result => {
-        return _.map(result.teams, ug => {
-          return { text: ug.name, value: ug };
-        });
-      })
-    );
-  }
-
-  onChange(option) {
-    this.teamPicked({ $group: option.value });
-  }
-}
-
-export function teamPicker() {
-  return {
-    restrict: 'E',
-    template: template,
-    controller: TeamPickerCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {
-      teamPicked: '&',
-    },
-    link: function(scope, elem, attrs, ctrl) {
-      scope.$on('team-picker-reset', () => {
-        ctrl.reset();
-      });
-    },
-  };
-}
-
-coreModule.directive('teamPicker', teamPicker);

+ 0 - 71
public/app/core/components/user_picker.ts

@@ -1,71 +0,0 @@
-import coreModule from 'app/core/core_module';
-import _ from 'lodash';
-
-const template = `
-<div class="dropdown">
-  <gf-form-dropdown model="ctrl.user"
-                    get-options="ctrl.debouncedSearchUsers($query)"
-                    css-class="gf-size-auto"
-                    on-change="ctrl.onChange($option)"
-  </gf-form-dropdown>
-</div>
-`;
-export class UserPickerCtrl {
-  user: any;
-  debouncedSearchUsers: any;
-  userPicked: any;
-
-  /** @ngInject */
-  constructor(private backendSrv) {
-    this.reset();
-    this.debouncedSearchUsers = _.debounce(this.searchUsers, 500, {
-      leading: true,
-      trailing: false,
-    });
-  }
-
-  searchUsers(query: string) {
-    return Promise.resolve(
-      this.backendSrv.get('/api/users/search?perpage=10&page=1&query=' + query).then(result => {
-        return _.map(result.users, user => {
-          return { text: user.login + ' -  ' + user.email, value: user };
-        });
-      })
-    );
-  }
-
-  onChange(option) {
-    this.userPicked({ $user: option.value });
-  }
-
-  reset() {
-    this.user = { text: 'Choose', value: null };
-  }
-}
-
-export interface User {
-  id: number;
-  name: string;
-  login: string;
-  email: string;
-}
-
-export function userPicker() {
-  return {
-    restrict: 'E',
-    template: template,
-    controller: UserPickerCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    scope: {
-      userPicked: '&',
-    },
-    link: function(scope, elem, attrs, ctrl) {
-      scope.$on('user-picker-reset', () => {
-        ctrl.reset();
-      });
-    },
-  };
-}
-
-coreModule.directive('userPicker', userPicker);

+ 0 - 4
public/app/core/core.ts

@@ -45,8 +45,6 @@ import { KeybindingSrv } from './services/keybindingSrv';
 import { helpModal } from './components/help/help';
 import { JsonExplorer } from './components/json_explorer/json_explorer';
 import { NavModelSrv, NavModel } from './nav_model_srv';
-import { userPicker } from './components/user_picker';
-import { teamPicker } from './components/team_picker';
 import { geminiScrollbar } from './components/scroll/scroll';
 import { pageScrollbar } from './components/scroll/page_scroll';
 import { gfPageDirective } from './components/gf_page';
@@ -85,8 +83,6 @@ export {
   JsonExplorer,
   NavModelSrv,
   NavModel,
-  userPicker,
-  teamPicker,
   geminiScrollbar,
   pageScrollbar,
   gfPageDirective,

+ 29 - 0
public/app/core/logs_model.ts

@@ -0,0 +1,29 @@
+export enum LogLevel {
+  crit = 'crit',
+  warn = 'warn',
+  err = 'error',
+  error = 'error',
+  info = 'info',
+  debug = 'debug',
+  trace = 'trace',
+}
+
+export interface LogSearchMatch {
+  start: number;
+  length: number;
+  text?: string;
+}
+
+export interface LogRow {
+  key: string;
+  entry: string;
+  logLevel: LogLevel;
+  timestamp: string;
+  timeFromNow: string;
+  timeLocal: string;
+  searchMatches?: LogSearchMatch[];
+}
+
+export interface LogsModel {
+  rows: LogRow[];
+}

+ 14 - 0
public/app/core/services/backend_srv.ts

@@ -368,3 +368,17 @@ export class BackendSrv {
 }
 
 coreModule.service('backendSrv', BackendSrv);
+
+//
+// Code below is to expore the service to react components
+//
+
+let singletonInstance: BackendSrv;
+
+export function setBackendSrv(instance: BackendSrv) {
+  singletonInstance = instance;
+}
+
+export function getBackendSrv(): BackendSrv {
+  return singletonInstance;
+}

+ 1 - 1
public/app/core/services/keybindingSrv.ts

@@ -191,7 +191,7 @@ export class KeybindingSrv {
               range,
             };
             const exploreState = encodePathComponent(JSON.stringify(state));
-            this.$location.url(`/explore/${exploreState}`);
+            this.$location.url(`/explore?state=${exploreState}`);
           }
         }
       });

+ 3 - 1
public/app/core/utils/kbn.ts

@@ -449,6 +449,7 @@ kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr');
 kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr');
 kbn.valueFormats.currencyCZK = kbn.formatBuilders.currency('czk');
 kbn.valueFormats.currencyCHF = kbn.formatBuilders.currency('CHF');
+kbn.valueFormats.currencyPLN = kbn.formatBuilders.currency('zł');
 
 // Data (Binary)
 kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b');
@@ -880,6 +881,7 @@ kbn.getUnitFormats = function() {
         { text: 'Swedish Krona (kr)', value: 'currencySEK' },
         { text: 'Czech koruna (czk)', value: 'currencyCZK' },
         { text: 'Swiss franc (CHF)', value: 'currencyCHF' },
+        { text: 'Polish Złoty (PLN)', value: 'currencyPLN' },
       ],
     },
     {
@@ -957,7 +959,7 @@ kbn.getUnitFormats = function() {
       text: 'throughput',
       submenu: [
         { text: 'ops/sec (ops)', value: 'ops' },
-        { text: 'requets/sec (rps)', value: 'reqps' },
+        { text: 'requests/sec (rps)', value: 'reqps' },
         { text: 'reads/sec (rps)', value: 'rps' },
         { text: 'writes/sec (wps)', value: 'wps' },
         { text: 'I/O ops/sec (iops)', value: 'iops' },

+ 1 - 1
public/app/features/dashboard/dashnav/dashnav.html

@@ -3,7 +3,7 @@
 	<div>
 		<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
 			<i class="gicon gicon-dashboard"></i>
-			{{ctrl.dashboard.title}}
+			<span ng-if="ctrl.dashboard.meta.folderId > 0" class="navbar-page-btn--folder">{{ctrl.dashboard.meta.folderTitle}} / </span>{{ctrl.dashboard.title}}
 			<i class="fa fa-caret-down"></i>
 		</a>
 	</div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است