Explorar o código

Merge branch 'master' into new-data-source-as-separate-page

Peter Holmberg %!s(int64=7) %!d(string=hai) anos
pai
achega
e1c77f634d
Modificáronse 100 ficheiros con 4297 adicións e 1338 borrados
  1. 7 3
      .circleci/config.yml
  2. 0 22
      .github/CONTRIBUTING.md
  3. 2 0
      .gitignore
  4. 12 0
      CHANGELOG.md
  5. 56 0
      CONTRIBUTING.md
  6. 7 1
      Gopkg.lock
  7. 4 0
      Gopkg.toml
  8. 1166 0
      devenv/dev-dashboards/panel_tests_slow_queries_and_annotations.json
  9. 27 6
      devenv/docker/ha_test/docker-compose.yaml
  10. 4 1
      docs/sources/installation/configuration.md
  11. 1 1
      package.json
  12. 15 1
      pkg/api/dataproxy.go
  13. 19 0
      pkg/api/dataproxy_test.go
  14. 17 0
      pkg/api/pluginproxy/ds_proxy_test.go
  15. 2 2
      pkg/models/alert.go
  16. 33 22
      pkg/models/alert_notifications.go
  17. 12 5
      pkg/services/alerting/interfaces.go
  18. 70 39
      pkg/services/alerting/notifier.go
  19. 1 1
      pkg/services/alerting/notifiers/alertmanager.go
  20. 23 33
      pkg/services/alerting/notifiers/base.go
  21. 57 60
      pkg/services/alerting/notifiers/base_test.go
  22. 25 20
      pkg/services/alerting/notifiers/telegram_test.go
  23. 6 13
      pkg/services/alerting/result_handler.go
  24. 3 0
      pkg/services/alerting/rule.go
  25. 1 1
      pkg/services/alerting/test_notification.go
  26. 1 1
      pkg/services/provisioning/dashboards/file_reader.go
  27. 6 0
      pkg/services/sqlstore/alert.go
  28. 115 28
      pkg/services/sqlstore/alert_notification.go
  29. 128 53
      pkg/services/sqlstore/alert_notification_test.go
  30. 23 0
      pkg/services/sqlstore/migrations/alert_mig.go
  31. 2 0
      pkg/services/sqlstore/migrator/dialect.go
  32. 12 0
      pkg/services/sqlstore/migrator/mysql_dialect.go
  33. 11 0
      pkg/services/sqlstore/migrator/postgres_dialect.go
  34. 11 0
      pkg/services/sqlstore/migrator/sqlite_dialect.go
  35. 6 6
      pkg/tsdb/elasticsearch/client/client_test.go
  36. 0 8
      pkg/tsdb/mssql/macros.go
  37. 0 84
      pkg/tsdb/mssql/macros_test.go
  38. 55 26
      pkg/tsdb/mssql/mssql_test.go
  39. 0 8
      pkg/tsdb/mysql/macros.go
  40. 0 84
      pkg/tsdb/mysql/macros_test.go
  41. 53 28
      pkg/tsdb/mysql/mysql_test.go
  42. 0 8
      pkg/tsdb/postgres/macros.go
  43. 0 84
      pkg/tsdb/postgres/macros_test.go
  44. 50 25
      pkg/tsdb/postgres/postgres_test.go
  45. 4 0
      pkg/tsdb/sql_engine.go
  46. 29 0
      pkg/tsdb/sql_engine_test.go
  47. 64 20
      pkg/tsdb/testdata/scenarios.go
  48. 24 0
      public/app/core/components/OrgActionBar/OrgActionBar.test.tsx
  49. 44 0
      public/app/core/components/OrgActionBar/OrgActionBar.tsx
  50. 2 3
      public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap
  51. 5 2
      public/app/core/components/grafana_app.ts
  52. 42 0
      public/app/core/services/AngularLoader.ts
  53. 12 10
      public/app/core/services/keybindingSrv.ts
  54. 5 5
      public/app/core/utils/explore.test.ts
  55. 78 0
      public/app/core/utils/explore.ts
  56. 0 5
      public/app/core/utils/location_util.ts
  57. 1 1
      public/app/features/dashboard/state/actions.ts
  58. 0 23
      public/app/features/datasources/DataSourcesActionBar.test.tsx
  59. 0 62
      public/app/features/datasources/DataSourcesActionBar.tsx
  60. 3 0
      public/app/features/datasources/DataSourcesListPage.test.tsx
  61. 38 7
      public/app/features/datasources/DataSourcesListPage.tsx
  62. 0 42
      public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap
  63. 11 1
      public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
  64. 105 103
      public/app/features/explore/Explore.tsx
  65. 19 9
      public/app/features/explore/PromQueryField.tsx
  66. 40 28
      public/app/features/explore/QueryField.tsx
  67. 3 4
      public/app/features/explore/QueryRows.tsx
  68. 0 1
      public/app/features/explore/TimePicker.tsx
  69. 3 1
      public/app/features/explore/Typeahead.tsx
  70. 3 23
      public/app/features/explore/Wrapper.tsx
  71. 6 4
      public/app/features/explore/utils/query.ts
  72. 1 1
      public/app/features/folders/state/actions.ts
  73. 0 2
      public/app/features/org/all.ts
  74. 0 87
      public/app/features/org/org_users_ctrl.ts
  75. 0 105
      public/app/features/org/partials/orgUsers.html
  76. 12 10
      public/app/features/panel/metrics_panel_ctrl.ts
  77. 1 1
      public/app/features/panel/specs/metrics_panel_ctrl.test.ts
  78. 0 31
      public/app/features/plugins/PluginActionBar.test.tsx
  79. 0 62
      public/app/features/plugins/PluginActionBar.tsx
  80. 3 0
      public/app/features/plugins/PluginListPage.test.tsx
  81. 23 7
      public/app/features/plugins/PluginListPage.tsx
  82. 12 1
      public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
  83. 1 1
      public/app/features/plugins/state/actions.ts
  84. 32 0
      public/app/features/users/InviteesTable.test.tsx
  85. 64 0
      public/app/features/users/InviteesTable.tsx
  86. 52 0
      public/app/features/users/UsersActionBar.test.tsx
  87. 97 0
      public/app/features/users/UsersActionBar.tsx
  88. 55 0
      public/app/features/users/UsersListPage.test.tsx
  89. 136 0
      public/app/features/users/UsersListPage.tsx
  90. 33 0
      public/app/features/users/UsersTable.test.tsx
  91. 67 0
      public/app/features/users/UsersTable.tsx
  92. 56 0
      public/app/features/users/__mocks__/userMocks.ts
  93. 318 0
      public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap
  94. 155 0
      public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap
  95. 22 0
      public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
  96. 444 0
      public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
  97. 79 0
      public/app/features/users/state/actions.ts
  98. 32 0
      public/app/features/users/state/reducers.ts
  99. 18 0
      public/app/features/users/state/selectors.ts
  100. 0 2
      public/app/plugins/datasource/cloudwatch/datasource.ts

+ 7 - 3
.circleci/config.yml

@@ -158,14 +158,18 @@ jobs:
           name: sha-sum packages
           command: 'go run build.go sha-dist'
       - run:
-          name: Build Grafana.com publisher
+          name: Build Grafana.com master publisher
           command: 'go build -o scripts/publish scripts/build/publish.go'
+      - run:
+          name: Build Grafana.com release publisher
+          command: 'cd scripts/build/release_publisher && go build -o release_publisher .'
       - persist_to_workspace:
           root: .
           paths:
             - dist/grafana*
             - scripts/*.sh
             - scripts/publish
+            - scripts/build/release_publisher/release_publisher
 
   build:
     docker:
@@ -299,8 +303,8 @@ jobs:
           name: deploy to s3
           command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release'
       - run:
-          name: Trigger Windows build
-          command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} release'
+          name: Deploy to Grafana.com
+          command: './scripts/build/publish.sh'
 
 workflows:
   version: 2

+ 0 - 22
.github/CONTRIBUTING.md

@@ -1,22 +0,0 @@
-Follow the setup guide in README.md
-
-### Rebuild frontend assets on source change
-```
-yarn watch
-```
-
-### Rerun tests on source change
-```
-yarn jest
-```
-
-### Run tests for backend assets before commit
-```
-test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)"
-```
-
-### Run tests for frontend assets before commit
-```
-yarn test
-go test -v ./pkg/...
-```

+ 2 - 0
.gitignore

@@ -73,3 +73,5 @@ debug.test
 
 /devenv/bulk-dashboards/*.json
 /devenv/bulk_alerting_dashboards/*.json
+
+/scripts/build/release_publisher/release_publisher

+ 12 - 0
CHANGELOG.md

@@ -1,5 +1,17 @@
+# 5.4.0 (unreleased)
+
+### Minor
+
+* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
+
 # 5.3.0 (unreleased)
 
+# 5.3.0-beta3 (2018-10-03)
+
+* **Stackdriver**: Fix for missing ngInject [#13511](https://github.com/grafana/grafana/pull/13511)
+* **Permissions**: Fix for broken permissions selector [#13507](https://github.com/grafana/grafana/issues/13507)
+* **Alerting**: Alert reminders deduping not working as expected when running multiple Grafana instances [#13492](https://github.com/grafana/grafana/issues/13492)
+
 # 5.3.0-beta2 (2018-10-01)
 
 ### New Features

+ 56 - 0
CONTRIBUTING.md

@@ -0,0 +1,56 @@
+
+# Contributing
+
+Grafana uses GitHub to manage contributions.
+Contributions take the form of pull requests that will be reviewed by the core team.
+
+* If you are a new contributor see: [Steps to Contribute](#steps-to-contribute)
+
+* If you have a trivial fix or improvement, go ahead and create a pull request.
+
+* If you plan to do something more involved, discuss your idea on the respective [issue](https://github.com/grafana/grafana/issues) or create a [new issue](https://github.com/grafana/grafana/issues/new) if it does not exist. This will avoid unnecessary work and surely give you and us a good deal of inspiration. 
+
+
+## Steps to Contribute
+
+Should you wish to work on a GitHub issue, check first if it is not already assigned to someone. If it is free, you claim it by commenting on the issue that you want to work on it. This is to prevent duplicated efforts from contributors on the same issue.
+
+Please check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are good for getting started. If you have questions about one of the issues, with or without the tag, please comment on them and one of the core team or the original poster will clarify it.
+
+
+
+## Setup
+
+Follow the setup guide in README.md
+
+### Rebuild frontend assets on source change
+```
+yarn watch
+```
+
+### Rerun tests on source change
+```
+yarn jest
+```
+
+### Run tests for backend assets before commit
+```
+test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)"
+```
+
+### Run tests for frontend assets before commit
+```
+yarn test
+go test -v ./pkg/...
+```
+
+
+## Pull Request Checklist
+
+* Branch from the master branch and, if needed, rebase to the current master branch before submitting your pull request. If it doesn't merge cleanly with master you may be asked to rebase your changes.
+
+* Commits should be as small as possible, while ensuring that each commit is correct independently (i.e., each commit should compile and pass tests).
+
+* If your patch is not getting reviewed or you need a specific person to review it, you can @-reply a reviewer asking for a review in the pull request or a comment.
+
+* Add tests relevant to the fixed bug or new feature.

+ 7 - 1
Gopkg.lock

@@ -19,6 +19,12 @@
   packages = ["."]
   revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520"
 
+[[projects]]
+  branch = "master"
+  name = "github.com/VividCortex/mysqlerr"
+  packages = ["."]
+  revision = "6c6b55f8796f578c870b7e19bafb16103bc40095"
+
 [[projects]]
   name = "github.com/aws/aws-sdk-go"
   packages = [
@@ -673,6 +679,6 @@
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "81a37e747b875cf870c1b9486fa3147e704dea7db8ba86f7cb942d3ddc01d3e3"
+  inputs-digest = "6e9458f912a5f0eb3430b968f1b4dbc4e3b7671b282cf4fe1573419a6d9ba0d4"
   solver-name = "gps-cdcl"
   solver-version = 1

+ 4 - 0
Gopkg.toml

@@ -203,3 +203,7 @@ ignored = [
 [[constraint]]
   name = "github.com/denisenkom/go-mssqldb"
   revision = "270bc3860bb94dd3a3ffd047377d746c5e276726"
+
+[[constraint]]
+  name = "github.com/VividCortex/mysqlerr"
+  branch = "master"

+ 1166 - 0
devenv/dev-dashboards/panel_tests_slow_queries_and_annotations.json

@@ -0,0 +1,1166 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      },
+      {
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": false,
+        "iconColor": "rgba(255, 96, 96, 1)",
+        "limit": 100,
+        "matchAny": false,
+        "name": "annotations",
+        "showIn": 0,
+        "tags": [
+          "asd"
+        ],
+        "type": "tags"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 13,
+        "x": 0,
+        "y": 0
+      },
+      "id": 6,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "30s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 11,
+        "x": 13,
+        "y": 0
+      },
+      "id": 7,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "30s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 7
+      },
+      "id": 8,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "30s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 7
+      },
+      "id": 18,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "30s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 7
+      },
+      "id": 17,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "30s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 5,
+        "w": 8,
+        "x": 0,
+        "y": 14
+      },
+      "id": 10,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 5,
+        "w": 8,
+        "x": 8,
+        "y": 14
+      },
+      "id": 9,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 5,
+        "w": 8,
+        "x": 16,
+        "y": 14
+      },
+      "id": 11,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 5,
+        "w": 8,
+        "x": 0,
+        "y": 19
+      },
+      "id": 14,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 5,
+        "w": 8,
+        "x": 8,
+        "y": 19
+      },
+      "id": 15,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 5,
+        "w": 8,
+        "x": 16,
+        "y": 19
+      },
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 6,
+        "w": 16,
+        "x": 0,
+        "y": 24
+      },
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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": "gdev-testdata",
+      "fill": 1,
+      "gridPos": {
+        "h": 6,
+        "w": 8,
+        "x": 16,
+        "y": 24
+      },
+      "id": 16,
+      "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": "",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A",
+          "scenarioId": "slow_query",
+          "stringInput": "5s"
+        }
+      ],
+      "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-6h",
+    "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": "Panel tests - Slow Queries & Annotations",
+  "uid": "xtY_uCAiz",
+  "version": 11
+}

+ 27 - 6
devenv/docker/ha_test/docker-compose.yaml

@@ -8,18 +8,33 @@ services:
     volumes:
       - /var/run/docker.sock:/tmp/docker.sock:ro
 
-  mysql:
+  db:
     image: mysql
     environment:
       MYSQL_ROOT_PASSWORD: rootpass
       MYSQL_DATABASE: grafana
       MYSQL_USER: grafana
       MYSQL_PASSWORD: password
+    ports:
+      - 3306
     healthcheck:
       test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
       timeout: 10s
       retries: 10
 
+  # db:
+  #   image: postgres:9.3
+  #   environment:
+  #     POSTGRES_DATABASE: grafana
+  #     POSTGRES_USER: grafana
+  #     POSTGRES_PASSWORD: password
+  #   ports:
+  #     - 5432
+  #   healthcheck:
+  #     test: ["CMD-SHELL", "pg_isready -d grafana -U grafana"]
+  #     timeout: 10s
+  #     retries: 10
+
   grafana:
     image: grafana/grafana:dev
     volumes:
@@ -27,17 +42,23 @@ services:
     environment:
       - VIRTUAL_HOST=grafana.loc
       - GF_SERVER_ROOT_URL=http://grafana.loc
-      - GF_DATABASE_TYPE=mysql
-      - GF_DATABASE_HOST=mysql:3306
       - GF_DATABASE_NAME=grafana
       - GF_DATABASE_USER=grafana
       - GF_DATABASE_PASSWORD=password
+      - GF_DATABASE_TYPE=mysql
+      - GF_DATABASE_HOST=db:3306
       - GF_SESSION_PROVIDER=mysql
-      - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(mysql:3306)/grafana?allowNativePasswords=true
+      - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true
+      # - GF_DATABASE_TYPE=postgres
+      # - GF_DATABASE_HOST=db:5432
+      # - GF_DATABASE_SSL_MODE=disable
+      # - GF_SESSION_PROVIDER=postgres
+      # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
+      - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
     ports:
       - 3000
     depends_on:
-      mysql:
+      db:
         condition: service_healthy
 
   prometheus:
@@ -54,4 +75,4 @@ services:
   #   environment:
   #     - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
   #   ports:
-  #     - 9104
+  #     - 9104

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

@@ -127,10 +127,13 @@ Another way is put a webserver like Nginx or Apache in front of Grafana and have
 
 ### protocol
 
-`http` or `https`
+`http`,`https` or `socket`
 
 > **Note** Grafana versions earlier than 3.0 are vulnerable to [POODLE](https://en.wikipedia.org/wiki/POODLE). So we strongly recommend to upgrade to 3.x or use a reverse proxy for ssl termination.
 
+### socket
+Path where the socket should be created when `protocol=socket`. Please make sure that Grafana has appropriate permissions.
+
 ### domain
 
 This setting is only used in as a part of the `root_url` setting (see below). Important if you

+ 1 - 1
package.json

@@ -4,7 +4,7 @@
     "company": "Grafana Labs"
   },
   "name": "grafana",
-  "version": "5.3.0-pre1",
+  "version": "5.4.0-pre1",
   "repository": {
     "type": "git",
     "url": "http://github.com/grafana/grafana.git"

+ 15 - 1
pkg/api/dataproxy.go

@@ -51,7 +51,21 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
 		return
 	}
 
-	proxyPath := c.Params("*")
+	// macaron does not include trailing slashes when resolving a wildcard path
+	proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*"))
+
 	proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath)
 	proxy.HandleRequest()
 }
+
+// ensureProxyPathTrailingSlash Check for a trailing slash in original path and makes
+// sure that a trailing slash is added to proxy path, if not already exists.
+func ensureProxyPathTrailingSlash(originalPath, proxyPath string) string {
+	if len(proxyPath) > 1 {
+		if originalPath[len(originalPath)-1] == '/' && proxyPath[len(proxyPath)-1] != '/' {
+			return proxyPath + "/"
+		}
+	}
+
+	return proxyPath
+}

+ 19 - 0
pkg/api/dataproxy_test.go

@@ -0,0 +1,19 @@
+package api
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDataProxy(t *testing.T) {
+	Convey("Data proxy test", t, func() {
+		Convey("Should append trailing slash to proxy path if original path has a trailing slash", func() {
+			So(ensureProxyPathTrailingSlash("/api/datasources/proxy/6/api/v1/query_range/", "api/v1/query_range/"), ShouldEqual, "api/v1/query_range/")
+		})
+
+		Convey("Should not append trailing slash to proxy path if original path doesn't have a trailing slash", func() {
+			So(ensureProxyPathTrailingSlash("/api/datasources/proxy/6/api/v1/query_range", "api/v1/query_range"), ShouldEqual, "api/v1/query_range")
+		})
+	})
+}

+ 17 - 0
pkg/api/pluginproxy/ds_proxy_test.go

@@ -362,6 +362,23 @@ func TestDSRouteRule(t *testing.T) {
 			})
 		})
 
+		Convey("When proxying a custom datasource", func() {
+			plugin := &plugins.DataSourcePlugin{}
+			ds := &m.DataSource{
+				Type: "custom-datasource",
+				Url:  "http://host/root/",
+			}
+			ctx := &m.ReqContext{}
+			proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/")
+			req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
+			So(err, ShouldBeNil)
+
+			proxy.getDirector()(req)
+
+			Convey("Shoudl keep user request (including trailing slash)", func() {
+				So(req.URL.String(), ShouldEqual, "http://host/root/path/to/folder/")
+			})
+		})
 	})
 }
 

+ 2 - 2
pkg/models/alert.go

@@ -75,7 +75,7 @@ type Alert struct {
 
 	EvalData     *simplejson.Json
 	NewStateDate time.Time
-	StateChanges int
+	StateChanges int64
 
 	Created time.Time
 	Updated time.Time
@@ -156,7 +156,7 @@ type SetAlertStateCommand struct {
 	Error    string
 	EvalData *simplejson.Json
 
-	Timestamp time.Time
+	Result Alert
 }
 
 //Queries

+ 33 - 22
pkg/models/alert_notifications.go

@@ -8,8 +8,18 @@ import (
 )
 
 var (
-	ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
-	ErrJournalingNotFound            = errors.New("alert notification journaling not found")
+	ErrNotificationFrequencyNotFound         = errors.New("Notification frequency not specified")
+	ErrAlertNotificationStateNotFound        = errors.New("alert notification state not found")
+	ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
+	ErrAlertNotificationStateAlreadyExist    = errors.New("alert notification state already exists.")
+)
+
+type AlertNotificationStateType string
+
+var (
+	AlertNotificationStatePending   = AlertNotificationStateType("pending")
+	AlertNotificationStateCompleted = AlertNotificationStateType("completed")
+	AlertNotificationStateUnknown   = AlertNotificationStateType("unknown")
 )
 
 type AlertNotification struct {
@@ -76,33 +86,34 @@ type GetAllAlertNotificationsQuery struct {
 	Result []*AlertNotification
 }
 
-type AlertNotificationJournal struct {
-	Id         int64
-	OrgId      int64
-	AlertId    int64
-	NotifierId int64
-	SentAt     int64
-	Success    bool
+type AlertNotificationState struct {
+	Id                           int64
+	OrgId                        int64
+	AlertId                      int64
+	NotifierId                   int64
+	State                        AlertNotificationStateType
+	Version                      int64
+	UpdatedAt                    int64
+	AlertRuleStateUpdatedVersion int64
 }
 
-type RecordNotificationJournalCommand struct {
-	OrgId      int64
-	AlertId    int64
-	NotifierId int64
-	SentAt     int64
-	Success    bool
-}
+type SetAlertNotificationStateToPendingCommand struct {
+	Id                           int64
+	AlertRuleStateUpdatedVersion int64
+	Version                      int64
 
-type GetLatestNotificationQuery struct {
-	OrgId      int64
-	AlertId    int64
-	NotifierId int64
+	ResultVersion int64
+}
 
-	Result []AlertNotificationJournal
+type SetAlertNotificationStateToCompleteCommand struct {
+	Id      int64
+	Version int64
 }
 
-type CleanNotificationJournalCommand struct {
+type GetOrCreateNotificationStateQuery struct {
 	OrgId      int64
 	AlertId    int64
 	NotifierId int64
+
+	Result *AlertNotificationState
 }

+ 12 - 5
pkg/services/alerting/interfaces.go

@@ -3,6 +3,8 @@ package alerting
 import (
 	"context"
 	"time"
+
+	"github.com/grafana/grafana/pkg/models"
 )
 
 type EvalHandler interface {
@@ -20,7 +22,7 @@ type Notifier interface {
 	NeedsImage() bool
 
 	// ShouldNotify checks this evaluation should send an alert notification
-	ShouldNotify(ctx context.Context, evalContext *EvalContext) bool
+	ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool
 
 	GetNotifierId() int64
 	GetIsDefault() bool
@@ -28,11 +30,16 @@ type Notifier interface {
 	GetFrequency() time.Duration
 }
 
-type NotifierSlice []Notifier
+type notifierState struct {
+	notifier Notifier
+	state    *models.AlertNotificationState
+}
+
+type notifierStateSlice []*notifierState
 
-func (notifiers NotifierSlice) ShouldUploadImage() bool {
-	for _, notifier := range notifiers {
-		if notifier.NeedsImage() {
+func (notifiers notifierStateSlice) ShouldUploadImage() bool {
+	for _, ns := range notifiers {
+		if ns.notifier.NeedsImage() {
 			return true
 		}
 	}

+ 70 - 39
pkg/services/alerting/notifier.go

@@ -1,10 +1,8 @@
 package alerting
 
 import (
-	"context"
 	"errors"
 	"fmt"
-	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/imguploader"
@@ -41,61 +39,78 @@ type notificationService struct {
 }
 
 func (n *notificationService) SendIfNeeded(context *EvalContext) error {
-	notifiers, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context)
+	notifierStates, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context)
 	if err != nil {
 		return err
 	}
 
-	if len(notifiers) == 0 {
+	if len(notifierStates) == 0 {
 		return nil
 	}
 
-	if notifiers.ShouldUploadImage() {
+	if notifierStates.ShouldUploadImage() {
 		if err = n.uploadImage(context); err != nil {
 			n.log.Error("Failed to upload alert panel image.", "error", err)
 		}
 	}
 
-	return n.sendNotifications(context, notifiers)
+	return n.sendNotifications(context, notifierStates)
 }
 
-func (n *notificationService) sendNotifications(evalContext *EvalContext, notifiers []Notifier) error {
-	for _, notifier := range notifiers {
-		not := notifier
+func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error {
+	notifier := notifierState.notifier
 
-		err := bus.InTransaction(evalContext.Ctx, func(ctx context.Context) error {
-			n.log.Debug("trying to send notification", "id", not.GetNotifierId())
+	n.log.Debug("Sending notification", "type", notifier.GetType(), "id", notifier.GetNotifierId(), "isDefault", notifier.GetIsDefault())
+	metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc()
 
-			// Verify that we can send the notification again
-			// but this time within the same transaction.
-			if !evalContext.IsTestRun && !not.ShouldNotify(ctx, evalContext) {
-				return nil
-			}
+	err := notifier.Notify(evalContext)
 
-			n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
-			metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc()
+	if err != nil {
+		n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err)
+	}
 
-			//send notification
-			success := not.Notify(evalContext) == nil
+	if evalContext.IsTestRun {
+		return nil
+	}
 
-			if evalContext.IsTestRun {
-				return nil
-			}
+	cmd := &m.SetAlertNotificationStateToCompleteCommand{
+		Id:      notifierState.state.Id,
+		Version: notifierState.state.Version,
+	}
 
-			//write result to db.
-			cmd := &m.RecordNotificationJournalCommand{
-				OrgId:      evalContext.Rule.OrgId,
-				AlertId:    evalContext.Rule.Id,
-				NotifierId: not.GetNotifierId(),
-				SentAt:     time.Now().Unix(),
-				Success:    success,
-			}
+	return bus.DispatchCtx(evalContext.Ctx, cmd)
+}
 
-			return bus.DispatchCtx(ctx, cmd)
-		})
+func (n *notificationService) sendNotification(evalContext *EvalContext, notifierState *notifierState) error {
+	if !evalContext.IsTestRun {
+		setPendingCmd := &m.SetAlertNotificationStateToPendingCommand{
+			Id:                           notifierState.state.Id,
+			Version:                      notifierState.state.Version,
+			AlertRuleStateUpdatedVersion: evalContext.Rule.StateChanges,
+		}
+
+		err := bus.DispatchCtx(evalContext.Ctx, setPendingCmd)
+		if err == m.ErrAlertNotificationStateVersionConflict {
+			return nil
+		}
 
 		if err != nil {
-			n.log.Error("failed to send notification", "id", not.GetNotifierId())
+			return err
+		}
+
+		// We need to update state version to be able to log
+		// unexpected version conflicts when marking notifications as ok
+		notifierState.state.Version = setPendingCmd.ResultVersion
+	}
+
+	return n.sendAndMarkAsComplete(evalContext, notifierState)
+}
+
+func (n *notificationService) sendNotifications(evalContext *EvalContext, notifierStates notifierStateSlice) error {
+	for _, notifierState := range notifierStates {
+		err := n.sendNotification(evalContext, notifierState)
+		if err != nil {
+			n.log.Error("failed to send notification", "id", notifierState.notifier.GetNotifierId(), "error", err)
 		}
 	}
 
@@ -142,22 +157,38 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
 	return nil
 }
 
-func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (NotifierSlice, error) {
+func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (notifierStateSlice, error) {
 	query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds}
 
 	if err := bus.Dispatch(query); err != nil {
 		return nil, err
 	}
 
-	var result []Notifier
+	var result notifierStateSlice
 	for _, notification := range query.Result {
 		not, err := n.createNotifierFor(notification)
 		if err != nil {
-			return nil, err
+			n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
+			continue
+		}
+
+		query := &m.GetOrCreateNotificationStateQuery{
+			NotifierId: notification.Id,
+			AlertId:    evalContext.Rule.Id,
+			OrgId:      evalContext.Rule.OrgId,
+		}
+
+		err = bus.DispatchCtx(evalContext.Ctx, query)
+		if err != nil {
+			n.log.Error("Could not get notification state.", "notifier", notification.Id, "error", err)
+			continue
 		}
 
-		if not.ShouldNotify(evalContext.Ctx, evalContext) {
-			result = append(result, not)
+		if not.ShouldNotify(evalContext.Ctx, evalContext, query.Result) {
+			result = append(result, &notifierState{
+				notifier: not,
+				state:    query.Result,
+			})
 		}
 	}
 

+ 1 - 1
pkg/services/alerting/notifiers/alertmanager.go

@@ -46,7 +46,7 @@ type AlertmanagerNotifier struct {
 	log log.Logger
 }
 
-func (this *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext) bool {
+func (this *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext, notificationState *m.AlertNotificationState) bool {
 	this.log.Debug("Should notify", "ruleId", evalContext.Rule.Id, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState)
 
 	// Do not notify when we become OK for the first time.

+ 23 - 33
pkg/services/alerting/notifiers/base.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"time"
 
-	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/models"
 
@@ -46,54 +45,45 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
 	}
 }
 
-func defaultShouldNotify(context *alerting.EvalContext, sendReminder bool, frequency time.Duration, journals []models.AlertNotificationJournal) bool {
+// ShouldNotify checks this evaluation should send an alert notification
+func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalContext, notiferState *models.AlertNotificationState) bool {
 	// Only notify on state change.
-	if context.PrevAlertState == context.Rule.State && !sendReminder {
+	if context.PrevAlertState == context.Rule.State && !n.SendReminder {
 		return false
 	}
 
-	// get last successfully sent notification
-	lastNotify := time.Time{}
-	for _, j := range journals {
-		if j.Success {
-			lastNotify = time.Unix(j.SentAt, 0)
-			break
+	if context.PrevAlertState == context.Rule.State && n.SendReminder {
+		// Do not notify if interval has not elapsed
+		lastNotify := time.Unix(notiferState.UpdatedAt, 0)
+		if notiferState.UpdatedAt != 0 && lastNotify.Add(n.Frequency).After(time.Now()) {
+			return false
 		}
-	}
-
-	// Do not notify if interval has not elapsed
-	if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) {
-		return false
-	}
 
-	// Do not notify if alert state if OK or pending even on repeated notify
-	if sendReminder && (context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending) {
-		return false
+		// Do not notify if alert state is OK or pending even on repeated notify
+		if context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending {
+			return false
+		}
 	}
 
 	// Do not notify when we become OK for the first time.
-	if (context.PrevAlertState == models.AlertStatePending) && (context.Rule.State == models.AlertStateOK) {
+	if context.PrevAlertState == models.AlertStatePending && context.Rule.State == models.AlertStateOK {
 		return false
 	}
 
-	return true
-}
-
-// ShouldNotify checks this evaluation should send an alert notification
-func (n *NotifierBase) ShouldNotify(ctx context.Context, c *alerting.EvalContext) bool {
-	cmd := &models.GetLatestNotificationQuery{
-		OrgId:      c.Rule.OrgId,
-		AlertId:    c.Rule.Id,
-		NotifierId: n.Id,
+	// Do not notify when we OK -> Pending
+	if context.PrevAlertState == models.AlertStateOK && context.Rule.State == models.AlertStatePending {
+		return false
 	}
 
-	err := bus.DispatchCtx(ctx, cmd)
-	if err != nil {
-		n.log.Error("Could not determine last time alert notifier fired", "Alert name", c.Rule.Name, "Error", err)
-		return false
+	// Do not notifu if state pending and it have been updated last minute
+	if notiferState.State == models.AlertNotificationStatePending {
+		lastUpdated := time.Unix(notiferState.UpdatedAt, 0)
+		if lastUpdated.Add(1 * time.Minute).After(time.Now()) {
+			return false
+		}
 	}
 
-	return defaultShouldNotify(c, n.SendReminder, n.Frequency, cmd.Result)
+	return true
 }
 
 func (n *NotifierBase) GetType() string {

+ 57 - 60
pkg/services/alerting/notifiers/base_test.go

@@ -2,12 +2,9 @@ package notifiers
 
 import (
 	"context"
-	"errors"
 	"testing"
 	"time"
 
-	"github.com/grafana/grafana/pkg/bus"
-
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/alerting"
@@ -23,34 +20,34 @@ func TestShouldSendAlertNotification(t *testing.T) {
 		newState     m.AlertStateType
 		sendReminder bool
 		frequency    time.Duration
-		journals     []m.AlertNotificationJournal
+		state        *m.AlertNotificationState
 
 		expect bool
 	}{
 		{
 			name:         "pending -> ok should not trigger an notification",
-			newState:     m.AlertStatePending,
-			prevState:    m.AlertStateOK,
+			newState:     m.AlertStateOK,
+			prevState:    m.AlertStatePending,
 			sendReminder: false,
-			journals:     []m.AlertNotificationJournal{},
+			state:        &m.AlertNotificationState{},
 
 			expect: false,
 		},
 		{
 			name:         "ok -> alerting should trigger an notification",
-			newState:     m.AlertStateOK,
-			prevState:    m.AlertStateAlerting,
+			newState:     m.AlertStateAlerting,
+			prevState:    m.AlertStateOK,
 			sendReminder: false,
-			journals:     []m.AlertNotificationJournal{},
+			state:        &m.AlertNotificationState{},
 
 			expect: true,
 		},
 		{
 			name:         "ok -> pending should not trigger an notification",
-			newState:     m.AlertStateOK,
-			prevState:    m.AlertStatePending,
+			newState:     m.AlertStatePending,
+			prevState:    m.AlertStateOK,
 			sendReminder: false,
-			journals:     []m.AlertNotificationJournal{},
+			state:        &m.AlertNotificationState{},
 
 			expect: false,
 		},
@@ -59,100 +56,100 @@ func TestShouldSendAlertNotification(t *testing.T) {
 			newState:     m.AlertStateOK,
 			prevState:    m.AlertStateOK,
 			sendReminder: false,
-			journals:     []m.AlertNotificationJournal{},
+			state:        &m.AlertNotificationState{},
 
 			expect: false,
 		},
 		{
-			name:         "ok -> alerting should trigger an notification",
+			name:         "ok -> ok with reminder should not trigger an notification",
 			newState:     m.AlertStateOK,
-			prevState:    m.AlertStateAlerting,
+			prevState:    m.AlertStateOK,
 			sendReminder: true,
-			journals:     []m.AlertNotificationJournal{},
+			state:        &m.AlertNotificationState{},
+
+			expect: false,
+		},
+		{
+			name:         "alerting -> ok should trigger an notification",
+			newState:     m.AlertStateOK,
+			prevState:    m.AlertStateAlerting,
+			sendReminder: false,
+			state:        &m.AlertNotificationState{},
 
 			expect: true,
 		},
 		{
-			name:         "ok -> ok with reminder should not trigger an notification",
+			name:         "alerting -> ok should trigger an notification when reminders enabled",
 			newState:     m.AlertStateOK,
-			prevState:    m.AlertStateOK,
+			prevState:    m.AlertStateAlerting,
+			frequency:    time.Minute * 10,
 			sendReminder: true,
-			journals:     []m.AlertNotificationJournal{},
+			state:        &m.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
 
-			expect: false,
+			expect: true,
 		},
 		{
-			name:         "alerting -> alerting with reminder and no journaling should trigger",
+			name:         "alerting -> alerting with reminder and no state should trigger",
 			newState:     m.AlertStateAlerting,
 			prevState:    m.AlertStateAlerting,
 			frequency:    time.Minute * 10,
 			sendReminder: true,
-			journals:     []m.AlertNotificationJournal{},
+			state:        &m.AlertNotificationState{},
 
 			expect: true,
 		},
 		{
-			name:         "alerting -> alerting with reminder and successful recent journal event should not trigger",
+			name:         "alerting -> alerting with reminder and last notification sent 1 minute ago should not trigger",
 			newState:     m.AlertStateAlerting,
 			prevState:    m.AlertStateAlerting,
 			frequency:    time.Minute * 10,
 			sendReminder: true,
-			journals: []m.AlertNotificationJournal{
-				{SentAt: tnow.Add(-time.Minute).Unix(), Success: true},
-			},
+			state:        &m.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
 
 			expect: false,
 		},
 		{
-			name:         "alerting -> alerting with reminder and failed recent journal event should trigger",
+			name:         "alerting -> alerting with reminder and last notifciation sent 11 minutes ago should trigger",
 			newState:     m.AlertStateAlerting,
 			prevState:    m.AlertStateAlerting,
 			frequency:    time.Minute * 10,
 			sendReminder: true,
-			expect:       true,
-			journals: []m.AlertNotificationJournal{
-				{SentAt: tnow.Add(-time.Minute).Unix(), Success: false}, // recent failed notification
-				{SentAt: tnow.Add(-time.Hour).Unix(), Success: true},    // old successful notification
-			},
+			state:        &m.AlertNotificationState{UpdatedAt: tnow.Add(-11 * time.Minute).Unix()},
+
+			expect: true,
+		},
+		{
+			name:      "OK -> alerting with notifciation state pending and updated 30 seconds ago should not trigger",
+			newState:  m.AlertStateAlerting,
+			prevState: m.AlertStateOK,
+			state:     &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-30 * time.Second).Unix()},
+
+			expect: false,
+		},
+		{
+			name:      "OK -> alerting with notifciation state pending and updated 2 minutes ago should trigger",
+			newState:  m.AlertStateAlerting,
+			prevState: m.AlertStateOK,
+			state:     &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()},
+
+			expect: true,
 		},
 	}
 
 	for _, tc := range tcs {
 		evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
-			State: tc.newState,
+			State: tc.prevState,
 		})
 
-		evalContext.Rule.State = tc.prevState
-		if defaultShouldNotify(evalContext, true, tc.frequency, tc.journals) != tc.expect {
+		evalContext.Rule.State = tc.newState
+		nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency}
+
+		if nb.ShouldNotify(evalContext.Ctx, evalContext, tc.state) != tc.expect {
 			t.Errorf("failed test %s.\n expected \n%+v \nto return: %v", tc.name, tc, tc.expect)
 		}
 	}
 }
 
-func TestShouldNotifyWhenNoJournalingIsFound(t *testing.T) {
-	Convey("base notifier", t, func() {
-		bus.ClearBusHandlers()
-
-		notifier := NewNotifierBase(&m.AlertNotification{
-			Id:       1,
-			Name:     "name",
-			Type:     "email",
-			Settings: simplejson.New(),
-		})
-		evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{})
-
-		Convey("should not notify query returns error", func() {
-			bus.AddHandlerCtx("", func(ctx context.Context, q *m.GetLatestNotificationQuery) error {
-				return errors.New("some kind of error unknown error")
-			})
-
-			if notifier.ShouldNotify(context.Background(), evalContext) {
-				t.Errorf("should not send notifications when query returns error")
-			}
-		})
-	})
-}
-
 func TestBaseNotifier(t *testing.T) {
 	Convey("default constructor for notifiers", t, func() {
 		bJson := simplejson.New()

+ 25 - 20
pkg/services/alerting/notifiers/telegram_test.go

@@ -1,6 +1,7 @@
 package notifiers
 
 import (
+	"context"
 	"testing"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -52,11 +53,12 @@ func TestTelegramNotifier(t *testing.T) {
 			})
 
 			Convey("generateCaption should generate a message with all pertinent details", func() {
-				evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
-					Name:    "This is an alarm",
-					Message: "Some kind of message.",
-					State:   m.AlertStateOK,
-				})
+				evalContext := alerting.NewEvalContext(context.Background(),
+					&alerting.Rule{
+						Name:    "This is an alarm",
+						Message: "Some kind of message.",
+						State:   m.AlertStateOK,
+					})
 
 				caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "")
 				So(len(caption), ShouldBeLessThanOrEqualTo, 200)
@@ -68,11 +70,12 @@ func TestTelegramNotifier(t *testing.T) {
 			Convey("When generating a message", func() {
 
 				Convey("URL should be skipped if it's too long", func() {
-					evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
-						Name:    "This is an alarm",
-						Message: "Some kind of message.",
-						State:   m.AlertStateOK,
-					})
+					evalContext := alerting.NewEvalContext(context.Background(),
+						&alerting.Rule{
+							Name:    "This is an alarm",
+							Message: "Some kind of message.",
+							State:   m.AlertStateOK,
+						})
 
 					caption := generateImageCaption(evalContext,
 						"http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
@@ -85,11 +88,12 @@ func TestTelegramNotifier(t *testing.T) {
 				})
 
 				Convey("Message should be trimmed if it's too long", func() {
-					evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
-						Name:    "This is an alarm",
-						Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.",
-						State:   m.AlertStateOK,
-					})
+					evalContext := alerting.NewEvalContext(context.Background(),
+						&alerting.Rule{
+							Name:    "This is an alarm",
+							Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.",
+							State:   m.AlertStateOK,
+						})
 
 					caption := generateImageCaption(evalContext,
 						"http://grafa.url/foo",
@@ -101,11 +105,12 @@ func TestTelegramNotifier(t *testing.T) {
 				})
 
 				Convey("Metrics should be skipped if they don't fit", func() {
-					evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
-						Name:    "This is an alarm",
-						Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ",
-						State:   m.AlertStateOK,
-					})
+					evalContext := alerting.NewEvalContext(context.Background(),
+						&alerting.Rule{
+							Name:    "This is an alarm",
+							Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ",
+							State:   m.AlertStateOK,
+						})
 
 					caption := generateImageCaption(evalContext,
 						"http://grafa.url/foo",

+ 6 - 13
pkg/services/alerting/result_handler.go

@@ -67,6 +67,12 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
 			}
 
 			handler.log.Error("Failed to save state", "error", err)
+		} else {
+
+			// StateChanges is used for de duping alert notifications
+			// when two servers are raising. This makes sure that the server
+			// with the last state change always sends a notification.
+			evalContext.Rule.StateChanges = cmd.Result.StateChanges
 		}
 
 		// save annotation
@@ -88,19 +94,6 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
 		}
 	}
 
-	if evalContext.Rule.State == m.AlertStateOK && evalContext.PrevAlertState != m.AlertStateOK {
-		for _, notifierId := range evalContext.Rule.Notifications {
-			cmd := &m.CleanNotificationJournalCommand{
-				AlertId:    evalContext.Rule.Id,
-				NotifierId: notifierId,
-				OrgId:      evalContext.Rule.OrgId,
-			}
-			if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
-				handler.log.Error("Failed to clean up old notification records", "notifier", notifierId, "alert", evalContext.Rule.Id, "Error", err)
-			}
-		}
-	}
-
 	handler.notifier.SendIfNeeded(evalContext)
 	return nil
 }

+ 3 - 0
pkg/services/alerting/rule.go

@@ -23,6 +23,8 @@ type Rule struct {
 	State               m.AlertStateType
 	Conditions          []Condition
 	Notifications       []int64
+
+	StateChanges int64
 }
 
 type ValidationError struct {
@@ -100,6 +102,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
 	model.State = ruleDef.State
 	model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data"))
 	model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting"))
+	model.StateChanges = ruleDef.StateChanges
 
 	for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
 		jsonModel := simplejson.NewFromAny(v)

+ 1 - 1
pkg/services/alerting/test_notification.go

@@ -39,7 +39,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
 		return err
 	}
 
-	return notifier.sendNotifications(createTestEvalContext(cmd), []Notifier{notifiers})
+	return notifier.sendNotifications(createTestEvalContext(cmd), notifierStateSlice{{notifier: notifiers}})
 }
 
 func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext {

+ 1 - 1
pkg/services/provisioning/dashboards/file_reader.go

@@ -137,7 +137,7 @@ func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs ma
 		cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId}
 		err := bus.Dispatch(cmd)
 		if err != nil {
-			fr.log.Error("failed to delete dashboard", "id", cmd.Id)
+			fr.log.Error("failed to delete dashboard", "id", cmd.Id, "error", err)
 		}
 	}
 }

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

@@ -60,6 +60,10 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro
 		return err
 	}
 
+	if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -275,6 +279,8 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error {
 		}
 
 		sess.ID(alert.Id).Update(&alert)
+
+		cmd.Result = alert
 		return nil
 	})
 }

+ 115 - 28
pkg/services/sqlstore/alert_notification.go

@@ -3,6 +3,7 @@ package sqlstore
 import (
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"strings"
 	"time"
@@ -18,16 +19,23 @@ func init() {
 	bus.AddHandler("sql", DeleteAlertNotification)
 	bus.AddHandler("sql", GetAlertNotificationsToSend)
 	bus.AddHandler("sql", GetAllAlertNotifications)
-	bus.AddHandlerCtx("sql", RecordNotificationJournal)
-	bus.AddHandlerCtx("sql", GetLatestNotification)
-	bus.AddHandlerCtx("sql", CleanNotificationJournal)
+	bus.AddHandlerCtx("sql", GetOrCreateAlertNotificationState)
+	bus.AddHandlerCtx("sql", SetAlertNotificationStateToCompleteCommand)
+	bus.AddHandlerCtx("sql", SetAlertNotificationStateToPendingCommand)
 }
 
 func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 		sql := "DELETE FROM alert_notification WHERE alert_notification.org_id = ? AND alert_notification.id = ?"
-		_, err := sess.Exec(sql, cmd.OrgId, cmd.Id)
-		return err
+		if _, err := sess.Exec(sql, cmd.OrgId, cmd.Id); err != nil {
+			return err
+		}
+
+		if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_notification_state.org_id = ? AND alert_notification_state.notifier_id = ?", cmd.OrgId, cmd.Id); err != nil {
+			return err
+		}
+
+		return nil
 	})
 }
 
@@ -229,44 +237,123 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
 	})
 }
 
-func RecordNotificationJournal(ctx context.Context, cmd *m.RecordNotificationJournalCommand) error {
-	return withDbSession(ctx, func(sess *DBSession) error {
-		journalEntry := &m.AlertNotificationJournal{
-			OrgId:      cmd.OrgId,
-			AlertId:    cmd.AlertId,
-			NotifierId: cmd.NotifierId,
-			SentAt:     cmd.SentAt,
-			Success:    cmd.Success,
+func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error {
+	return inTransactionCtx(ctx, func(sess *DBSession) error {
+		version := cmd.Version
+		var current m.AlertNotificationState
+		sess.ID(cmd.Id).Get(&current)
+
+		newVersion := cmd.Version + 1
+
+		sql := `UPDATE alert_notification_state SET
+			state = ?,
+			version = ?,
+			updated_at = ?
+		WHERE
+			id = ?`
+
+		_, err := sess.Exec(sql, m.AlertNotificationStateCompleted, newVersion, timeNow().Unix(), cmd.Id)
+
+		if err != nil {
+			return err
 		}
 
-		_, err := sess.Insert(journalEntry)
-		return err
+		if current.Version != version {
+			sqlog.Error("notification state out of sync. the notification is marked as complete but has been modified between set as pending and completion.", "notifierId", current.NotifierId)
+		}
+
+		return nil
 	})
 }
 
-func GetLatestNotification(ctx context.Context, cmd *m.GetLatestNotificationQuery) error {
+func SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToPendingCommand) error {
 	return withDbSession(ctx, func(sess *DBSession) error {
-		nj := []m.AlertNotificationJournal{}
-
-		err := sess.Desc("alert_notification_journal.sent_at").
-			Where("alert_notification_journal.org_id = ?", cmd.OrgId).
-			Where("alert_notification_journal.alert_id = ?", cmd.AlertId).
-			Where("alert_notification_journal.notifier_id = ?", cmd.NotifierId).
-			Find(&nj)
+		newVersion := cmd.Version + 1
+		sql := `UPDATE alert_notification_state SET
+			state = ?,
+			version = ?,
+			updated_at = ?,
+			alert_rule_state_updated_version = ?
+		WHERE
+			id = ? AND
+			(version = ? OR alert_rule_state_updated_version < ?)`
+
+		res, err := sess.Exec(sql,
+			m.AlertNotificationStatePending,
+			newVersion,
+			timeNow().Unix(),
+			cmd.AlertRuleStateUpdatedVersion,
+			cmd.Id,
+			cmd.Version,
+			cmd.AlertRuleStateUpdatedVersion)
 
 		if err != nil {
 			return err
 		}
 
-		cmd.Result = nj
+		affected, _ := res.RowsAffected()
+		if affected == 0 {
+			return m.ErrAlertNotificationStateVersionConflict
+		}
+
+		cmd.ResultVersion = newVersion
+
 		return nil
 	})
 }
 
-func CleanNotificationJournal(ctx context.Context, cmd *m.CleanNotificationJournalCommand) error {
+func GetOrCreateAlertNotificationState(ctx context.Context, cmd *m.GetOrCreateNotificationStateQuery) error {
 	return inTransactionCtx(ctx, func(sess *DBSession) error {
-		sql := "DELETE FROM alert_notification_journal WHERE alert_notification_journal.org_id = ? AND alert_notification_journal.alert_id = ? AND alert_notification_journal.notifier_id = ?"
-		_, err := sess.Exec(sql, cmd.OrgId, cmd.AlertId, cmd.NotifierId)
-		return err
+		nj := &m.AlertNotificationState{}
+
+		exist, err := getAlertNotificationState(sess, cmd, nj)
+
+		// if exists, return it, otherwise create it with default values
+		if err != nil {
+			return err
+		}
+
+		if exist {
+			cmd.Result = nj
+			return nil
+		}
+
+		notificationState := &m.AlertNotificationState{
+			OrgId:      cmd.OrgId,
+			AlertId:    cmd.AlertId,
+			NotifierId: cmd.NotifierId,
+			State:      m.AlertNotificationStateUnknown,
+			UpdatedAt:  timeNow().Unix(),
+		}
+
+		if _, err := sess.Insert(notificationState); err != nil {
+			if dialect.IsUniqueConstraintViolation(err) {
+				exist, err = getAlertNotificationState(sess, cmd, nj)
+
+				if err != nil {
+					return err
+				}
+
+				if !exist {
+					return errors.New("Should not happen")
+				}
+
+				cmd.Result = nj
+				return nil
+			}
+
+			return err
+		}
+
+		cmd.Result = notificationState
+		return nil
 	})
 }
+
+func getAlertNotificationState(sess *DBSession, cmd *m.GetOrCreateNotificationStateQuery, nj *m.AlertNotificationState) (bool, error) {
+	return sess.
+		Where("alert_notification_state.org_id = ?", cmd.OrgId).
+		Where("alert_notification_state.alert_id = ?", cmd.AlertId).
+		Where("alert_notification_state.notifier_id = ?", cmd.NotifierId).
+		Get(nj)
+}

+ 128 - 53
pkg/services/sqlstore/alert_notification_test.go

@@ -6,7 +6,7 @@ import (
 	"time"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
-	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/models"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
@@ -14,58 +14,133 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 	Convey("Testing Alert notification sql access", t, func() {
 		InitTestDB(t)
 
-		Convey("Alert notification journal", func() {
-			var alertId int64 = 7
-			var orgId int64 = 5
-			var notifierId int64 = 10
+		Convey("Alert notification state", func() {
+			var alertID int64 = 7
+			var orgID int64 = 5
+			var notifierID int64 = 10
+			oldTimeNow := timeNow
+			now := time.Date(2018, 9, 30, 0, 0, 0, 0, time.UTC)
+			timeNow = func() time.Time { return now }
+
+			Convey("Get no existing state should create a new state", func() {
+				query := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+				err := GetOrCreateAlertNotificationState(context.Background(), query)
+				So(err, ShouldBeNil)
+				So(query.Result, ShouldNotBeNil)
+				So(query.Result.State, ShouldEqual, "unknown")
+				So(query.Result.Version, ShouldEqual, 0)
+				So(query.Result.UpdatedAt, ShouldEqual, now.Unix())
+
+				Convey("Get existing state should not create a new state", func() {
+					query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+					err := GetOrCreateAlertNotificationState(context.Background(), query2)
+					So(err, ShouldBeNil)
+					So(query2.Result, ShouldNotBeNil)
+					So(query2.Result.Id, ShouldEqual, query.Result.Id)
+					So(query2.Result.UpdatedAt, ShouldEqual, now.Unix())
+				})
 
-			Convey("Getting last journal should raise error if no one exists", func() {
-				query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
-				GetLatestNotification(context.Background(), query)
-				So(len(query.Result), ShouldEqual, 0)
+				Convey("Update existing state to pending with correct version should update database", func() {
+					s := *query.Result
 
-				// recording an journal entry in another org to make sure org filter works as expected.
-				journalInOtherOrg := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: 10, Success: true, SentAt: 1}
-				err := RecordNotificationJournal(context.Background(), journalInOtherOrg)
-				So(err, ShouldBeNil)
+					cmd := models.SetAlertNotificationStateToPendingCommand{
+						Id:                           s.Id,
+						Version:                      s.Version,
+						AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
+					}
 
-				Convey("should be able to record two journaling events", func() {
-					createCmd := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId, Success: true, SentAt: 1}
+					err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
+					So(err, ShouldBeNil)
+					So(cmd.ResultVersion, ShouldEqual, 1)
 
-					err := RecordNotificationJournal(context.Background(), createCmd)
+					query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+					err = GetOrCreateAlertNotificationState(context.Background(), query2)
 					So(err, ShouldBeNil)
+					So(query2.Result.Version, ShouldEqual, 1)
+					So(query2.Result.State, ShouldEqual, models.AlertNotificationStatePending)
+					So(query2.Result.UpdatedAt, ShouldEqual, now.Unix())
+
+					Convey("Update existing state to completed should update database", func() {
+						s := *query.Result
+						setStateCmd := models.SetAlertNotificationStateToCompleteCommand{
+							Id:      s.Id,
+							Version: cmd.ResultVersion,
+						}
+						err := SetAlertNotificationStateToCompleteCommand(context.Background(), &setStateCmd)
+						So(err, ShouldBeNil)
 
-					createCmd.SentAt += 1000 //increase epoch
+						query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+						err = GetOrCreateAlertNotificationState(context.Background(), query3)
+						So(err, ShouldBeNil)
+						So(query3.Result.Version, ShouldEqual, 2)
+						So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
+						So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
+					})
 
-					err = RecordNotificationJournal(context.Background(), createCmd)
-					So(err, ShouldBeNil)
+					Convey("Update existing state to completed should update database. regardless of version", func() {
+						s := *query.Result
+						unknownVersion := int64(1000)
+						cmd := models.SetAlertNotificationStateToCompleteCommand{
+							Id:      s.Id,
+							Version: unknownVersion,
+						}
+						err := SetAlertNotificationStateToCompleteCommand(context.Background(), &cmd)
+						So(err, ShouldBeNil)
 
-					Convey("get last journaling event", func() {
-						err := GetLatestNotification(context.Background(), query)
+						query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+						err = GetOrCreateAlertNotificationState(context.Background(), query3)
 						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						last := query.Result[0]
-						So(last.SentAt, ShouldEqual, 1001)
-
-						Convey("be able to clear all journaling for an notifier", func() {
-							cmd := &m.CleanNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId}
-							err := CleanNotificationJournal(context.Background(), cmd)
-							So(err, ShouldBeNil)
-
-							Convey("querying for last journaling should return no journal entries", func() {
-								query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
-								err := GetLatestNotification(context.Background(), query)
-								So(err, ShouldBeNil)
-								So(len(query.Result), ShouldEqual, 0)
-							})
-						})
+						So(query3.Result.Version, ShouldEqual, unknownVersion+1)
+						So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
+						So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
 					})
 				})
+
+				Convey("Update existing state to pending with incorrect version should return version mismatch error", func() {
+					s := *query.Result
+					s.Version = 1000
+					cmd := models.SetAlertNotificationStateToPendingCommand{
+						Id:                           s.NotifierId,
+						Version:                      s.Version,
+						AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
+					}
+					err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
+					So(err, ShouldEqual, models.ErrAlertNotificationStateVersionConflict)
+				})
+
+				Convey("Updating existing state to pending with incorrect version since alert rule state update version is higher", func() {
+					s := *query.Result
+					cmd := models.SetAlertNotificationStateToPendingCommand{
+						Id:                           s.Id,
+						Version:                      s.Version,
+						AlertRuleStateUpdatedVersion: 1000,
+					}
+					err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
+					So(err, ShouldBeNil)
+
+					So(cmd.ResultVersion, ShouldEqual, 1)
+				})
+
+				Convey("different version and same alert state change version should return error", func() {
+					s := *query.Result
+					s.Version = 1000
+					cmd := models.SetAlertNotificationStateToPendingCommand{
+						Id:                           s.Id,
+						Version:                      s.Version,
+						AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
+					}
+					err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
+					So(err, ShouldNotBeNil)
+				})
+			})
+
+			Reset(func() {
+				timeNow = oldTimeNow
 			})
 		})
 
 		Convey("Alert notifications should be empty", func() {
-			cmd := &m.GetAlertNotificationsQuery{
+			cmd := &models.GetAlertNotificationsQuery{
 				OrgId: 2,
 				Name:  "email",
 			}
@@ -76,7 +151,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 		})
 
 		Convey("Cannot save alert notifier with send reminder = true", func() {
-			cmd := &m.CreateAlertNotificationCommand{
+			cmd := &models.CreateAlertNotificationCommand{
 				Name:         "ops",
 				Type:         "email",
 				OrgId:        1,
@@ -86,7 +161,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 
 			Convey("and missing frequency", func() {
 				err := CreateAlertNotificationCommand(cmd)
-				So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
+				So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
 			})
 
 			Convey("invalid frequency", func() {
@@ -98,7 +173,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 		})
 
 		Convey("Cannot update alert notifier with send reminder = false", func() {
-			cmd := &m.CreateAlertNotificationCommand{
+			cmd := &models.CreateAlertNotificationCommand{
 				Name:         "ops update",
 				Type:         "email",
 				OrgId:        1,
@@ -109,14 +184,14 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 			err := CreateAlertNotificationCommand(cmd)
 			So(err, ShouldBeNil)
 
-			updateCmd := &m.UpdateAlertNotificationCommand{
+			updateCmd := &models.UpdateAlertNotificationCommand{
 				Id:           cmd.Result.Id,
 				SendReminder: true,
 			}
 
 			Convey("and missing frequency", func() {
 				err := UpdateAlertNotification(updateCmd)
-				So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
+				So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
 			})
 
 			Convey("invalid frequency", func() {
@@ -129,7 +204,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 		})
 
 		Convey("Can save Alert Notification", func() {
-			cmd := &m.CreateAlertNotificationCommand{
+			cmd := &models.CreateAlertNotificationCommand{
 				Name:         "ops",
 				Type:         "email",
 				OrgId:        1,
@@ -151,7 +226,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 			})
 
 			Convey("Can update alert notification", func() {
-				newCmd := &m.UpdateAlertNotificationCommand{
+				newCmd := &models.UpdateAlertNotificationCommand{
 					Name:         "NewName",
 					Type:         "webhook",
 					OrgId:        cmd.Result.OrgId,
@@ -167,7 +242,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 			})
 
 			Convey("Can update alert notification to disable sending of reminders", func() {
-				newCmd := &m.UpdateAlertNotificationCommand{
+				newCmd := &models.UpdateAlertNotificationCommand{
 					Name:         "NewName",
 					Type:         "webhook",
 					OrgId:        cmd.Result.OrgId,
@@ -182,12 +257,12 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 		})
 
 		Convey("Can search using an array of ids", func() {
-			cmd1 := m.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
-			cmd2 := m.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
-			cmd3 := m.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
-			cmd4 := m.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+			cmd1 := models.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+			cmd2 := models.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+			cmd3 := models.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+			cmd4 := models.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
 
-			otherOrg := m.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+			otherOrg := models.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
 
 			So(CreateAlertNotificationCommand(&cmd1), ShouldBeNil)
 			So(CreateAlertNotificationCommand(&cmd2), ShouldBeNil)
@@ -196,7 +271,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 			So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil)
 
 			Convey("search", func() {
-				query := &m.GetAlertNotificationsToSendQuery{
+				query := &models.GetAlertNotificationsToSendQuery{
 					Ids:   []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231},
 					OrgId: 1,
 				}
@@ -207,7 +282,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 			})
 
 			Convey("all", func() {
-				query := &m.GetAllAlertNotificationsQuery{
+				query := &models.GetAllAlertNotificationsQuery{
 					OrgId: 1,
 				}
 

+ 23 - 0
pkg/services/sqlstore/migrations/alert_mig.go

@@ -107,4 +107,27 @@ func addAlertMigrations(mg *Migrator) {
 
 	mg.AddMigration("create notification_journal table v1", NewAddTableMigration(notification_journal))
 	mg.AddMigration("add index notification_journal org_id & alert_id & notifier_id", NewAddIndexMigration(notification_journal, notification_journal.Indices[0]))
+
+	mg.AddMigration("drop alert_notification_journal", NewDropTableMigration("alert_notification_journal"))
+
+	alert_notification_state := Table{
+		Name: "alert_notification_state",
+		Columns: []*Column{
+			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+			{Name: "org_id", Type: DB_BigInt, Nullable: false},
+			{Name: "alert_id", Type: DB_BigInt, Nullable: false},
+			{Name: "notifier_id", Type: DB_BigInt, Nullable: false},
+			{Name: "state", Type: DB_NVarchar, Length: 50, Nullable: false},
+			{Name: "version", Type: DB_BigInt, Nullable: false},
+			{Name: "updated_at", Type: DB_BigInt, Nullable: false},
+			{Name: "alert_rule_state_updated_version", Type: DB_BigInt, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"org_id", "alert_id", "notifier_id"}, Type: UniqueIndex},
+		},
+	}
+
+	mg.AddMigration("create alert_notification_state table v1", NewAddTableMigration(alert_notification_state))
+	mg.AddMigration("add index alert_notification_state org_id & alert_id & notifier_id",
+		NewAddIndexMigration(alert_notification_state, alert_notification_state.Indices[0]))
 }

+ 2 - 0
pkg/services/sqlstore/migrator/dialect.go

@@ -44,6 +44,8 @@ type Dialect interface {
 
 	CleanDB() error
 	NoOpSql() string
+
+	IsUniqueConstraintViolation(err error) bool
 }
 
 func NewDialect(engine *xorm.Engine) Dialect {

+ 12 - 0
pkg/services/sqlstore/migrator/mysql_dialect.go

@@ -5,6 +5,8 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/VividCortex/mysqlerr"
+	"github.com/go-sql-driver/mysql"
 	"github.com/go-xorm/xorm"
 )
 
@@ -125,3 +127,13 @@ func (db *Mysql) CleanDB() error {
 
 	return nil
 }
+
+func (db *Mysql) IsUniqueConstraintViolation(err error) bool {
+	if driverErr, ok := err.(*mysql.MySQLError); ok {
+		if driverErr.Number == mysqlerr.ER_DUP_ENTRY {
+			return true
+		}
+	}
+
+	return false
+}

+ 11 - 0
pkg/services/sqlstore/migrator/postgres_dialect.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 
 	"github.com/go-xorm/xorm"
+	"github.com/lib/pq"
 )
 
 type Postgres struct {
@@ -136,3 +137,13 @@ func (db *Postgres) CleanDB() error {
 
 	return nil
 }
+
+func (db *Postgres) IsUniqueConstraintViolation(err error) bool {
+	if driverErr, ok := err.(*pq.Error); ok {
+		if driverErr.Code == "23505" {
+			return true
+		}
+	}
+
+	return false
+}

+ 11 - 0
pkg/services/sqlstore/migrator/sqlite_dialect.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/go-xorm/xorm"
+	sqlite3 "github.com/mattn/go-sqlite3"
 )
 
 type Sqlite3 struct {
@@ -82,3 +83,13 @@ func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {
 func (db *Sqlite3) CleanDB() error {
 	return nil
 }
+
+func (db *Sqlite3) IsUniqueConstraintViolation(err error) bool {
+	if driverErr, ok := err.(sqlite3.Error); ok {
+		if driverErr.ExtendedCode == sqlite3.ErrConstraintUnique {
+			return true
+		}
+	}
+
+	return false
+}

+ 6 - 6
pkg/tsdb/elasticsearch/client/client_test.go

@@ -25,7 +25,7 @@ func TestClient(t *testing.T) {
 					JsonData: simplejson.NewFromAny(make(map[string]interface{})),
 				}
 
-				_, err := NewClient(nil, ds, nil)
+				_, err := NewClient(context.Background(), ds, nil)
 				So(err, ShouldNotBeNil)
 			})
 
@@ -36,7 +36,7 @@ func TestClient(t *testing.T) {
 					}),
 				}
 
-				_, err := NewClient(nil, ds, nil)
+				_, err := NewClient(context.Background(), ds, nil)
 				So(err, ShouldNotBeNil)
 			})
 
@@ -48,7 +48,7 @@ func TestClient(t *testing.T) {
 					}),
 				}
 
-				_, err := NewClient(nil, ds, nil)
+				_, err := NewClient(context.Background(), ds, nil)
 				So(err, ShouldNotBeNil)
 			})
 
@@ -60,7 +60,7 @@ func TestClient(t *testing.T) {
 					}),
 				}
 
-				c, err := NewClient(nil, ds, nil)
+				c, err := NewClient(context.Background(), ds, nil)
 				So(err, ShouldBeNil)
 				So(c.GetVersion(), ShouldEqual, 2)
 			})
@@ -73,7 +73,7 @@ func TestClient(t *testing.T) {
 					}),
 				}
 
-				c, err := NewClient(nil, ds, nil)
+				c, err := NewClient(context.Background(), ds, nil)
 				So(err, ShouldBeNil)
 				So(c.GetVersion(), ShouldEqual, 5)
 			})
@@ -86,7 +86,7 @@ func TestClient(t *testing.T) {
 					}),
 				}
 
-				c, err := NewClient(nil, ds, nil)
+				c, err := NewClient(context.Background(), ds, nil)
 				So(err, ShouldBeNil)
 				So(c.GetVersion(), ShouldEqual, 56)
 			})

+ 0 - 8
pkg/tsdb/mssql/macros.go

@@ -66,10 +66,6 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 		}
 
 		return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
-	case "__timeFrom":
-		return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
-	case "__timeTo":
-		return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval", name)
@@ -96,10 +92,6 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 			return "", fmt.Errorf("missing time column argument for macro %v", name)
 		}
 		return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
-	case "__unixEpochFrom":
-		return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
-	case "__unixEpochTo":
-		return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
 	case "__unixEpochGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)

+ 0 - 84
pkg/tsdb/mssql/macros_test.go

@@ -111,20 +111,6 @@ func TestMacroEngine(t *testing.T) {
 				So(fillInterval, ShouldEqual, 5*time.Minute.Seconds())
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
 				So(err, ShouldBeNil)
@@ -132,20 +118,6 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
 			})
 
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
-
 			Convey("interpolate __unixEpochGroup function", func() {
 
 				sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@@ -171,40 +143,12 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
 				So(err, ShouldBeNil)
 
 				So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
 			})
-
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
 		})
 
 		Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -219,40 +163,12 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
 				So(err, ShouldBeNil)
 
 				So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
 			})
-
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
 		})
 	})
 }

+ 55 - 26
pkg/tsdb/mssql/mssql_test.go

@@ -1,6 +1,7 @@
 package mssql
 
 import (
+	"context"
 	"fmt"
 	"math/rand"
 	"strings"
@@ -128,7 +129,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				queryResult := resp.Results["A"]
 				So(err, ShouldBeNil)
 
@@ -218,7 +219,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -265,7 +266,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -327,7 +328,7 @@ func TestMSSQL(t *testing.T) {
 						},
 					}
 
-					resp, err := endpoint.Query(nil, nil, query)
+					resp, err := endpoint.Query(context.Background(), nil, query)
 					So(err, ShouldBeNil)
 					queryResult := resp.Results["A"]
 					So(queryResult.Error, ShouldBeNil)
@@ -352,7 +353,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -441,7 +442,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -463,7 +464,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -485,7 +486,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -507,7 +508,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -529,7 +530,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -551,7 +552,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -573,7 +574,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -595,7 +596,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -617,7 +618,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -640,7 +641,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -663,7 +664,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -675,6 +676,30 @@ func TestMSSQL(t *testing.T) {
 				So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo")
 			})
 
+			Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
+				tsdb.Interpolate = origInterpolate
+				query := &tsdb.TsdbQuery{
+					TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
+					Queries: []*tsdb.Query{
+						{
+							DataSource: &models.DataSource{JsonData: simplejson.New()},
+							Model: simplejson.NewFromAny(map[string]interface{}{
+								"rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
+								"format": "time_series",
+							}),
+							RefId: "A",
+						},
+					},
+				}
+
+				resp, err := endpoint.Query(nil, nil, query)
+				So(err, ShouldBeNil)
+				queryResult := resp.Results["A"]
+				So(queryResult.Error, ShouldBeNil)
+				So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
+
+			})
+
 			Convey("Given a stored procedure that takes @from and @to in epoch time", func() {
 				sql := `
 						IF object_id('sp_test_epoch') IS NOT NULL
@@ -719,9 +744,11 @@ func TestMSSQL(t *testing.T) {
 				So(err, ShouldBeNil)
 
 				Convey("When doing a metric query using stored procedure should return correct result", func() {
+					tsdb.Interpolate = origInterpolate
 					query := &tsdb.TsdbQuery{
 						Queries: []*tsdb.Query{
 							{
+								DataSource: &models.DataSource{JsonData: simplejson.New()},
 								Model: simplejson.NewFromAny(map[string]interface{}{
 									"rawSql": `DECLARE
 											@from int = $__unixEpochFrom(),
@@ -739,7 +766,7 @@ func TestMSSQL(t *testing.T) {
 						},
 					}
 
-					resp, err := endpoint.Query(nil, nil, query)
+					resp, err := endpoint.Query(context.Background(), nil, query)
 					queryResult := resp.Results["A"]
 					So(err, ShouldBeNil)
 					So(queryResult.Error, ShouldBeNil)
@@ -796,9 +823,11 @@ func TestMSSQL(t *testing.T) {
 				So(err, ShouldBeNil)
 
 				Convey("When doing a metric query using stored procedure should return correct result", func() {
+					tsdb.Interpolate = origInterpolate
 					query := &tsdb.TsdbQuery{
 						Queries: []*tsdb.Query{
 							{
+								DataSource: &models.DataSource{JsonData: simplejson.New()},
 								Model: simplejson.NewFromAny(map[string]interface{}{
 									"rawSql": `DECLARE
 											@from int = $__unixEpochFrom(),
@@ -816,7 +845,7 @@ func TestMSSQL(t *testing.T) {
 						},
 					}
 
-					resp, err := endpoint.Query(nil, nil, query)
+					resp, err := endpoint.Query(context.Background(), nil, query)
 					queryResult := resp.Results["A"]
 					So(err, ShouldBeNil)
 					So(queryResult.Error, ShouldBeNil)
@@ -892,7 +921,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				queryResult := resp.Results["Deploys"]
 				So(err, ShouldBeNil)
 				So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -915,7 +944,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				queryResult := resp.Results["Tickets"]
 				So(err, ShouldBeNil)
 				So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -941,7 +970,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -971,7 +1000,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -1001,7 +1030,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -1031,7 +1060,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -1059,7 +1088,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -1087,7 +1116,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)

+ 0 - 8
pkg/tsdb/mysql/macros.go

@@ -61,10 +61,6 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 		}
 
 		return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
-	case "__timeFrom":
-		return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
-	case "__timeTo":
-		return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval", name)
@@ -91,10 +87,6 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 			return "", fmt.Errorf("missing time column argument for macro %v", name)
 		}
 		return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
-	case "__unixEpochFrom":
-		return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
-	case "__unixEpochTo":
-		return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
 	case "__unixEpochGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)

+ 0 - 84
pkg/tsdb/mysql/macros_test.go

@@ -63,20 +63,6 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
 				So(err, ShouldBeNil)
@@ -84,20 +70,6 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 			})
 
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
-
 			Convey("interpolate __unixEpochGroup function", func() {
 
 				sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@@ -123,40 +95,12 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
 				So(err, ShouldBeNil)
 
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 			})
-
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
 		})
 
 		Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -171,40 +115,12 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
 				So(err, ShouldBeNil)
 
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 			})
-
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
 		})
 	})
 }

+ 53 - 28
pkg/tsdb/mysql/mysql_test.go

@@ -1,6 +1,7 @@
 package mysql
 
 import (
+	"context"
 	"fmt"
 	"math/rand"
 	"strings"
@@ -129,7 +130,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -217,7 +218,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -264,7 +265,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -313,7 +314,7 @@ func TestMySQL(t *testing.T) {
 					query := &tsdb.TsdbQuery{
 						Queries: []*tsdb.Query{
 							{
-								DataSource: &models.DataSource{},
+								DataSource: &models.DataSource{JsonData: simplejson.New()},
 								Model: simplejson.NewFromAny(map[string]interface{}{
 									"rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1",
 									"format": "time_series",
@@ -327,7 +328,7 @@ func TestMySQL(t *testing.T) {
 						},
 					}
 
-					resp, err := endpoint.Query(nil, nil, query)
+					resp, err := endpoint.Query(context.Background(), nil, query)
 					So(err, ShouldBeNil)
 					queryResult := resp.Results["A"]
 					So(queryResult.Error, ShouldBeNil)
@@ -352,7 +353,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -378,7 +379,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -473,7 +474,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -495,7 +496,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -517,7 +518,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -539,7 +540,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -561,7 +562,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -583,7 +584,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -605,7 +606,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -627,7 +628,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -649,7 +650,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -671,7 +672,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -693,7 +694,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -716,7 +717,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -741,7 +742,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -752,6 +753,30 @@ func TestMySQL(t *testing.T) {
 			})
 		})
 
+		Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
+			tsdb.Interpolate = origInterpolate
+			query := &tsdb.TsdbQuery{
+				TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
+				Queries: []*tsdb.Query{
+					{
+						DataSource: &models.DataSource{JsonData: simplejson.New()},
+						Model: simplejson.NewFromAny(map[string]interface{}{
+							"rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
+							"format": "time_series",
+						}),
+						RefId: "A",
+					},
+				},
+			}
+
+			resp, err := endpoint.Query(nil, nil, query)
+			So(err, ShouldBeNil)
+			queryResult := resp.Results["A"]
+			So(queryResult.Error, ShouldBeNil)
+			So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
+
+		})
+
 		Convey("Given a table with event data", func() {
 			type event struct {
 				TimeSec     int64
@@ -802,7 +827,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				queryResult := resp.Results["Deploys"]
 				So(err, ShouldBeNil)
 				So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -825,7 +850,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				queryResult := resp.Results["Tickets"]
 				So(err, ShouldBeNil)
 				So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -851,7 +876,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -881,7 +906,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -911,7 +936,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -941,7 +966,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -969,7 +994,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -997,7 +1022,7 @@ func TestMySQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)

+ 0 - 8
pkg/tsdb/postgres/macros.go

@@ -87,10 +87,6 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
 		}
 
 		return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
-	case "__timeFrom":
-		return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
-	case "__timeTo":
-		return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
@@ -122,10 +118,6 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
 			return "", fmt.Errorf("missing time column argument for macro %v", name)
 		}
 		return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
-	case "__unixEpochFrom":
-		return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
-	case "__unixEpochTo":
-		return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
 	case "__unixEpochGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)

+ 0 - 84
pkg/tsdb/postgres/macros_test.go

@@ -44,13 +44,6 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __timeGroup function pre 5.3 compatibility", func() {
 
 				sql, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m'), value")
@@ -102,13 +95,6 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, "GROUP BY time_bucket('300s',time_column)")
 			})
 
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
 				So(err, ShouldBeNil)
@@ -116,20 +102,6 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 			})
 
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
-
 			Convey("interpolate __unixEpochGroup function", func() {
 
 				sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@@ -155,40 +127,12 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
 				So(err, ShouldBeNil)
 
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 			})
-
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
 		})
 
 		Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -203,40 +147,12 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
-			Convey("interpolate __timeFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
-			})
-
-			Convey("interpolate __timeTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
-			})
-
 			Convey("interpolate __unixEpochFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
 				So(err, ShouldBeNil)
 
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 			})
-
-			Convey("interpolate __unixEpochFrom function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
-			})
-
-			Convey("interpolate __unixEpochTo function", func() {
-				sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
-				So(err, ShouldBeNil)
-
-				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
-			})
 		})
 	})
 }

+ 50 - 25
pkg/tsdb/postgres/postgres_test.go

@@ -1,6 +1,7 @@
 package postgres
 
 import (
+	"context"
 	"fmt"
 	"math/rand"
 	"strings"
@@ -117,7 +118,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -197,7 +198,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -254,7 +255,7 @@ func TestPostgres(t *testing.T) {
 						},
 					}
 
-					resp, err := endpoint.Query(nil, nil, query)
+					resp, err := endpoint.Query(context.Background(), nil, query)
 					So(err, ShouldBeNil)
 					queryResult := resp.Results["A"]
 					So(queryResult.Error, ShouldBeNil)
@@ -279,7 +280,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -333,7 +334,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -360,7 +361,7 @@ func TestPostgres(t *testing.T) {
 				},
 			}
 
-			resp, err := endpoint.Query(nil, nil, query)
+			resp, err := endpoint.Query(context.Background(), nil, query)
 			So(err, ShouldBeNil)
 			queryResult := resp.Results["A"]
 			So(queryResult.Error, ShouldBeNil)
@@ -450,7 +451,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -472,7 +473,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -494,7 +495,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -516,7 +517,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -538,7 +539,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -560,7 +561,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -582,7 +583,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -604,7 +605,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -626,7 +627,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -649,7 +650,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -674,7 +675,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -683,6 +684,30 @@ func TestPostgres(t *testing.T) {
 				So(queryResult.Series[0].Name, ShouldEqual, "valueOne")
 				So(queryResult.Series[1].Name, ShouldEqual, "valueTwo")
 			})
+
+			Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
+				tsdb.Interpolate = origInterpolate
+				query := &tsdb.TsdbQuery{
+					TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
+					Queries: []*tsdb.Query{
+						{
+							DataSource: &models.DataSource{JsonData: simplejson.New()},
+							Model: simplejson.NewFromAny(map[string]interface{}{
+								"rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
+								"format": "time_series",
+							}),
+							RefId: "A",
+						},
+					},
+				}
+
+				resp, err := endpoint.Query(nil, nil, query)
+				So(err, ShouldBeNil)
+				queryResult := resp.Results["A"]
+				So(queryResult.Error, ShouldBeNil)
+				So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
+
+			})
 		})
 
 		Convey("Given a table with event data", func() {
@@ -735,7 +760,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				queryResult := resp.Results["Deploys"]
 				So(err, ShouldBeNil)
 				So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -758,7 +783,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				queryResult := resp.Results["Tickets"]
 				So(err, ShouldBeNil)
 				So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -784,7 +809,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -814,7 +839,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -844,7 +869,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -874,7 +899,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -902,7 +927,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)
@@ -930,7 +955,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)

+ 4 - 0
pkg/tsdb/sql_engine.go

@@ -184,6 +184,10 @@ var Interpolate = func(query *Query, timeRange *TimeRange, sql string) (string,
 
 	sql = strings.Replace(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10), -1)
 	sql = strings.Replace(sql, "$__interval", interval.Text, -1)
+	sql = strings.Replace(sql, "$__timeFrom()", fmt.Sprintf("'%s'", timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), -1)
+	sql = strings.Replace(sql, "$__timeTo()", fmt.Sprintf("'%s'", timeRange.GetToAsTimeUTC().Format(time.RFC3339)), -1)
+	sql = strings.Replace(sql, "$__unixEpochFrom()", fmt.Sprintf("%d", timeRange.GetFromAsSecondsEpoch()), -1)
+	sql = strings.Replace(sql, "$__unixEpochTo()", fmt.Sprintf("%d", timeRange.GetToAsSecondsEpoch()), -1)
 
 	return sql, nil
 }

+ 29 - 0
pkg/tsdb/sql_engine_test.go

@@ -1,6 +1,7 @@
 package tsdb
 
 import (
+	"fmt"
 	"testing"
 	"time"
 
@@ -43,6 +44,34 @@ func TestSqlEngine(t *testing.T) {
 				So(sql, ShouldEqual, "select 60000 ")
 			})
 
+			Convey("interpolate __timeFrom function", func() {
+				sql, err := Interpolate(query, timeRange, "select $__timeFrom()")
+				So(err, ShouldBeNil)
+
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
+			})
+
+			Convey("interpolate __timeTo function", func() {
+				sql, err := Interpolate(query, timeRange, "select $__timeTo()")
+				So(err, ShouldBeNil)
+
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
+			})
+
+			Convey("interpolate __unixEpochFrom function", func() {
+				sql, err := Interpolate(query, timeRange, "select $__unixEpochFrom()")
+				So(err, ShouldBeNil)
+
+				So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
+			})
+
+			Convey("interpolate __unixEpochTo function", func() {
+				sql, err := Interpolate(query, timeRange, "select $__unixEpochTo()")
+				So(err, ShouldBeNil)
+
+				So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
+			})
+
 		})
 
 		Convey("Given row values with time.Time as time columns", func() {

+ 64 - 20
pkg/tsdb/testdata/scenarios.go

@@ -95,27 +95,20 @@ func init() {
 		Id:   "random_walk",
 		Name: "Random Walk",
 
-		Handler: func(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
-			timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
-			to := tsdbQuery.TimeRange.GetToAsMsEpoch()
-
-			series := newSeriesForQuery(query)
-
-			points := make(tsdb.TimeSeriesPoints, 0)
-			walker := rand.Float64() * 100
-
-			for i := int64(0); i < 10000 && timeWalkerMs < to; i++ {
-				points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs)))
-
-				walker += rand.Float64() - 0.5
-				timeWalkerMs += query.IntervalMs
-			}
-
-			series.Points = points
+		Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
+			return getRandomWalk(query, context)
+		},
+	})
 
-			queryRes := tsdb.NewQueryResult()
-			queryRes.Series = append(queryRes.Series, series)
-			return queryRes
+	registerScenario(&Scenario{
+		Id:          "slow_query",
+		Name:        "Slow Query",
+		StringInput: "5s",
+		Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
+			stringInput := query.Model.Get("stringInput").MustString()
+			parsedInterval, _ := time.ParseDuration(stringInput)
+			time.Sleep(parsedInterval)
+			return getRandomWalk(query, context)
 		},
 	})
 
@@ -221,6 +214,57 @@ func init() {
 			return queryRes
 		},
 	})
+
+	registerScenario(&Scenario{
+		Id:   "table_static",
+		Name: "Table Static",
+
+		Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
+			timeWalkerMs := context.TimeRange.GetFromAsMsEpoch()
+			to := context.TimeRange.GetToAsMsEpoch()
+
+			table := tsdb.Table{
+				Columns: []tsdb.TableColumn{
+					{Text: "Time"},
+					{Text: "Message"},
+					{Text: "Description"},
+					{Text: "Value"},
+				},
+				Rows: []tsdb.RowValues{},
+			}
+			for i := int64(0); i < 10 && timeWalkerMs < to; i++ {
+				table.Rows = append(table.Rows, tsdb.RowValues{float64(timeWalkerMs), "This is a message", "Description", 23.1})
+				timeWalkerMs += query.IntervalMs
+			}
+
+			queryRes := tsdb.NewQueryResult()
+			queryRes.Tables = append(queryRes.Tables, &table)
+			return queryRes
+		},
+	})
+}
+
+func getRandomWalk(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
+	timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
+	to := tsdbQuery.TimeRange.GetToAsMsEpoch()
+
+	series := newSeriesForQuery(query)
+
+	points := make(tsdb.TimeSeriesPoints, 0)
+	walker := rand.Float64() * 100
+
+	for i := int64(0); i < 10000 && timeWalkerMs < to; i++ {
+		points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs)))
+
+		walker += rand.Float64() - 0.5
+		timeWalkerMs += query.IntervalMs
+	}
+
+	series.Points = points
+
+	queryRes := tsdb.NewQueryResult()
+	queryRes.Series = append(queryRes.Series, series)
+	return queryRes
 }
 
 func registerScenario(scenario *Scenario) {

+ 24 - 0
public/app/core/components/OrgActionBar/OrgActionBar.test.tsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import OrgActionBar, { Props } from './OrgActionBar';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    searchQuery: '',
+    setSearchQuery: jest.fn(),
+    target: '_blank',
+    linkButton: { href: 'some/url', title: 'test' },
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<OrgActionBar {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 44 - 0
public/app/core/components/OrgActionBar/OrgActionBar.tsx

@@ -0,0 +1,44 @@
+import React, { PureComponent } from 'react';
+import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
+
+export interface Props {
+  searchQuery: string;
+  layoutMode?: LayoutMode;
+  onSetLayoutMode?: (mode: LayoutMode) => {};
+  setSearchQuery: (value: string) => {};
+  linkButton: { href: string; title: string };
+  target?: string;
+}
+
+export default class OrgActionBar extends PureComponent<Props> {
+  render() {
+    const { searchQuery, layoutMode, onSetLayoutMode, linkButton, setSearchQuery, target } = this.props;
+    const linkProps = { href: linkButton.href, target: undefined };
+
+    if (target) {
+      linkProps.target = target;
+    }
+
+    return (
+      <div className="page-action-bar">
+        <div className="gf-form gf-form--grow">
+          <label className="gf-form--has-input-icon">
+            <input
+              type="text"
+              className="gf-form-input width-20"
+              value={searchQuery}
+              onChange={event => setSearchQuery(event.target.value)}
+              placeholder="Filter by name or type"
+            />
+            <i className="gf-form-input-icon fa fa-search" />
+          </label>
+          <LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => onSetLayoutMode(mode)} />
+        </div>
+        <div className="page-action-bar__spacer" />
+        <a className="btn btn-success" {...linkProps}>
+          {linkButton.title}
+        </a>
+      </div>
+    );
+  }
+}

+ 2 - 3
public/app/features/plugins/__snapshots__/PluginActionBar.test.tsx.snap → public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap

@@ -22,7 +22,6 @@ exports[`Render should render component 1`] = `
       />
     </label>
     <LayoutSelector
-      mode="grid"
       onLayoutModeChanged={[Function]}
     />
   </div>
@@ -31,10 +30,10 @@ exports[`Render should render component 1`] = `
   />
   <a
     className="btn btn-success"
-    href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
+    href="some/url"
     target="_blank"
   >
-    Find more plugins on Grafana.com
+    test
   </a>
 </div>
 `;

+ 5 - 2
public/app/core/components/grafana_app.ts

@@ -10,6 +10,7 @@ import colors from 'app/core/utils/colors';
 import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { configureStore } from 'app/store/configureStore';
+import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
 
 export class GrafanaCtrl {
   /** @ngInject */
@@ -22,11 +23,13 @@ export class GrafanaCtrl {
     contextSrv,
     bridgeSrv,
     backendSrv: BackendSrv,
-    datasourceSrv: DatasourceSrv
+    datasourceSrv: DatasourceSrv,
+    angularLoader: AngularLoader
   ) {
     // sets singleston instances for angular services so react components can access them
-    configureStore();
+    setAngularLoader(angularLoader);
     setBackendSrv(backendSrv);
+    configureStore();
 
     $scope.init = () => {
       $scope.contextSrv = contextSrv;

+ 42 - 0
public/app/core/services/AngularLoader.ts

@@ -0,0 +1,42 @@
+import angular from 'angular';
+import coreModule from 'app/core/core_module';
+import _ from 'lodash';
+
+export interface AngularComponent {
+  destroy();
+}
+
+export class AngularLoader {
+  /** @ngInject */
+  constructor(private $compile, private $rootScope) {}
+
+  load(elem, scopeProps, template): AngularComponent {
+    const scope = this.$rootScope.$new();
+
+    _.assign(scope, scopeProps);
+
+    const compiledElem = this.$compile(template)(scope);
+    const rootNode = angular.element(elem);
+    rootNode.append(compiledElem);
+
+    return {
+      destroy: () => {
+        scope.$destroy();
+        compiledElem.remove();
+      },
+    };
+  }
+}
+
+coreModule.service('angularLoader', AngularLoader);
+
+let angularLoaderInstance: AngularLoader;
+
+export function setAngularLoader(pl: AngularLoader) {
+  angularLoaderInstance = pl;
+}
+
+// away to access it from react
+export function getAngularLoader(): AngularLoader {
+  return angularLoaderInstance;
+}

+ 12 - 10
public/app/core/services/keybindingSrv.ts

@@ -4,7 +4,7 @@ import _ from 'lodash';
 import config from 'app/core/config';
 import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
-import { renderUrl } from 'app/core/utils/url';
+import { getExploreUrl } from 'app/core/utils/explore';
 
 import Mousetrap from 'mousetrap';
 import 'mousetrap-global-bind';
@@ -15,7 +15,14 @@ export class KeybindingSrv {
   timepickerOpen = false;
 
   /** @ngInject */
-  constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv, private contextSrv) {
+  constructor(
+    private $rootScope,
+    private $location,
+    private $timeout,
+    private datasourceSrv,
+    private timeSrv,
+    private contextSrv
+  ) {
     // clear out all shortcuts on route change
     $rootScope.$on('$routeChangeSuccess', () => {
       Mousetrap.reset();
@@ -194,14 +201,9 @@ export class KeybindingSrv {
         if (dashboard.meta.focusPanelId) {
           const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
           const datasource = await this.datasourceSrv.get(panel.datasource);
-          if (datasource && datasource.supportsExplore) {
-            const range = this.timeSrv.timeRangeForUrl();
-            const state = {
-              ...datasource.getExploreState(panel),
-              range,
-            };
-            const exploreState = JSON.stringify(state);
-            this.$location.url(renderUrl('/explore', { state: exploreState }));
+          const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
+          if (url) {
+            this.$timeout(() => this.$location.url(url));
           }
         }
       });

+ 5 - 5
public/app/features/explore/Wrapper.test.tsx → public/app/core/utils/explore.test.ts

@@ -1,6 +1,5 @@
-import { serializeStateToUrlParam, parseUrlState } from './Wrapper';
-import { DEFAULT_RANGE } from './TimePicker';
-import { ExploreState } from './Explore';
+import { DEFAULT_RANGE, serializeStateToUrlParam, parseUrlState } from './explore';
+import { ExploreState } from 'app/types/explore';
 
 const DEFAULT_EXPLORE_STATE: ExploreState = {
   datasource: null,
@@ -8,6 +7,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
   datasourceLoading: null,
   datasourceMissing: false,
   datasourceName: '',
+  exploreDatasources: [],
   graphResult: null,
   history: [],
   latency: 0,
@@ -27,7 +27,7 @@ const DEFAULT_EXPLORE_STATE: ExploreState = {
   tableResult: null,
 };
 
-describe('Wrapper state functions', () => {
+describe('state functions', () => {
   describe('parseUrlState', () => {
     it('returns default state on empty string', () => {
       expect(parseUrlState('')).toMatchObject({
@@ -57,7 +57,7 @@ describe('Wrapper state functions', () => {
       };
       expect(serializeStateToUrlParam(state)).toBe(
         '{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' +
-          '{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}'
+        '{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now - 5h","to":"now"}}'
       );
     });
   });

+ 78 - 0
public/app/core/utils/explore.ts

@@ -0,0 +1,78 @@
+import { renderUrl } from 'app/core/utils/url';
+import { ExploreState, ExploreUrlState } from 'app/types/explore';
+
+export const DEFAULT_RANGE = {
+  from: 'now-6h',
+  to: 'now',
+};
+
+/**
+ * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
+ *
+ * @param panel Origin panel of the jump to Explore
+ * @param panelTargets The origin panel's query targets
+ * @param panelDatasource The origin panel's datasource
+ * @param datasourceSrv Datasource service to query other datasources in case the panel datasource is mixed
+ * @param timeSrv Time service to get the current dashboard range from
+ */
+export async function getExploreUrl(
+  panel: any,
+  panelTargets: any[],
+  panelDatasource: any,
+  datasourceSrv: any,
+  timeSrv: any
+) {
+  let exploreDatasource = panelDatasource;
+  let exploreTargets = panelTargets;
+  let url;
+
+  // Mixed datasources need to choose only one datasource
+  if (panelDatasource.meta.id === 'mixed' && panelTargets) {
+    // Find first explore datasource among targets
+    let mixedExploreDatasource;
+    for (const t of panel.targets) {
+      const datasource = await datasourceSrv.get(t.datasource);
+      if (datasource && datasource.meta.explore) {
+        mixedExploreDatasource = datasource;
+        break;
+      }
+    }
+
+    // Add all its targets
+    if (mixedExploreDatasource) {
+      exploreDatasource = mixedExploreDatasource;
+      exploreTargets = panelTargets.filter(t => t.datasource === mixedExploreDatasource.name);
+    }
+  }
+
+  if (exploreDatasource && exploreDatasource.meta.explore) {
+    const range = timeSrv.timeRangeForUrl();
+    const state = {
+      ...exploreDatasource.getExploreState(exploreTargets),
+      range,
+    };
+    const exploreState = JSON.stringify(state);
+    url = renderUrl('/explore', { state: exploreState });
+  }
+  return url;
+}
+
+export function parseUrlState(initial: string | undefined): ExploreUrlState {
+  if (initial) {
+    try {
+      return JSON.parse(decodeURI(initial));
+    } catch (e) {
+      console.error(e);
+    }
+  }
+  return { datasource: null, queries: [], range: DEFAULT_RANGE };
+}
+
+export function serializeStateToUrlParam(state: ExploreState): string {
+  const urlState: ExploreUrlState = {
+    datasource: state.datasourceName,
+    queries: state.queries.map(q => ({ query: q.query })),
+    range: state.range,
+  };
+  return JSON.stringify(urlState);
+}

+ 0 - 5
public/app/core/utils/location_util.ts

@@ -1,10 +1,5 @@
 import config from 'app/core/config';
 
-// Slash encoding for angular location provider, see https://github.com/angular/angular.js/issues/10479
-const SLASH = '<SLASH>';
-export const decodePathComponent = (pc: string) => decodeURIComponent(pc).replace(new RegExp(SLASH, 'g'), '/');
-export const encodePathComponent = (pc: string) => encodeURIComponent(pc.replace(/\//g, SLASH));
-
 export const stripBaseFromUrl = url => {
   const appSubUrl = config.appSubUrl;
   const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;

+ 1 - 1
public/app/features/dashboard/state/actions.ts

@@ -58,7 +58,7 @@ export function updateDashboardPermission(
         continue;
       }
 
-      const updated = toUpdateItem(itemToUpdate);
+      const updated = toUpdateItem(item);
 
       // if this is the item we want to update, update it's permisssion
       if (itemToUpdate === item) {

+ 0 - 23
public/app/features/datasources/DataSourcesActionBar.test.tsx

@@ -1,23 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { DataSourcesActionBar, Props } from './DataSourcesActionBar';
-import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
-
-const setup = (propOverrides?: object) => {
-  const props: Props = {
-    layoutMode: LayoutModes.Grid,
-    searchQuery: '',
-    setDataSourcesLayoutMode: jest.fn(),
-    setDataSourcesSearchQuery: jest.fn(),
-  };
-
-  return shallow(<DataSourcesActionBar {...props} />);
-};
-
-describe('Render', () => {
-  it('should render component', () => {
-    const wrapper = setup();
-
-    expect(wrapper).toMatchSnapshot();
-  });
-});

+ 0 - 62
public/app/features/datasources/DataSourcesActionBar.tsx

@@ -1,62 +0,0 @@
-import React, { PureComponent } from 'react';
-import { connect } from 'react-redux';
-import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
-import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
-import { getDataSourcesLayoutMode, getDataSourcesSearchQuery } from './state/selectors';
-
-export interface Props {
-  searchQuery: string;
-  layoutMode: LayoutMode;
-  setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
-  setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
-}
-
-export class DataSourcesActionBar extends PureComponent<Props> {
-  onSearchQueryChange = event => {
-    this.props.setDataSourcesSearchQuery(event.target.value);
-  };
-
-  render() {
-    const { searchQuery, layoutMode, setDataSourcesLayoutMode } = this.props;
-
-    return (
-      <div className="page-action-bar">
-        <div className="gf-form gf-form--grow">
-          <label className="gf-form--has-input-icon">
-            <input
-              type="text"
-              className="gf-form-input width-20"
-              value={searchQuery}
-              onChange={this.onSearchQueryChange}
-              placeholder="Filter by name or type"
-            />
-            <i className="gf-form-input-icon fa fa-search" />
-          </label>
-          <LayoutSelector
-            mode={layoutMode}
-            onLayoutModeChanged={(mode: LayoutMode) => setDataSourcesLayoutMode(mode)}
-          />
-        </div>
-        <div className="page-action-bar__spacer" />
-        <a className="page-header__cta btn btn-success" href="datasources/new">
-          <i className="fa fa-plus" />
-          Add data source
-        </a>
-      </div>
-    );
-  }
-}
-
-function mapStateToProps(state) {
-  return {
-    searchQuery: getDataSourcesSearchQuery(state.dataSources),
-    layoutMode: getDataSourcesLayoutMode(state.dataSources),
-  };
-}
-
-const mapDispatchToProps = {
-  setDataSourcesLayoutMode,
-  setDataSourcesSearchQuery,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(DataSourcesActionBar);

+ 3 - 0
public/app/features/datasources/DataSourcesListPage.test.tsx

@@ -12,6 +12,9 @@ const setup = (propOverrides?: object) => {
     loadDataSources: jest.fn(),
     navModel: {} as NavModel,
     dataSourcesCount: 0,
+    searchQuery: '',
+    setDataSourcesSearchQuery: jest.fn(),
+    setDataSourcesLayoutMode: jest.fn(),
   };
 
   Object.assign(props, propOverrides);

+ 38 - 7
public/app/features/datasources/DataSourcesListPage.tsx

@@ -2,21 +2,29 @@ import React, { PureComponent } from 'react';
 import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 import PageHeader from '../../core/components/PageHeader/PageHeader';
-import DataSourcesActionBar from './DataSourcesActionBar';
+import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
+import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
 import DataSourcesList from './DataSourcesList';
-import { loadDataSources } from './state/actions';
-import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors';
-import { getNavModel } from '../../core/selectors/navModel';
 import { DataSource, NavModel } from 'app/types';
 import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
-import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
+import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import {
+  getDataSources,
+  getDataSourcesCount,
+  getDataSourcesLayoutMode,
+  getDataSourcesSearchQuery,
+} from './state/selectors';
 
 export interface Props {
   navModel: NavModel;
   dataSources: DataSource[];
   dataSourcesCount: number;
   layoutMode: LayoutMode;
+  searchQuery: string;
   loadDataSources: typeof loadDataSources;
+  setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
+  setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
 }
 
 const emptyListModel = {
@@ -40,7 +48,20 @@ export class DataSourcesListPage extends PureComponent<Props> {
   }
 
   render() {
-    const { dataSources, dataSourcesCount, navModel, layoutMode } = this.props;
+    const {
+      dataSources,
+      dataSourcesCount,
+      navModel,
+      layoutMode,
+      searchQuery,
+      setDataSourcesSearchQuery,
+      setDataSourcesLayoutMode,
+    } = this.props;
+
+    const linkButton = {
+      href: 'datasources/new',
+      title: 'Add data source',
+    };
 
     return (
       <div>
@@ -50,7 +71,14 @@ export class DataSourcesListPage extends PureComponent<Props> {
             <EmptyListCTA model={emptyListModel} />
           ) : (
             [
-              <DataSourcesActionBar key="action-bar" />,
+              <OrgActionBar
+                layoutMode={layoutMode}
+                searchQuery={searchQuery}
+                onSetLayoutMode={mode => setDataSourcesLayoutMode(mode)}
+                setSearchQuery={query => setDataSourcesSearchQuery(query)}
+                linkButton={linkButton}
+                key="action-bar"
+              />,
               <DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
             ]
           )}
@@ -66,11 +94,14 @@ function mapStateToProps(state) {
     dataSources: getDataSources(state.dataSources),
     layoutMode: getDataSourcesLayoutMode(state.dataSources),
     dataSourcesCount: getDataSourcesCount(state.dataSources),
+    searchQuery: getDataSourcesSearchQuery(state.dataSources),
   };
 }
 
 const mapDispatchToProps = {
   loadDataSources,
+  setDataSourcesSearchQuery,
+  setDataSourcesLayoutMode,
 };
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));

+ 0 - 42
public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap

@@ -1,42 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Render should render component 1`] = `
-<div
-  className="page-action-bar"
->
-  <div
-    className="gf-form gf-form--grow"
-  >
-    <label
-      className="gf-form--has-input-icon"
-    >
-      <input
-        className="gf-form-input width-20"
-        onChange={[Function]}
-        placeholder="Filter by name or type"
-        type="text"
-        value=""
-      />
-      <i
-        className="gf-form-input-icon fa fa-search"
-      />
-    </label>
-    <LayoutSelector
-      mode="grid"
-      onLayoutModeChanged={[Function]}
-    />
-  </div>
-  <div
-    className="page-action-bar__spacer"
-  />
-  <a
-    className="page-header__cta btn btn-success"
-    href="datasources/new"
-  >
-    <i
-      className="fa fa-plus"
-    />
-    Add data source
-  </a>
-</div>
-`;

+ 11 - 1
public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap

@@ -8,8 +8,18 @@ exports[`Render should render action bar and datasources 1`] = `
   <div
     className="page-container page-body"
   >
-    <Connect(DataSourcesActionBar)
+    <OrgActionBar
       key="action-bar"
+      layoutMode="grid"
+      linkButton={
+        Object {
+          "href": "datasources/new",
+          "title": "Add data source",
+        }
+      }
+      onSetLayoutMode={[Function]}
+      searchQuery=""
+      setSearchQuery={[Function]}
     />
     <DataSourcesList
       dataSources={

+ 105 - 103
public/app/features/explore/Explore.tsx

@@ -2,19 +2,20 @@ import React from 'react';
 import { hot } from 'react-hot-loader';
 import Select from 'react-select';
 
-import { Query, Range, ExploreUrlState } from 'app/types/explore';
+import { ExploreState, ExploreUrlState, Query } from 'app/types/explore';
 import kbn from 'app/core/utils/kbn';
 import colors from 'app/core/utils/colors';
 import store from 'app/core/store';
 import TimeSeries from 'app/core/time_series2';
 import { parse as parseDate } from 'app/core/utils/datemath';
+import { DEFAULT_RANGE } from 'app/core/utils/explore';
 
 import 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 TimePicker from './TimePicker';
 import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
 
 const MAX_HISTORY_ITEMS = 100;
@@ -58,64 +59,52 @@ interface ExploreProps {
   urlState: ExploreUrlState;
 }
 
-export interface ExploreState {
-  datasource: any;
-  datasourceError: any;
-  datasourceLoading: boolean | null;
-  datasourceMissing: boolean;
-  datasourceName?: string;
-  graphResult: any;
-  history: any[];
-  latency: number;
-  loading: any;
-  logsResult: any;
-  queries: Query[];
-  queryErrors: any[];
-  queryHints: any[];
-  range: Range;
-  requestOptions: any;
-  showingGraph: boolean;
-  showingLogs: boolean;
-  showingTable: boolean;
-  supportsGraph: boolean | null;
-  supportsLogs: boolean | null;
-  supportsTable: boolean | null;
-  tableResult: any;
-}
-
 export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   el: any;
+  /**
+   * Current query expressions of the rows including their modifications, used for running queries.
+   * Not kept in component state to prevent edit-render roundtrips.
+   */
+  queryExpressions: string[];
 
   constructor(props) {
     super(props);
-    // Split state overrides everything
     const splitState: ExploreState = props.splitState;
-    const { datasource, queries, range } = props.urlState;
-    this.state = {
-      datasource: null,
-      datasourceError: null,
-      datasourceLoading: null,
-      datasourceMissing: false,
-      datasourceName: datasource,
-      graphResult: null,
-      history: [],
-      latency: 0,
-      loading: false,
-      logsResult: null,
-      queries: ensureQueries(queries),
-      queryErrors: [],
-      queryHints: [],
-      range: range || { ...DEFAULT_RANGE },
-      requestOptions: null,
-      showingGraph: true,
-      showingLogs: true,
-      showingTable: true,
-      supportsGraph: null,
-      supportsLogs: null,
-      supportsTable: null,
-      tableResult: null,
-      ...splitState,
-    };
+    let initialQueries: Query[];
+    if (splitState) {
+      // Split state overrides everything
+      this.state = splitState;
+      initialQueries = splitState.queries;
+    } else {
+      const { datasource, queries, range } = props.urlState as ExploreUrlState;
+      initialQueries = ensureQueries(queries);
+      this.state = {
+        datasource: null,
+        datasourceError: null,
+        datasourceLoading: null,
+        datasourceMissing: false,
+        datasourceName: datasource,
+        exploreDatasources: [],
+        graphResult: null,
+        history: [],
+        latency: 0,
+        loading: false,
+        logsResult: null,
+        queries: initialQueries,
+        queryErrors: [],
+        queryHints: [],
+        range: range || { ...DEFAULT_RANGE },
+        requestOptions: null,
+        showingGraph: true,
+        showingLogs: true,
+        showingTable: true,
+        supportsGraph: null,
+        supportsLogs: null,
+        supportsTable: null,
+        tableResult: null,
+      };
+    }
+    this.queryExpressions = initialQueries.map(q => q.query);
   }
 
   async componentDidMount() {
@@ -125,8 +114,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       throw new Error('No datasource service passed as props.');
     }
     const datasources = datasourceSrv.getExploreSources();
+    const exploreDatasources = datasources.map(ds => ({
+      value: ds.name,
+      label: ds.name,
+    }));
+
     if (datasources.length > 0) {
-      this.setState({ datasourceLoading: true });
+      this.setState({ datasourceLoading: true, exploreDatasources });
       // Priority: datasource in url, default datasource, first explore datasource
       let datasource;
       if (datasourceName) {
@@ -170,9 +164,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     }
 
     // Keep queries but reset edit state
-    const nextQueries = this.state.queries.map(q => ({
+    const nextQueries = this.state.queries.map((q, i) => ({
       ...q,
-      edited: false,
+      key: generateQueryKey(i),
+      query: this.queryExpressions[i],
     }));
 
     this.setState(
@@ -201,6 +196,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
 
   onAddQueryRow = index => {
     const { queries } = this.state;
+    this.queryExpressions[index + 1] = '';
     const nextQueries = [
       ...queries.slice(0, index + 1),
       { query: '', key: generateQueryKey() },
@@ -227,29 +223,28 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onChangeQuery = (value: string, index: number, override?: boolean) => {
-    const { queries } = this.state;
-    let { queryErrors, queryHints } = this.state;
-    const prevQuery = queries[index];
-    const edited = override ? false : prevQuery.query !== value;
-    const nextQuery = {
-      ...queries[index],
-      edited,
-      query: value,
-    };
-    const nextQueries = [...queries];
-    nextQueries[index] = nextQuery;
+    // Keep current value in local cache
+    this.queryExpressions[index] = value;
+
+    // Replace query row on override
     if (override) {
-      queryErrors = [];
-      queryHints = [];
+      const { queries } = this.state;
+      const nextQuery: Query = {
+        key: generateQueryKey(index),
+        query: value,
+      };
+      const nextQueries = [...queries];
+      nextQueries[index] = nextQuery;
+
+      this.setState(
+        {
+          queryErrors: [],
+          queryHints: [],
+          queries: nextQueries,
+        },
+        this.onSubmit
+      );
     }
-    this.setState(
-      {
-        queryErrors,
-        queryHints,
-        queries: nextQueries,
-      },
-      override ? () => this.onSubmit() : undefined
-    );
   };
 
   onChangeTime = nextRange => {
@@ -261,6 +256,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   };
 
   onClickClear = () => {
+    this.queryExpressions = [''];
     this.setState(
       {
         graphResult: null,
@@ -293,9 +289,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
 
   onClickSplit = () => {
     const { onChangeSplit } = this.props;
-    const state = { ...this.state };
-    state.queries = state.queries.map(({ edited, ...rest }) => rest);
     if (onChangeSplit) {
+      const state = this.cloneState();
       onChangeSplit(true, state);
       this.saveState();
     }
@@ -315,23 +310,22 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       let nextQueries;
       if (index === undefined) {
         // Modify all queries
-        nextQueries = queries.map(q => ({
-          ...q,
-          edited: false,
-          query: datasource.modifyQuery(q.query, action),
+        nextQueries = queries.map((q, i) => ({
+          key: generateQueryKey(i),
+          query: datasource.modifyQuery(this.queryExpressions[i], action),
         }));
       } else {
         // Modify query only at index
         nextQueries = [
           ...queries.slice(0, index),
           {
-            ...queries[index],
-            edited: false,
-            query: datasource.modifyQuery(queries[index].query, action),
+            key: generateQueryKey(index),
+            query: datasource.modifyQuery(this.queryExpressions[index], action),
           },
           ...queries.slice(index + 1),
         ];
       }
+      this.queryExpressions = nextQueries.map(q => q.query);
       this.setState({ queries: nextQueries }, () => this.onSubmit());
     }
   };
@@ -342,6 +336,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       return;
     }
     const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
+    this.queryExpressions = nextQueries.map(q => q.query);
     this.setState({ queries: nextQueries }, () => this.onSubmit());
   };
 
@@ -359,7 +354,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     this.saveState();
   };
 
-  onQuerySuccess(datasourceId: string, queries: any[]): void {
+  onQuerySuccess(datasourceId: string, queries: string[]): void {
     // save queries to history
     let { history } = this.state;
     const { datasource } = this.state;
@@ -370,8 +365,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     }
 
     const ts = Date.now();
-    queries.forEach(q => {
-      const { query } = q;
+    queries.forEach(query => {
       history = [{ query, ts }, ...history];
     });
 
@@ -386,16 +380,16 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   }
 
   buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) {
-    const { datasource, queries, range } = this.state;
+    const { datasource, 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 => ({
+    const targets = this.queryExpressions.map(q => ({
       ...targetOptions,
-      expr: q.query,
+      expr: q,
     }));
     return {
       interval,
@@ -405,7 +399,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   }
 
   async runGraphQuery() {
-    const { datasource, queries } = this.state;
+    const { datasource } = this.state;
+    const queries = [...this.queryExpressions];
     if (!hasQuery(queries)) {
       return;
     }
@@ -427,7 +422,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   }
 
   async runTableQuery() {
-    const { datasource, queries } = this.state;
+    const queries = [...this.queryExpressions];
+    const { datasource } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
@@ -451,7 +447,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
   }
 
   async runLogsQuery() {
-    const { datasource, queries } = this.state;
+    const queries = [...this.queryExpressions];
+    const { datasource } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
@@ -479,18 +476,27 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     return datasource.metadataRequest(url);
   };
 
+  cloneState(): ExploreState {
+    // Copy state, but copy queries including modifications
+    return {
+      ...this.state,
+      queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
+    };
+  }
+
   saveState = () => {
     const { stateKey, onSaveState } = this.props;
-    onSaveState(stateKey, this.state);
+    onSaveState(stateKey, this.cloneState());
   };
 
   render() {
-    const { datasourceSrv, position, split } = this.props;
+    const { position, split } = this.props;
     const {
       datasource,
       datasourceError,
       datasourceLoading,
       datasourceMissing,
+      exploreDatasources,
       graphResult,
       history,
       latency,
@@ -515,10 +521,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     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 (
@@ -544,7 +546,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
                 clearable={false}
                 className="gf-form-input gf-form-input--form-dropdown datasource-picker"
                 onChange={this.onChangeDatasource}
-                options={datasources}
+                options={exploreDatasources}
                 isOpen={true}
                 placeholder="Loading datasources..."
                 value={selectedDatasource}

+ 19 - 9
public/app/features/explore/PromQueryField.tsx

@@ -156,6 +156,7 @@ interface PromQueryFieldState {
   labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   logLabelOptions: any[];
   metrics: string[];
+  metricsOptions: any[];
   metricsByPrefix: CascaderOption[];
 }
 
@@ -167,7 +168,7 @@ interface PromTypeaheadInput {
   value?: Value;
 }
 
-class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
+class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
   plugins: any[];
 
   constructor(props: PromQueryFieldProps, context) {
@@ -189,6 +190,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
       logLabelOptions: [],
       metrics: props.metrics || [],
       metricsByPrefix: props.metricsByPrefix || [],
+      metricsOptions: [],
     };
   }
 
@@ -258,10 +260,22 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
   };
 
   onReceiveMetrics = () => {
-    if (!this.state.metrics) {
+    const { histogramMetrics, metrics, metricsByPrefix } = this.state;
+    if (!metrics) {
       return;
     }
+
+    // Update global prism config
     setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
+
+    // Build metrics tree
+    const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
+    const metricsOptions = [
+      { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
+      ...metricsByPrefix,
+    ];
+
+    this.setState({ metricsOptions });
   };
 
   onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
@@ -453,7 +467,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
       const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
       if (histogramSeries && histogramSeries['__name__']) {
         const histogramMetrics = histogramSeries['__name__'].slice().sort();
-        this.setState({ histogramMetrics });
+        this.setState({ histogramMetrics }, this.onReceiveMetrics);
       }
     });
   }
@@ -545,12 +559,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
 
   render() {
     const { error, hint, supportsLogs } = this.props;
-    const { histogramMetrics, logLabelOptions, metricsByPrefix } = this.state;
-    const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
-    const metricsOptions = [
-      { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
-      ...metricsByPrefix,
-    ];
+    const { logLabelOptions, metricsOptions } = this.state;
 
     return (
       <div className="prom-query-field">
@@ -575,6 +584,7 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
               onWillApplySuggestion={willApplySuggestion}
               onValueChanged={this.onChangeQuery}
               placeholder="Enter a PromQL query"
+              portalPrefix="prometheus"
             />
           </div>
           {error ? <div className="prom-query-field-info text-error">{error}</div> : null}

+ 40 - 28
public/app/features/explore/QueryField.tsx

@@ -11,10 +11,17 @@ import NewlinePlugin from './slate-plugins/newline';
 import Typeahead from './Typeahead';
 import { makeFragment, makeValue } from './Value';
 
-export const TYPEAHEAD_DEBOUNCE = 300;
+export const TYPEAHEAD_DEBOUNCE = 100;
 
-function flattenSuggestions(s: any[]): any[] {
-  return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
+function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion {
+  // Flatten suggestion groups
+  const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
+  const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
+  return flattenedSuggestions[correctedIndex];
+}
+
+function hasSuggestions(suggestions: SuggestionGroup[]): boolean {
+  return suggestions && suggestions.length > 0;
 }
 
 export interface Suggestion {
@@ -125,7 +132,7 @@ export interface TypeaheadOutput {
   suggestions: SuggestionGroup[];
 }
 
-class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
+class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadFieldState> {
   menuEl: HTMLElement | null;
   plugins: any[];
   resetTimer: any;
@@ -154,8 +161,14 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
     clearTimeout(this.resetTimer);
   }
 
-  componentDidUpdate() {
-    this.updateMenu();
+  componentDidUpdate(prevProps, prevState) {
+    // Only update menu location when suggestion existence or text/selection changed
+    if (
+      this.state.value !== prevState.value ||
+      hasSuggestions(this.state.suggestions) !== hasSuggestions(prevState.suggestions)
+    ) {
+      this.updateMenu();
+    }
   }
 
   componentWillReceiveProps(nextProps) {
@@ -166,15 +179,21 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
   }
 
   onChange = ({ value }) => {
-    const changed = value.document !== this.state.value.document;
+    const textChanged = value.document !== this.state.value.document;
+
+    // Control editor loop, then pass text change up to parent
     this.setState({ value }, () => {
-      if (changed) {
+      if (textChanged) {
         this.handleChangeValue();
       }
     });
 
-    if (changed) {
+    // Show suggest menu on text input
+    if (textChanged && value.selection.isCollapsed) {
+      // Need one paint to allow DOM-based typeahead rules to work
       window.requestAnimationFrame(this.handleTypeahead);
+    } else {
+      this.resetTypeahead();
     }
   };
 
@@ -216,7 +235,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
         wrapperNode,
       });
 
-      const filteredSuggestions = suggestions
+      let filteredSuggestions = suggestions
         .map(group => {
           if (group.items) {
             if (prefix) {
@@ -241,6 +260,11 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
         })
         .filter(group => group.items && group.items.length > 0); // Filter out empty groups
 
+      // Keep same object for equality checking later
+      if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
+        filteredSuggestions = this.state.suggestions;
+      }
+
       this.setState(
         {
           suggestions: filteredSuggestions,
@@ -326,12 +350,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
             return undefined;
           }
 
-          // Get the currently selected suggestion
-          const flattenedSuggestions = flattenSuggestions(suggestions);
-          const selected = Math.abs(typeaheadIndex);
-          const selectedIndex = selected % flattenedSuggestions.length || 0;
-          const suggestion = flattenedSuggestions[selectedIndex];
-
+          const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
           this.applyTypeahead(change, suggestion);
           return true;
         }
@@ -408,8 +427,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
     }
 
     // No suggestions or blur, remove menu
-    const hasSuggesstions = suggestions && suggestions.length > 0;
-    if (!hasSuggesstions) {
+    if (!hasSuggestions(suggestions)) {
       menu.removeAttribute('style');
       return;
     }
@@ -436,18 +454,12 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
 
   renderMenu = () => {
     const { portalPrefix } = this.props;
-    const { suggestions } = this.state;
-    const hasSuggesstions = suggestions && suggestions.length > 0;
-    if (!hasSuggesstions) {
+    const { suggestions, typeaheadIndex } = this.state;
+    if (!hasSuggestions(suggestions)) {
       return null;
     }
 
-    // Guard selectedIndex to be within the length of the suggestions
-    let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
-    const flattenedSuggestions = flattenSuggestions(suggestions);
-    selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
-    const selectedItem: Suggestion | null =
-      flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
+    const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
 
     // Create typeahead in DOM root so we can later position it absolutely
     return (
@@ -482,7 +494,7 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
   }
 }
 
-class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
+class Portal extends React.PureComponent<{ index?: number; prefix: string }, {}> {
   node: HTMLElement;
 
   constructor(props) {

+ 3 - 4
public/app/features/explore/QueryRows.tsx

@@ -44,14 +44,14 @@ class QueryRow extends PureComponent<any, {}> {
   };
 
   render() {
-    const { edited, history, query, queryError, queryHint, request, supportsLogs } = this.props;
+    const { history, query, queryError, queryHint, request, supportsLogs } = this.props;
     return (
       <div className="query-row">
         <div className="query-row-field">
           <QueryField
             error={queryError}
             hint={queryHint}
-            initialQuery={edited ? null : query}
+            initialQuery={query}
             history={history}
             portalPrefix="explore"
             onClickHintFix={this.onClickHintFix}
@@ -79,7 +79,7 @@ class QueryRow extends PureComponent<any, {}> {
 
 export default class QueryRows extends PureComponent<any, {}> {
   render() {
-    const { className = '', queries, queryErrors = [], queryHints = [], ...handlers } = this.props;
+    const { className = '', queries, queryErrors, queryHints, ...handlers } = this.props;
     return (
       <div className={className}>
         {queries.map((q, index) => (
@@ -89,7 +89,6 @@ export default class QueryRows extends PureComponent<any, {}> {
             query={q.query}
             queryError={queryErrors[index]}
             queryHint={queryHints[index]}
-            edited={q.edited}
             {...handlers}
           />
         ))}

+ 0 - 1
public/app/features/explore/TimePicker.tsx

@@ -5,7 +5,6 @@ import * as dateMath from 'app/core/utils/datemath';
 import * as rangeUtil from 'app/core/utils/rangeutil';
 
 const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
-
 export const DEFAULT_RANGE = {
   from: 'now-6h',
   to: 'now',

+ 3 - 1
public/app/features/explore/Typeahead.tsx

@@ -23,7 +23,9 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
 
   componentDidUpdate(prevProps) {
     if (this.props.isSelected && !prevProps.isSelected) {
-      scrollIntoView(this.el);
+      requestAnimationFrame(() => {
+        scrollIntoView(this.el);
+      });
     }
   }
 

+ 3 - 23
public/app/features/explore/Wrapper.tsx

@@ -3,31 +3,11 @@ import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 
 import { updateLocation } from 'app/core/actions';
+import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
 import { StoreState } from 'app/types';
-import { ExploreUrlState } from 'app/types/explore';
+import { ExploreState } from 'app/types/explore';
 
-import Explore, { ExploreState } from './Explore';
-import { DEFAULT_RANGE } from './TimePicker';
-
-export function parseUrlState(initial: string | undefined): ExploreUrlState {
-  if (initial) {
-    try {
-      return JSON.parse(decodeURI(initial));
-    } catch (e) {
-      console.error(e);
-    }
-  }
-  return { datasource: null, queries: [], range: DEFAULT_RANGE };
-}
-
-export function serializeStateToUrlParam(state: ExploreState): string {
-  const urlState: ExploreUrlState = {
-    datasource: state.datasourceName,
-    queries: state.queries.map(q => ({ query: q.query })),
-    range: state.range,
-  };
-  return JSON.stringify(urlState);
-}
+import Explore from './Explore';
 
 interface WrapperProps {
   backendSrv?: any;

+ 6 - 4
public/app/features/explore/utils/query.ts

@@ -1,14 +1,16 @@
-export function generateQueryKey(index = 0) {
+import { Query } from 'app/types/explore';
+
+export function generateQueryKey(index = 0): string {
   return `Q-${Date.now()}-${Math.random()}-${index}`;
 }
 
-export function ensureQueries(queries?) {
+export function ensureQueries(queries?: Query[]): Query[] {
   if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') {
     return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query }));
   }
   return [{ key: generateQueryKey(), query: '' }];
 }
 
-export function hasQuery(queries) {
-  return queries.some(q => q.query);
+export function hasQuery(queries: string[]): boolean {
+  return queries.some(q => Boolean(q));
 }

+ 1 - 1
public/app/features/folders/state/actions.ts

@@ -110,7 +110,7 @@ export function updateFolderPermission(itemToUpdate: DashboardAcl, level: Permis
         continue;
       }
 
-      const updated = toUpdateItem(itemToUpdate);
+      const updated = toUpdateItem(item);
 
       // if this is the item we want to update, update it's permisssion
       if (itemToUpdate === item) {

+ 0 - 2
public/app/features/org/all.ts

@@ -1,6 +1,4 @@
-import './org_users_ctrl';
 import './profile_ctrl';
-import './org_users_ctrl';
 import './select_org_ctrl';
 import './change_password_ctrl';
 import './new_org_ctrl';

+ 0 - 87
public/app/features/org/org_users_ctrl.ts

@@ -1,87 +0,0 @@
-import config from 'app/core/config';
-import coreModule from 'app/core/core_module';
-import Remarkable from 'remarkable';
-import _ from 'lodash';
-
-export class OrgUsersCtrl {
-  unfiltered: any;
-  users: any;
-  pendingInvites: any;
-  editor: any;
-  navModel: any;
-  externalUserMngLinkUrl: string;
-  externalUserMngLinkName: string;
-  externalUserMngInfo: string;
-  canInvite: boolean;
-  searchQuery: string;
-  showInvites: boolean;
-
-  /** @ngInject */
-  constructor(private $scope, private backendSrv, navModelSrv, $sce) {
-    this.navModel = navModelSrv.getNav('cfg', 'users', 0);
-
-    this.get();
-    this.externalUserMngLinkUrl = config.externalUserMngLinkUrl;
-    this.externalUserMngLinkName = config.externalUserMngLinkName;
-    this.canInvite = !config.disableLoginForm && !config.externalUserMngLinkName;
-
-    // render external user management info markdown
-    if (config.externalUserMngInfo) {
-      this.externalUserMngInfo = new Remarkable({
-        linkTarget: '__blank',
-      }).render(config.externalUserMngInfo);
-    }
-  }
-
-  get() {
-    this.backendSrv.get('/api/org/users').then(users => {
-      this.users = users;
-      this.unfiltered = users;
-    });
-    this.backendSrv.get('/api/org/invites').then(pendingInvites => {
-      this.pendingInvites = pendingInvites;
-    });
-  }
-
-  onQueryUpdated() {
-    const regex = new RegExp(this.searchQuery, 'ig');
-    this.users = _.filter(this.unfiltered, item => {
-      return regex.test(item.email) || regex.test(item.login);
-    });
-  }
-
-  updateOrgUser(user) {
-    this.backendSrv.patch('/api/org/users/' + user.userId, user);
-  }
-
-  removeUser(user) {
-    this.$scope.appEvent('confirm-modal', {
-      title: 'Delete',
-      text: 'Are you sure you want to delete user ' + user.login + '?',
-      yesText: 'Delete',
-      icon: 'fa-warning',
-      onConfirm: () => {
-        this.removeUserConfirmed(user);
-      },
-    });
-  }
-
-  removeUserConfirmed(user) {
-    this.backendSrv.delete('/api/org/users/' + user.userId).then(this.get.bind(this));
-  }
-
-  revokeInvite(invite, evt) {
-    evt.stopPropagation();
-    this.backendSrv.patch('/api/org/invites/' + invite.code + '/revoke').then(this.get.bind(this));
-  }
-
-  copyInviteToClipboard(evt) {
-    evt.stopPropagation();
-  }
-
-  getInviteUrl(invite) {
-    return invite.url;
-  }
-}
-
-coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);

+ 0 - 105
public/app/features/org/partials/orgUsers.html

@@ -1,105 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <div class="page-action-bar">
-    <label class="gf-form gf-form--has-input-icon">
-      <input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by username or email" />
-      <i class="gf-form-input-icon fa fa-search"></i>
-    </label>
-
-    <div ng-if="ctrl.pendingInvites.length" style="margin-left: 1rem">
-      <button class="btn toggle-btn active" ng-if="!ctrl.showInvites">
-        Users
-      </button><button class="btn toggle-btn" ng-if="!ctrl.showInvites" ng-click="ctrl.showInvites = true">
-        Pending Invites ({{ctrl.pendingInvites.length}})
-      </button>
-      <button class="btn toggle-btn" ng-if="ctrl.showInvites" ng-click="ctrl.showInvites = false">
-        Users
-      </button><button class="btn toggle-btn active" ng-if="ctrl.showInvites">
-        Pending Invites ({{ctrl.pendingInvites.length}})
-      </button>
-    </div>
-
-    <div class="page-action-bar__spacer"></div>
-
-    <a class="btn btn-success" href="org/users/invite" ng-show="ctrl.canInvite">
-      <i class="fa fa-plus"></i>
-      <span>Invite</span>
-    </a>
-
-    <a class="btn btn-success" ng-href="{{ctrl.externalUserMngLinkUrl}}" target="_blank" ng-if="ctrl.externalUserMngLinkUrl">
-      <i class="fa fa-external-link-square"></i>
-      {{ctrl.externalUserMngLinkName}}
-    </a>
-  </div>
-
-  <div class="grafana-info-box" ng-if="ctrl.externalUserMngInfo">
-    <span ng-bind-html="ctrl.externalUserMngInfo"></span>
-  </div>
-
-  <div ng-hide="ctrl.showInvites">
-    <table class="filter-table form-inline">
-      <thead>
-        <tr>
-          <th></th>
-          <th>Login</th>
-          <th>Email</th>
-          <th>
-            Seen
-            <tip>Time since user was seen using Grafana</tip>
-          </th>
-          <th>Role</th>
-          <th style="width: 34px;"></th>
-        </tr>
-      </thead>
-      <tr ng-repeat="user in ctrl.users">
-        <td class="width-4 text-center">
-          <img class="filter-table__avatar" ng-src="{{user.avatarUrl}}"></img>
-        </td>
-        <td>{{user.login}}</td>
-        <td><span class="ellipsis">{{user.email}}</span></td>
-        <td>{{user.lastSeenAtAge}}</td>
-        <td>
-          <div class="gf-form-select-wrapper width-12">
-            <select type="text" ng-model="user.role" class="gf-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Admin']" ng-change="ctrl.updateOrgUser(user)">
-            </select>
-          </div>
-        </td>
-        <td>
-          <a ng-click="ctrl.removeUser(user)" class="btn btn-danger btn-mini">
-            <i class="fa fa-remove"></i>
-          </a>
-        </td>
-      </tr>
-    </table>
-  </div>
-
-  <div ng-if="ctrl.showInvites">
-    <table class="filter-table form-inline">
-      <thead>
-        <tr>
-          <th>Email</th>
-          <th>Name</th>
-          <th></th>
-          <th style="width: 34px;"></th>
-        </tr>
-      </thead>
-      <tr ng-repeat="invite in ctrl.pendingInvites">
-        <td>{{invite.email}}</td>
-        <td>{{invite.name}}</td>
-        <td class="text-right">
-          <button class="btn btn-inverse btn-mini" clipboard-button="ctrl.getInviteUrl(invite)" ng-click="ctrl.copyInviteToClipboard($event)">
-            <i class="fa fa-clipboard"></i> Copy Invite
-          </button>
-          &nbsp;
-        </td>
-        <td>
-          <button class="btn btn-danger btn-mini" ng-click="ctrl.revokeInvite(invite, $event)">
-            <i class="fa fa-remove"></i>
-          </button>
-        </td>
-      </tr>
-    </table>
-  </div>
-</div>
-

+ 12 - 10
public/app/features/panel/metrics_panel_ctrl.ts

@@ -6,7 +6,7 @@ import kbn from 'app/core/utils/kbn';
 import { PanelCtrl } from 'app/features/panel/panel_ctrl';
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import * as dateMath from 'app/core/utils/datemath';
-import { renderUrl } from 'app/core/utils/url';
+import { getExploreUrl } from 'app/core/utils/explore';
 
 import { metricsTabDirective } from './metrics_tab';
 
@@ -314,7 +314,12 @@ class MetricsPanelCtrl extends PanelCtrl {
 
   getAdditionalMenuItems() {
     const items = [];
-    if (config.exploreEnabled && this.contextSrv.isEditor && this.datasource && this.datasource.supportsExplore) {
+    if (
+      config.exploreEnabled &&
+      this.contextSrv.isEditor &&
+      this.datasource &&
+      (this.datasource.meta.explore || this.datasource.meta.id === 'mixed')
+    ) {
       items.push({
         text: 'Explore',
         click: 'ctrl.explore();',
@@ -325,14 +330,11 @@ class MetricsPanelCtrl extends PanelCtrl {
     return items;
   }
 
-  explore() {
-    const range = this.timeSrv.timeRangeForUrl();
-    const state = {
-      ...this.datasource.getExploreState(this.panel),
-      range,
-    };
-    const exploreState = JSON.stringify(state);
-    this.$location.url(renderUrl('/explore', { state: exploreState }));
+  async explore() {
+    const url = await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv);
+    if (url) {
+      this.$timeout(() => this.$location.url(url));
+    }
   }
 
   addQuery(target) {

+ 1 - 1
public/app/features/panel/specs/metrics_panel_ctrl.test.ts

@@ -38,7 +38,7 @@ describe('MetricsPanelCtrl', () => {
     describe('and has datasource set that supports explore and user has powers', () => {
       beforeEach(() => {
         ctrl.contextSrv = { isEditor: true };
-        ctrl.datasource = { supportsExplore: true };
+        ctrl.datasource = { meta: { explore: true } };
         additionalItems = ctrl.getAdditionalMenuItems();
       });
 

+ 0 - 31
public/app/features/plugins/PluginActionBar.test.tsx

@@ -1,31 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { PluginActionBar, Props } from './PluginActionBar';
-import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
-
-const setup = (propOverrides?: object) => {
-  const props: Props = {
-    searchQuery: '',
-    layoutMode: LayoutModes.Grid,
-    setLayoutMode: jest.fn(),
-    setPluginsSearchQuery: jest.fn(),
-  };
-
-  Object.assign(props, propOverrides);
-
-  const wrapper = shallow(<PluginActionBar {...props} />);
-  const instance = wrapper.instance() as PluginActionBar;
-
-  return {
-    wrapper,
-    instance,
-  };
-};
-
-describe('Render', () => {
-  it('should render component', () => {
-    const { wrapper } = setup();
-
-    expect(wrapper).toMatchSnapshot();
-  });
-});

+ 0 - 62
public/app/features/plugins/PluginActionBar.tsx

@@ -1,62 +0,0 @@
-import React, { PureComponent } from 'react';
-import { connect } from 'react-redux';
-import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
-import { setLayoutMode, setPluginsSearchQuery } from './state/actions';
-import { getPluginsSearchQuery, getLayoutMode } from './state/selectors';
-
-export interface Props {
-  searchQuery: string;
-  layoutMode: LayoutMode;
-  setLayoutMode: typeof setLayoutMode;
-  setPluginsSearchQuery: typeof setPluginsSearchQuery;
-}
-
-export class PluginActionBar extends PureComponent<Props> {
-  onSearchQueryChange = event => {
-    this.props.setPluginsSearchQuery(event.target.value);
-  };
-
-  render() {
-    const { searchQuery, layoutMode, setLayoutMode } = this.props;
-
-    return (
-      <div className="page-action-bar">
-        <div className="gf-form gf-form--grow">
-          <label className="gf-form--has-input-icon">
-            <input
-              type="text"
-              className="gf-form-input width-20"
-              value={searchQuery}
-              onChange={this.onSearchQueryChange}
-              placeholder="Filter by name or type"
-            />
-            <i className="gf-form-input-icon fa fa-search" />
-          </label>
-          <LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
-        </div>
-        <div className="page-action-bar__spacer" />
-        <a
-          className="btn btn-success"
-          href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
-          target="_blank"
-        >
-          Find more plugins on Grafana.com
-        </a>
-      </div>
-    );
-  }
-}
-
-function mapStateToProps(state) {
-  return {
-    searchQuery: getPluginsSearchQuery(state.plugins),
-    layoutMode: getLayoutMode(state.plugins),
-  };
-}
-
-const mapDispatchToProps = {
-  setPluginsSearchQuery,
-  setLayoutMode,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(PluginActionBar);

+ 3 - 0
public/app/features/plugins/PluginListPage.test.tsx

@@ -8,6 +8,9 @@ const setup = (propOverrides?: object) => {
   const props: Props = {
     navModel: {} as NavModel,
     plugins: [] as Plugin[],
+    searchQuery: '',
+    setPluginsSearchQuery: jest.fn(),
+    setPluginsLayoutMode: jest.fn(),
     layoutMode: LayoutModes.Grid,
     loadPlugins: jest.fn(),
   };

+ 23 - 7
public/app/features/plugins/PluginListPage.tsx

@@ -1,20 +1,23 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
-import PageHeader from '../../core/components/PageHeader/PageHeader';
-import PluginActionBar from './PluginActionBar';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
 import PluginList from './PluginList';
-import { NavModel, Plugin } from '../../types';
-import { loadPlugins } from './state/actions';
+import { NavModel, Plugin } from 'app/types';
+import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
 import { getNavModel } from '../../core/selectors/navModel';
-import { getLayoutMode, getPlugins } from './state/selectors';
+import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors';
 import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
 
 export interface Props {
   navModel: NavModel;
   plugins: Plugin[];
   layoutMode: LayoutMode;
+  searchQuery: string;
   loadPlugins: typeof loadPlugins;
+  setPluginsLayoutMode: typeof setPluginsLayoutMode;
+  setPluginsSearchQuery: typeof setPluginsSearchQuery;
 }
 
 export class PluginListPage extends PureComponent<Props> {
@@ -27,13 +30,23 @@ export class PluginListPage extends PureComponent<Props> {
   }
 
   render() {
-    const { navModel, plugins, layoutMode } = this.props;
+    const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
 
+    const linkButton = {
+      href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
+      title: 'Find more plugins on Grafana.com',
+    };
     return (
       <div>
         <PageHeader model={navModel} />
         <div className="page-container page-body">
-          <PluginActionBar />
+          <OrgActionBar
+            searchQuery={searchQuery}
+            layoutMode={layoutMode}
+            onSetLayoutMode={mode => setPluginsLayoutMode(mode)}
+            setSearchQuery={query => setPluginsSearchQuery(query)}
+            linkButton={linkButton}
+          />
           {plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
         </div>
       </div>
@@ -46,11 +59,14 @@ function mapStateToProps(state) {
     navModel: getNavModel(state.navIndex, 'plugins'),
     plugins: getPlugins(state.plugins),
     layoutMode: getLayoutMode(state.plugins),
+    searchQuery: getPluginsSearchQuery(state.plugins),
   };
 }
 
 const mapDispatchToProps = {
   loadPlugins,
+  setPluginsLayoutMode,
+  setPluginsSearchQuery,
 };
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));

+ 12 - 1
public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap

@@ -8,7 +8,18 @@ exports[`Render should render component 1`] = `
   <div
     className="page-container page-body"
   >
-    <Connect(PluginActionBar) />
+    <OrgActionBar
+      layoutMode="grid"
+      linkButton={
+        Object {
+          "href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
+          "title": "Find more plugins on Grafana.com",
+        }
+      }
+      onSetLayoutMode={[Function]}
+      searchQuery=""
+      setSearchQuery={[Function]}
+    />
     <PluginList
       layoutMode="grid"
       plugins={Array []}

+ 1 - 1
public/app/features/plugins/state/actions.ts

@@ -24,7 +24,7 @@ export interface SetLayoutModeAction {
   payload: LayoutMode;
 }
 
-export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
+export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
   type: ActionTypes.SetLayoutMode,
   payload: mode,
 });

+ 32 - 0
public/app/features/users/InviteesTable.test.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import InviteesTable, { Props } from './InviteesTable';
+import { Invitee } from 'app/types';
+import { getMockInvitees } from './__mocks__/userMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    invitees: [] as Invitee[],
+    onRevokeInvite: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<InviteesTable {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render invitees', () => {
+    const wrapper = setup({
+      invitees: getMockInvitees(5),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 64 - 0
public/app/features/users/InviteesTable.tsx

@@ -0,0 +1,64 @@
+import React, { createRef, PureComponent } from 'react';
+import { Invitee } from 'app/types';
+
+export interface Props {
+  invitees: Invitee[];
+  onRevokeInvite: (code: string) => void;
+}
+
+export default class InviteesTable extends PureComponent<Props> {
+  private copyUrlRef = createRef<HTMLTextAreaElement>();
+
+  copyToClipboard = () => {
+    const node = this.copyUrlRef.current;
+
+    if (node) {
+      node.select();
+      document.execCommand('copy');
+    }
+  };
+
+  render() {
+    const { invitees, onRevokeInvite } = this.props;
+
+    return (
+      <table className="filter-table form-inline">
+        <thead>
+          <tr>
+            <th>Email</th>
+            <th>Name</th>
+            <th />
+            <th style={{ width: '34px' }} />
+          </tr>
+        </thead>
+        <tbody>
+          {invitees.map((invitee, index) => {
+            return (
+              <tr key={`${invitee.id}-${index}`}>
+                <td>{invitee.email}</td>
+                <td>{invitee.name}</td>
+                <td className="text-right">
+                  <button className="btn btn-inverse btn-mini" onClick={this.copyToClipboard}>
+                    <textarea
+                      readOnly={true}
+                      value={invitee.url}
+                      style={{ position: 'absolute', right: -1000 }}
+                      ref={this.copyUrlRef}
+                    />
+                    <i className="fa fa-clipboard" /> Copy Invite
+                  </button>
+                  &nbsp;
+                </td>
+                <td>
+                  <button className="btn btn-danger btn-mini" onClick={() => onRevokeInvite(invitee.code)}>
+                    <i className="fa fa-remove" />
+                  </button>
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    );
+  }
+}

+ 52 - 0
public/app/features/users/UsersActionBar.test.tsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { UsersActionBar, Props } from './UsersActionBar';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    searchQuery: '',
+    setUsersSearchQuery: jest.fn(),
+    onShowInvites: jest.fn(),
+    pendingInvitesCount: 0,
+    canInvite: false,
+    externalUserMngLinkUrl: '',
+    externalUserMngLinkName: '',
+    showInvites: false,
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<UsersActionBar {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render pending invites button', () => {
+    const wrapper = setup({
+      pendingInvitesCount: 5,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should show invite button', () => {
+    const wrapper = setup({
+      canInvite: true,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should show external user management button', () => {
+    const wrapper = setup({
+      externalUserMngLinkUrl: 'some/url',
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 97 - 0
public/app/features/users/UsersActionBar.tsx

@@ -0,0 +1,97 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames/bind';
+import { setUsersSearchQuery } from './state/actions';
+import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
+
+export interface Props {
+  searchQuery: string;
+  setUsersSearchQuery: typeof setUsersSearchQuery;
+  onShowInvites: () => void;
+  pendingInvitesCount: number;
+  canInvite: boolean;
+  showInvites: boolean;
+  externalUserMngLinkUrl: string;
+  externalUserMngLinkName: string;
+}
+
+export class UsersActionBar extends PureComponent<Props> {
+  render() {
+    const {
+      canInvite,
+      externalUserMngLinkName,
+      externalUserMngLinkUrl,
+      searchQuery,
+      pendingInvitesCount,
+      setUsersSearchQuery,
+      onShowInvites,
+      showInvites,
+    } = this.props;
+
+    const pendingInvitesButtonStyle = classNames({
+      btn: true,
+      'toggle-btn': true,
+      active: showInvites,
+    });
+
+    const usersButtonStyle = classNames({
+      btn: true,
+      'toggle-btn': true,
+      active: !showInvites,
+    });
+
+    return (
+      <div className="page-action-bar">
+        <div className="gf-form gf-form--grow">
+          <label className="gf-form--has-input-icon">
+            <input
+              type="text"
+              className="gf-form-input width-20"
+              value={searchQuery}
+              onChange={event => setUsersSearchQuery(event.target.value)}
+              placeholder="Filter by name or type"
+            />
+            <i className="gf-form-input-icon fa fa-search" />
+          </label>
+          {pendingInvitesCount > 0 && (
+            <div style={{ marginLeft: '1rem' }}>
+              <button className={usersButtonStyle} key="users" onClick={onShowInvites}>
+                Users
+              </button>
+              <button className={pendingInvitesButtonStyle} onClick={onShowInvites} key="pending-invites">
+                Pending Invites ({pendingInvitesCount})
+              </button>
+            </div>
+          )}
+          <div className="page-action-bar__spacer" />
+          {canInvite && (
+            <a className="btn btn-success" href="org/users/invite">
+              <span>Invite</span>
+            </a>
+          )}
+          {externalUserMngLinkUrl && (
+            <a className="btn btn-success" href={externalUserMngLinkUrl} target="_blank">
+              <i className="fa fa-external-link-square" /> {externalUserMngLinkName}
+            </a>
+          )}
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    searchQuery: getUsersSearchQuery(state.users),
+    pendingInvitesCount: getInviteesCount(state.users),
+    externalUserMngLinkName: state.users.externalUserMngLinkName,
+    externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
+    canInvite: state.users.canInvite,
+  };
+}
+
+const mapDispatchToProps = {
+  setUsersSearchQuery,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);

+ 55 - 0
public/app/features/users/UsersListPage.test.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { UsersListPage, Props } from './UsersListPage';
+import { Invitee, NavModel, OrgUser } from 'app/types';
+import { getMockUser } from './__mocks__/userMocks';
+import appEvents from '../../core/app_events';
+
+jest.mock('../../core/app_events', () => ({
+  emit: jest.fn(),
+}));
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    users: [] as OrgUser[],
+    invitees: [] as Invitee[],
+    searchQuery: '',
+    externalUserMngInfo: '',
+    revokeInvite: jest.fn(),
+    loadInvitees: jest.fn(),
+    loadUsers: jest.fn(),
+    updateUser: jest.fn(),
+    removeUser: jest.fn(),
+    setUsersSearchQuery: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<UsersListPage {...props} />);
+  const instance = wrapper.instance() as UsersListPage;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Functions', () => {
+  it('should emit show remove user modal', () => {
+    const { instance } = setup();
+    const mockUser = getMockUser();
+
+    instance.onRemoveUser(mockUser);
+
+    expect(appEvents.emit).toHaveBeenCalled();
+  });
+});

+ 136 - 0
public/app/features/users/UsersListPage.tsx

@@ -0,0 +1,136 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import Remarkable from 'remarkable';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import UsersActionBar from './UsersActionBar';
+import UsersTable from 'app/features/users/UsersTable';
+import InviteesTable from './InviteesTable';
+import { Invitee, NavModel, OrgUser } from 'app/types';
+import appEvents from 'app/core/app_events';
+import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
+
+export interface Props {
+  navModel: NavModel;
+  invitees: Invitee[];
+  users: OrgUser[];
+  searchQuery: string;
+  externalUserMngInfo: string;
+  loadUsers: typeof loadUsers;
+  loadInvitees: typeof loadInvitees;
+  setUsersSearchQuery: typeof setUsersSearchQuery;
+  updateUser: typeof updateUser;
+  removeUser: typeof removeUser;
+  revokeInvite: typeof revokeInvite;
+}
+
+export interface State {
+  showInvites: boolean;
+}
+
+export class UsersListPage extends PureComponent<Props, State> {
+  externalUserMngInfoHtml: string;
+
+  constructor(props) {
+    super(props);
+
+    if (this.props.externalUserMngInfo) {
+      const markdownRenderer = new Remarkable();
+      this.externalUserMngInfoHtml = markdownRenderer.render(this.props.externalUserMngInfo);
+    }
+
+    this.state = {
+      showInvites: false,
+    };
+  }
+
+  componentDidMount() {
+    this.fetchUsers();
+    this.fetchInvitees();
+  }
+
+  async fetchUsers() {
+    return await this.props.loadUsers();
+  }
+
+  async fetchInvitees() {
+    return await this.props.loadInvitees();
+  }
+
+  onRoleChange = (role, user) => {
+    const updatedUser = { ...user, role: role };
+
+    this.props.updateUser(updatedUser);
+  };
+
+  onRemoveUser = user => {
+    appEvents.emit('confirm-modal', {
+      title: 'Delete',
+      text: 'Are you sure you want to delete user ' + user.login + '?',
+      yesText: 'Delete',
+      icon: 'fa-warning',
+      onConfirm: () => {
+        this.props.removeUser(user.userId);
+      },
+    });
+  };
+
+  onRevokeInvite = code => {
+    this.props.revokeInvite(code);
+  };
+
+  onShowInvites = () => {
+    this.setState(prevState => ({
+      showInvites: !prevState.showInvites,
+    }));
+  };
+
+  render() {
+    const { invitees, navModel, users } = this.props;
+    const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        <div className="page-container page-body">
+          <UsersActionBar onShowInvites={this.onShowInvites} showInvites={this.state.showInvites} />
+          {externalUserMngInfoHtml && (
+            <div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
+          )}
+          {this.state.showInvites ? (
+            <InviteesTable invitees={invitees} onRevokeInvite={code => this.onRevokeInvite(code)} />
+          ) : (
+            <UsersTable
+              users={users}
+              onRoleChange={(role, user) => this.onRoleChange(role, user)}
+              onRemoveUser={user => this.onRemoveUser(user)}
+            />
+          )}
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    navModel: getNavModel(state.navIndex, 'users'),
+    users: getUsers(state.users),
+    searchQuery: getUsersSearchQuery(state.users),
+    invitees: getInvitees(state.users),
+    externalUserMngInfo: state.users.externalUserMngInfo,
+  };
+}
+
+const mapDispatchToProps = {
+  loadUsers,
+  loadInvitees,
+  setUsersSearchQuery,
+  updateUser,
+  removeUser,
+  revokeInvite,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UsersListPage));

+ 33 - 0
public/app/features/users/UsersTable.test.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import UsersTable, { Props } from './UsersTable';
+import { OrgUser } from 'app/types';
+import { getMockUsers } from './__mocks__/userMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    users: [] as OrgUser[],
+    onRoleChange: jest.fn(),
+    onRemoveUser: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<UsersTable {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render users table', () => {
+    const wrapper = setup({
+      users: getMockUsers(5),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 67 - 0
public/app/features/users/UsersTable.tsx

@@ -0,0 +1,67 @@
+import React, { SFC } from 'react';
+import { OrgUser } from 'app/types';
+
+export interface Props {
+  users: OrgUser[];
+  onRoleChange: (role: string, user: OrgUser) => void;
+  onRemoveUser: (user: OrgUser) => void;
+}
+
+const UsersTable: SFC<Props> = props => {
+  const { users, onRoleChange, onRemoveUser } = props;
+
+  return (
+    <table className="filter-table form-inline">
+      <thead>
+        <tr>
+          <th />
+          <th>Login</th>
+          <th>Email</th>
+          <th>Seen</th>
+          <th>Role</th>
+          <th style={{ width: '34px' }} />
+        </tr>
+      </thead>
+      <tbody>
+        {users.map((user, index) => {
+          return (
+            <tr key={`${user.userId}-${index}`}>
+              <td className="width-4 text-center">
+                <img className="filter-table__avatar" src={user.avatarUrl} />
+              </td>
+              <td>{user.login}</td>
+              <td>
+                <span className="ellipsis">{user.email}</span>
+              </td>
+              <td>{user.lastSeenAtAge}</td>
+              <td>
+                <div className="gf-form-select-wrapper width-12">
+                  <select
+                    value={user.role}
+                    className="gf-form-input"
+                    onChange={event => onRoleChange(event.target.value, user)}
+                  >
+                    {['Viewer', 'Editor', 'Admin'].map((option, index) => {
+                      return (
+                        <option value={option} key={`${option}-${index}`}>
+                          {option}
+                        </option>
+                      );
+                    })}
+                  </select>
+                </div>
+              </td>
+              <td>
+                <div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-mini">
+                  <i className="fa fa-remove" />
+                </div>
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+};
+
+export default UsersTable;

+ 56 - 0
public/app/features/users/__mocks__/userMocks.ts

@@ -0,0 +1,56 @@
+export const getMockUsers = (amount: number) => {
+  const users = [];
+
+  for (let i = 0; i <= amount; i++) {
+    users.push({
+      avatarUrl: 'url/to/avatar',
+      email: `user-${i}@test.com`,
+      lastSeenAt: '2018-10-01',
+      lastSeenAtAge: '',
+      login: `user-${i}`,
+      orgId: 1,
+      role: 'Admin',
+      userId: i,
+    });
+  }
+
+  return users;
+};
+
+export const getMockUser = () => {
+  return {
+    avatarUrl: 'url/to/avatar',
+    email: `user@test.com`,
+    lastSeenAt: '2018-10-01',
+    lastSeenAtAge: '',
+    login: `user`,
+    orgId: 1,
+    role: 'Admin',
+    userId: 2,
+  };
+};
+
+export const getMockInvitees = (amount: number) => {
+  const invitees = [];
+
+  for (let i = 0; i <= amount; i++) {
+    invitees.push({
+      code: `asdfasdfsadf-${i}`,
+      createdOn: '2018-10-02',
+      email: `invitee-${i}@test.com`,
+      emailSent: true,
+      emailSentOn: '2018-10-02',
+      id: i,
+      invitedByEmail: 'admin@grafana.com',
+      invitedByLogin: 'admin',
+      invitedByName: 'admin',
+      name: `invitee-${i}`,
+      orgId: 1,
+      role: 'viewer',
+      status: 'not accepted',
+      url: `localhost/invite/$${i}`,
+    });
+  }
+
+  return invitees;
+};

+ 318 - 0
public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap

@@ -0,0 +1,318 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th>
+        Email
+      </th>
+      <th>
+        Name
+      </th>
+      <th />
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody />
+</table>
+`;
+
+exports[`Render should render invitees 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th>
+        Email
+      </th>
+      <th>
+        Name
+      </th>
+      <th />
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody>
+    <tr
+      key="0-0"
+    >
+      <td>
+        invitee-0@test.com
+      </td>
+      <td>
+        invitee-0
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$0"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="1-1"
+    >
+      <td>
+        invitee-1@test.com
+      </td>
+      <td>
+        invitee-1
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$1"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="2-2"
+    >
+      <td>
+        invitee-2@test.com
+      </td>
+      <td>
+        invitee-2
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$2"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="3-3"
+    >
+      <td>
+        invitee-3@test.com
+      </td>
+      <td>
+        invitee-3
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$3"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="4-4"
+    >
+      <td>
+        invitee-4@test.com
+      </td>
+      <td>
+        invitee-4
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$4"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="5-5"
+    >
+      <td>
+        invitee-5@test.com
+      </td>
+      <td>
+        invitee-5
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$5"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+  </tbody>
+</table>
+`;

+ 155 - 0
public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap

@@ -0,0 +1,155 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      className="page-action-bar__spacer"
+    />
+  </div>
+</div>
+`;
+
+exports[`Render should render pending invites button 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      style={
+        Object {
+          "marginLeft": "1rem",
+        }
+      }
+    >
+      <button
+        className="btn toggle-btn active"
+        key="users"
+        onClick={[MockFunction]}
+      >
+        Users
+      </button>
+      <button
+        className="btn toggle-btn"
+        key="pending-invites"
+        onClick={[MockFunction]}
+      >
+        Pending Invites (
+        5
+        )
+      </button>
+    </div>
+    <div
+      className="page-action-bar__spacer"
+    />
+  </div>
+</div>
+`;
+
+exports[`Render should show external user management button 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <a
+      className="btn btn-success"
+      href="some/url"
+      target="_blank"
+    >
+      <i
+        className="fa fa-external-link-square"
+      />
+       
+    </a>
+  </div>
+</div>
+`;
+
+exports[`Render should show invite button 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <a
+      className="btn btn-success"
+      href="org/users/invite"
+    >
+      <span>
+        Invite
+      </span>
+    </a>
+  </div>
+</div>
+`;

+ 22 - 0
public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap

@@ -0,0 +1,22 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(UsersActionBar)
+      onShowInvites={[Function]}
+      showInvites={false}
+    />
+    <UsersTable
+      onRemoveUser={[Function]}
+      onRoleChange={[Function]}
+      users={Array []}
+    />
+  </div>
+</div>
+`;

+ 444 - 0
public/app/features/users/__snapshots__/UsersTable.test.tsx.snap

@@ -0,0 +1,444 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th />
+      <th>
+        Login
+      </th>
+      <th>
+        Email
+      </th>
+      <th>
+        Seen
+      </th>
+      <th>
+        Role
+      </th>
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody />
+</table>
+`;
+
+exports[`Render should render users table 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th />
+      <th>
+        Login
+      </th>
+      <th>
+        Email
+      </th>
+      <th>
+        Seen
+      </th>
+      <th>
+        Role
+      </th>
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody>
+    <tr
+      key="0-0"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-0
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-0@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="1-1"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-1
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-1@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="2-2"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-2
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-2@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="3-3"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-3
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-3@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="4-4"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-4
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-4@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="5-5"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-5
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-5@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+  </tbody>
+</table>
+`;

+ 79 - 0
public/app/features/users/state/actions.ts

@@ -0,0 +1,79 @@
+import { ThunkAction } from 'redux-thunk';
+import { StoreState } from '../../../types';
+import { getBackendSrv } from '../../../core/services/backend_srv';
+import { Invitee, OrgUser } from 'app/types';
+
+export enum ActionTypes {
+  LoadUsers = 'LOAD_USERS',
+  LoadInvitees = 'LOAD_INVITEES',
+  SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
+}
+
+export interface LoadUsersAction {
+  type: ActionTypes.LoadUsers;
+  payload: OrgUser[];
+}
+
+export interface LoadInviteesAction {
+  type: ActionTypes.LoadInvitees;
+  payload: Invitee[];
+}
+
+export interface SetUsersSearchQueryAction {
+  type: ActionTypes.SetUsersSearchQuery;
+  payload: string;
+}
+
+const usersLoaded = (users: OrgUser[]): LoadUsersAction => ({
+  type: ActionTypes.LoadUsers,
+  payload: users,
+});
+
+const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({
+  type: ActionTypes.LoadInvitees,
+  payload: invitees,
+});
+
+export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({
+  type: ActionTypes.SetUsersSearchQuery,
+  payload: query,
+});
+
+export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
+
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
+
+export function loadUsers(): ThunkResult<void> {
+  return async dispatch => {
+    const users = await getBackendSrv().get('/api/org/users');
+    dispatch(usersLoaded(users));
+  };
+}
+
+export function loadInvitees(): ThunkResult<void> {
+  return async dispatch => {
+    const invitees = await getBackendSrv().get('/api/org/invites');
+    dispatch(inviteesLoaded(invitees));
+  };
+}
+
+export function updateUser(user: OrgUser): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role });
+    dispatch(loadUsers());
+  };
+}
+
+export function removeUser(userId: number): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().delete(`/api/org/users/${userId}`);
+    dispatch(loadUsers());
+  };
+}
+
+export function revokeInvite(code: string): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
+    dispatch(loadInvitees());
+  };
+}

+ 32 - 0
public/app/features/users/state/reducers.ts

@@ -0,0 +1,32 @@
+import { Invitee, OrgUser, UsersState } from 'app/types';
+import { Action, ActionTypes } from './actions';
+import config from '../../../core/config';
+
+export const initialState: UsersState = {
+  invitees: [] as Invitee[],
+  users: [] as OrgUser[],
+  searchQuery: '',
+  canInvite: !config.disableLoginForm && !config.externalUserMngLinkName,
+  externalUserMngInfo: config.externalUserMngInfo,
+  externalUserMngLinkName: config.externalUserMngLinkName,
+  externalUserMngLinkUrl: config.externalUserMngLinkUrl,
+};
+
+export const usersReducer = (state = initialState, action: Action): UsersState => {
+  switch (action.type) {
+    case ActionTypes.LoadUsers:
+      return { ...state, users: action.payload };
+
+    case ActionTypes.LoadInvitees:
+      return { ...state, invitees: action.payload };
+
+    case ActionTypes.SetUsersSearchQuery:
+      return { ...state, searchQuery: action.payload };
+  }
+
+  return state;
+};
+
+export default {
+  users: usersReducer,
+};

+ 18 - 0
public/app/features/users/state/selectors.ts

@@ -0,0 +1,18 @@
+export const getUsers = state => {
+  const regex = new RegExp(state.searchQuery, 'i');
+
+  return state.users.filter(user => {
+    return regex.test(user.login) || regex.test(user.email);
+  });
+};
+
+export const getInvitees = state => {
+  const regex = new RegExp(state.searchQuery, 'i');
+
+  return state.invitees.filter(invitee => {
+    return regex.test(invitee.name) || regex.test(invitee.email);
+  });
+};
+
+export const getInviteesCount = state => state.invitees.length;
+export const getUsersSearchQuery = state => state.searchQuery;

+ 0 - 2
public/app/plugins/datasource/cloudwatch/datasource.ts

@@ -8,7 +8,6 @@ import * as templatingVariable from 'app/features/templating/variable';
 export default class CloudWatchDatasource {
   type: any;
   name: any;
-  supportMetrics: any;
   proxyUrl: any;
   defaultRegion: any;
   instanceSettings: any;
@@ -17,7 +16,6 @@ export default class CloudWatchDatasource {
   constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
     this.type = 'cloudwatch';
     this.name = instanceSettings.name;
-    this.supportMetrics = true;
     this.proxyUrl = instanceSettings.url;
     this.defaultRegion = instanceSettings.jsonData.defaultRegion;
     this.instanceSettings = instanceSettings;

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio