Browse Source

Merge branch 'master' into feature/add_es_alerting

Marcus Efraimsson 7 years ago
parent
commit
28f0acd854
70 changed files with 2735 additions and 485 deletions
  1. 51 0
      .circleci/config.yml
  2. 2 0
      .gitignore
  3. 7 0
      CHANGELOG.md
  4. 11 0
      devenv/README.md
  5. 9 0
      devenv/dashboards/bulk-testing/bulk-dashboards.yaml
  6. 1140 0
      devenv/dashboards/bulk-testing/bulkdash.jsonnet
  7. 73 0
      devenv/datasources/default/default.yaml
  8. 61 0
      devenv/setup.sh
  9. 1 1
      docker/blocks/mysql/docker-compose.yaml
  10. 2 2
      docker/blocks/mysql_tests/Dockerfile
  11. 2 2
      docker/blocks/postgres/docker-compose.yaml
  12. 2 2
      docker/blocks/postgres_tests/Dockerfile
  13. 1 1
      docker/blocks/postgres_tests/setup.sql
  14. 15 10
      pkg/api/index.go
  15. 7 1
      pkg/services/provisioning/dashboards/file_reader.go
  16. 50 28
      pkg/services/provisioning/dashboards/file_reader_test.go
  17. 6 6
      pkg/services/provisioning/datasources/config_reader_test.go
  18. 0 0
      pkg/services/provisioning/datasources/testdata/all-properties/all-properties.yaml
  19. 0 0
      pkg/services/provisioning/datasources/testdata/all-properties/not.yaml.txt
  20. 0 0
      pkg/services/provisioning/datasources/testdata/all-properties/sample.yaml
  21. 0 0
      pkg/services/provisioning/datasources/testdata/all-properties/second.yaml
  22. 0 0
      pkg/services/provisioning/datasources/testdata/broken-yaml/broken.yaml
  23. 0 0
      pkg/services/provisioning/datasources/testdata/broken-yaml/commented.yaml
  24. 0 0
      pkg/services/provisioning/datasources/testdata/double-default/default-1.yaml
  25. 0 0
      pkg/services/provisioning/datasources/testdata/double-default/default-2.yaml
  26. 0 0
      pkg/services/provisioning/datasources/testdata/insert-two-delete-two/one-datasources.yaml
  27. 0 0
      pkg/services/provisioning/datasources/testdata/insert-two-delete-two/two-datasources.yml
  28. 0 0
      pkg/services/provisioning/datasources/testdata/two-datasources/two-datasources.yaml
  29. 0 0
      pkg/services/provisioning/datasources/testdata/version-0/version-0.yaml
  30. 0 0
      pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git
  31. 8 0
      pkg/tsdb/influxdb/query_part_test.go
  32. 3 3
      pkg/tsdb/mysql/mysql_test.go
  33. 1 1
      public/app/containers/Explore/ElapsedTime.tsx
  34. 135 76
      public/app/containers/Explore/Explore.tsx
  35. 23 10
      public/app/containers/Explore/Graph.tsx
  36. 1 1
      public/app/containers/Explore/QueryField.tsx
  37. 3 2
      public/app/containers/Explore/QueryRows.tsx
  38. 74 0
      public/app/containers/Explore/TimePicker.jest.tsx
  39. 245 0
      public/app/containers/Explore/TimePicker.tsx
  40. 33 0
      public/app/containers/Explore/Wrapper.tsx
  41. 2 7
      public/app/containers/Explore/utils/query.ts
  42. 1 1
      public/app/core/components/manage_dashboards/manage_dashboards.html
  43. 6 0
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  44. 3 3
      public/app/core/components/search/search.html
  45. 2 0
      public/app/core/components/search/search.ts
  46. 7 2
      public/app/core/services/keybindingSrv.ts
  47. 31 0
      public/app/features/dashboard/dashboard_import_ctrl.ts
  48. 25 1
      public/app/features/dashboard/dashboard_model.ts
  49. 30 2
      public/app/features/dashboard/partials/dashboard_import.html
  50. 59 6
      public/app/features/dashboard/save_modal.ts
  51. 1 0
      public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts
  52. 59 0
      public/app/features/dashboard/specs/dashboard_model.jest.ts
  53. 90 0
      public/app/features/dashboard/specs/save_modal.jest.ts
  54. 6 1
      public/app/features/panel/metrics_panel_ctrl.ts
  55. 9 11
      public/app/features/plugins/plugin_loader.ts
  56. 23 0
      public/app/plugins/datasource/influxdb/query_part.ts
  57. 144 0
      public/app/plugins/datasource/influxdb/specs/query_part.jest.ts
  58. 11 10
      public/app/plugins/datasource/prometheus/datasource.ts
  59. 8 3
      public/app/plugins/datasource/prometheus/result_transformer.ts
  60. 24 1
      public/app/plugins/datasource/prometheus/specs/datasource.jest.ts
  61. 8 31
      public/app/plugins/datasource/prometheus/specs/datasource_specs.ts
  62. 1 1
      public/app/plugins/panel/singlestat/editor.html
  63. 2 0
      public/app/plugins/panel/singlestat/module.ts
  64. 1 2
      public/app/routes/routes.ts
  65. 12 0
      public/sass/_variables.dark.scss
  66. 12 0
      public/sass/_variables.light.scss
  67. 14 0
      public/sass/components/_dashboard_grid.scss
  68. 2 1
      public/sass/components/_view_states.scss
  69. 163 244
      public/sass/pages/_explore.scss
  70. 13 12
      public/vendor/plugin-css/css.js

+ 51 - 0
.circleci/config.yml

@@ -12,6 +12,45 @@ aliases:
 version: 2
 
 jobs:
+  mysql-integration-test:
+    docker:
+      - image: circleci/golang:1.10
+      - image: circleci/mysql:5.6-ram
+        environment:
+          MYSQL_ROOT_PASSWORD: rootpass
+          MYSQL_DATABASE: grafana_tests
+          MYSQL_USER: grafana
+          MYSQL_PASSWORD: password
+    working_directory: /go/src/github.com/grafana/grafana
+    steps:
+        - checkout
+        - run: sudo apt update
+        - run: sudo apt install -y mysql-client
+        - run: dockerize -wait tcp://127.0.0.1:3306 -timeout 120s
+        - run: cat docker/blocks/mysql_tests/setup.sql | mysql -h 127.0.0.1 -P 3306 -u root -prootpass
+        - run:
+            name: mysql integration tests
+            command: 'GRAFANA_TEST_DB=mysql go test ./pkg/services/sqlstore/... ./pkg/tsdb/mysql/... '
+
+  postgres-integration-test:
+    docker:
+      - image: circleci/golang:1.10
+      - image: circleci/postgres:9.3-ram
+        environment:
+          POSTGRES_USER: grafanatest
+          POSTGRES_PASSWORD: grafanatest
+          POSTGRES_DB: grafanatest
+    working_directory: /go/src/github.com/grafana/grafana
+    steps:
+        - checkout
+        - run: sudo apt update
+        - run: sudo apt install -y postgresql-client
+        - run: dockerize -wait tcp://127.0.0.1:5432 -timeout 120s
+        - run: 'PGPASSWORD=grafanatest psql -p 5432 -h 127.0.0.1 -U grafanatest -d grafanatest -f docker/blocks/postgres_tests/setup.sql'
+        - run:
+            name: postgres integration tests
+            command: 'GRAFANA_TEST_DB=postgres go test ./pkg/services/sqlstore/... ./pkg/tsdb/postgres/...'
+
   codespell:
     docker:
       - image: circleci/python
@@ -188,6 +227,10 @@ workflows:
           filters: *filter-not-release
       - test-backend:
           filters: *filter-not-release
+      - mysql-integration-test:
+          filters: *filter-not-release
+      - postgres-integration-test:
+          filters: *filter-not-release
       - deploy-master:
           requires:
             - build-all
@@ -195,6 +238,8 @@ workflows:
             - test-frontend
             - codespell
             - gometalinter
+            - mysql-integration-test
+            - postgres-integration-test
           filters:
             branches:
               only: master
@@ -210,6 +255,10 @@ workflows:
           filters: *filter-only-release
       - test-backend:
           filters: *filter-only-release
+      - mysql-integration-test:
+          filters: *filter-only-release
+      - postgres-integration-test:
+          filters: *filter-only-release
       - deploy-release:
           requires:
             - build-all
@@ -217,4 +266,6 @@ workflows:
             - test-frontend
             - codespell
             - gometalinter
+            - mysql-integration-test
+            - postgres-integration-test
           filters: *filter-only-release

+ 2 - 0
.gitignore

@@ -66,3 +66,5 @@ debug.test
 /vendor/**/.editorconfig
 /vendor/**/appengine*
 *.orig
+
+/devenv/dashboards/bulk-testing/*.json

+ 7 - 0
CHANGELOG.md

@@ -2,6 +2,7 @@
 
 ### Minor
 
+* **Dashboard**: Modified time range and variables are now not saved by default [#10748](https://github.com/grafana/grafana/issues/10748), [#8805](https://github.com/grafana/grafana/issues/8805)
 * **Graph**: Show invisible highest value bucket in histogram [#11498](https://github.com/grafana/grafana/issues/11498)
 * **Dashboard**: Enable "Save As..." if user has edit permission [#11625](https://github.com/grafana/grafana/issues/11625)
 * **Prometheus**: Table columns order now changes when rearrange queries [#11690](https://github.com/grafana/grafana/issues/11690), thx [@mtanda](https://github.com/mtanda)
@@ -9,6 +10,7 @@
 * **Dashboard**: Fix date selector styling for dark/light theme in time picker control [#11616](https://github.com/grafana/grafana/issues/11616)
 * **Discord**: Alert notification channel type for Discord, [#7964](https://github.com/grafana/grafana/issues/7964) thx [@jereksel](https://github.com/jereksel),
 * **InfluxDB**: Support SELECT queries in templating query, [#5013](https://github.com/grafana/grafana/issues/5013)
+* **InfluxDB**: Support count distinct aggregation [#11645](https://github.com/grafana/grafana/issues/11645), thx [@kichristensen](https://github.com/kichristensen)
 * **Dashboard**: JSON Model under dashboard settings can now be updated & changes saved, [#1429](https://github.com/grafana/grafana/issues/1429), thx [@jereksel](https://github.com/jereksel)
 * **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813)
 * **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338)
@@ -17,6 +19,11 @@
 * **Login**: Use proxy server from environment variable if available [#9703](https://github.com/grafana/grafana/issues/9703), thx [@iyeonok](https://github.com/iyeonok)
 * **Invite users**: Friendlier error message when smtp is not configured [#12087](https://github.com/grafana/grafana/issues/12087), thx [@thurt](https://github.com/thurt)
 * **Graphite**: Don't send distributed tracing headers when using direct/browser access mode [#11494](https://github.com/grafana/grafana/issues/11494)
+* **Sidenav**: Show create dashboard link for viewers if at least editor in one folder [#11858](https://github.com/grafana/grafana/issues/11858)
+* **SQL**: Second epochs are now correctly converted to ms. [#12085](https://github.com/grafana/grafana/pull/12085)
+* **Singlestat**: Fix singlestat threshold tooltip [#11971](https://github.com/grafana/grafana/issues/11971)
+* **Dashboard**: Hide grid controls in fullscreen/low-activity views [#11771](https://github.com/grafana/grafana/issues/11771)
+* **Dashboard**: Validate uid when importing dashboards [#11515](https://github.com/grafana/grafana/issues/11515)
 
 # 5.1.3 (2018-05-16)
 

+ 11 - 0
devenv/README.md

@@ -0,0 +1,11 @@
+This folder contains useful scripts and configuration for...
+
+* Configuring datasources in Grafana
+* Provision example dashboards in Grafana
+* Run preconfiured datasources as docker containers
+
+want to know more? run setup!
+
+```bash
+./setup.sh
+```

+ 9 - 0
devenv/dashboards/bulk-testing/bulk-dashboards.yaml

@@ -0,0 +1,9 @@
+apiVersion: 1
+
+providers:
+ - name: 'Bulk dashboards'
+   folder: 'Bulk dashboards'
+   type: file
+   options:
+     path: devenv/dashboards/bulk-testing
+

+ 1140 - 0
devenv/dashboards/bulk-testing/bulkdash.jsonnet

@@ -0,0 +1,1140 @@
+{
+  "annotations": {
+    "enable": false,
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 1,
+  "links": [],
+  "panels": [
+    {
+      "aliasColors": {
+        "cpu": "#E24D42",
+        "memory": "#1f78c1",
+        "statsd.fakesite.counters.session_start.desktop.count": "#6ED0E0"
+      },
+      "annotate": {
+        "enable": false
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "editable": true,
+      "fill": 3,
+      "grid": {
+        "max": null,
+        "min": 0
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 0
+      },
+      "id": 4,
+      "interactive": true,
+      "legend": {
+        "avg": false,
+        "current": true,
+        "max": false,
+        "min": true,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "legend_counts": true,
+      "lines": true,
+      "linewidth": 2,
+      "nullPointMode": "connected",
+      "options": false,
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "resolution": 100,
+      "scale": 1,
+      "seriesOverrides": [
+        {
+          "alias": "cpu",
+          "fill": 0,
+          "lines": true,
+          "yaxis": 2,
+          "zindex": 2
+        },
+        {
+          "alias": "memory",
+          "pointradius": 2,
+          "points": true
+        }
+      ],
+      "spaceLength": 10,
+      "spyable": true,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "hide": false,
+          "refId": "A",
+          "target": "alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), 'cpu')"
+        },
+        {
+          "refId": "B",
+          "target": "alias(statsd.fakesite.counters.session_start.desktop.count, 'memory')"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "timezone": "browser",
+      "title": "Memory / CPU",
+      "tooltip": {
+        "msResolution": false,
+        "query_as_alias": true,
+        "shared": false,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "percent",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      },
+      "zerofill": true
+    },
+    {
+      "aliasColors": {
+        "logins": "#5195ce",
+        "logins (-1 day)": "#447EBC",
+        "logins (-1 hour)": "#705da0"
+      },
+      "annotate": {
+        "enable": false
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "editable": true,
+      "fill": 1,
+      "grid": {
+        "max": null,
+        "min": 0
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 0
+      },
+      "id": 3,
+      "interactive": true,
+      "legend": {
+        "alignAsTable": false,
+        "avg": false,
+        "current": true,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "legend_counts": true,
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "connected",
+      "options": false,
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "resolution": 100,
+      "scale": 1,
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "spyable": true,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "target": "alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), 2), 'logins')"
+        },
+        {
+          "refId": "B",
+          "target": "alias(movingAverage(timeShift(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), '1h'), 2), 'logins (-1 hour)')"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": "1h",
+      "timezone": "browser",
+      "title": "logins",
+      "tooltip": {
+        "msResolution": false,
+        "query_as_alias": true,
+        "shared": false,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      },
+      "zerofill": true
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "#629e51",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(245, 54, 54, 0.9)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "bytes",
+      "gauge": {
+        "maxValue": 300,
+        "minValue": 0,
+        "show": true,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 4,
+        "x": 16,
+        "y": 0
+      },
+      "id": 22,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "scale(apps.backend.backend_01.counters.requests.count, 0.4)"
+        }
+      ],
+      "thresholds": "200,270",
+      "title": "Memory",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "none",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 3,
+        "w": 4,
+        "x": 20,
+        "y": 0
+      },
+      "id": 16,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "apps.backend.backend_02.counters.requests.count"
+        }
+      ],
+      "thresholds": "100,270",
+      "title": "Sign ups",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "none",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 3,
+        "w": 4,
+        "x": 20,
+        "y": 3
+      },
+      "id": 17,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "apps.backend.backend_04.counters.requests.count"
+        }
+      ],
+      "thresholds": "100,270",
+      "title": "Sign outs",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "none",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 3,
+        "w": 4,
+        "x": 20,
+        "y": 6
+      },
+      "id": 15,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "scale(apps.backend.backend_01.counters.requests.count, 0.7)"
+        }
+      ],
+      "thresholds": "100,270",
+      "title": "Logins",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "aliasColors": {
+        "web_server_01": "#badff4",
+        "web_server_02": "#5195ce",
+        "web_server_03": "#1f78c1",
+        "web_server_04": "#0a437c"
+      },
+      "annotate": {
+        "enable": false
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "editable": true,
+      "fill": 6,
+      "grid": {
+        "max": null,
+        "min": 0
+      },
+      "gridPos": {
+        "h": 11,
+        "w": 16,
+        "x": 0,
+        "y": 7
+      },
+      "id": 2,
+      "interactive": true,
+      "legend": {
+        "alignAsTable": false,
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "rightSide": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "legend_counts": true,
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "connected",
+      "options": false,
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "resolution": 100,
+      "scale": 1,
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "spyable": true,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "target": "aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "timezone": "browser",
+      "title": "server requests",
+      "tooltip": {
+        "msResolution": false,
+        "query_as_alias": true,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      },
+      "zerofill": true
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "#629e51",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(245, 54, 54, 0.9)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "none",
+      "gauge": {
+        "maxValue": 300,
+        "minValue": 0,
+        "show": true,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 5,
+        "w": 4,
+        "x": 16,
+        "y": 7
+      },
+      "id": 21,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "scale(apps.backend.backend_01.counters.requests.count, 0.8)"
+        }
+      ],
+      "thresholds": "200,270",
+      "title": "Logouts",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "none",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 3,
+        "w": 4,
+        "x": 20,
+        "y": 9
+      },
+      "id": 18,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "scale(apps.backend.backend_03.counters.requests.count, 0.3)"
+        }
+      ],
+      "thresholds": "100,270",
+      "title": "Support calls",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "#629e51",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(245, 54, 54, 0.9)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "none",
+      "gauge": {
+        "maxValue": 300,
+        "minValue": 0,
+        "show": true,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 6,
+        "w": 4,
+        "x": 16,
+        "y": 12
+      },
+      "id": 26,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "scale(apps.backend.backend_01.counters.requests.count, 0.2)"
+        }
+      ],
+      "thresholds": "200,270",
+      "title": "Google hits",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "#629e51",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(245, 54, 54, 0.9)"
+      ],
+      "datasource": null,
+      "editable": true,
+      "error": false,
+      "format": "none",
+      "gauge": {
+        "maxValue": 300,
+        "minValue": 0,
+        "show": true,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 6,
+        "w": 4,
+        "x": 20,
+        "y": 12
+      },
+      "id": 24,
+      "interval": null,
+      "links": [],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "50%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": true,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "refId": "A",
+          "target": "scale(apps.backend.backend_01.counters.requests.count, 0.2)"
+        }
+      ],
+      "thresholds": "200,270",
+      "title": "Google hits",
+      "type": "singlestat",
+      "valueFontSize": "100%",
+      "valueMaps": [
+        {
+          "op": "=",
+          "text": "N/A",
+          "value": "null"
+        }
+      ],
+      "valueName": "avg"
+    },
+    {
+      "aliasColors": {
+        "upper_25": "#F9E2D2",
+        "upper_50": "#F2C96D",
+        "upper_75": "#EAB839"
+      },
+      "annotate": {
+        "enable": false
+      },
+      "bars": true,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "editable": true,
+      "fill": 1,
+      "grid": {
+        "max": null,
+        "min": 0
+      },
+      "gridPos": {
+        "h": 11,
+        "w": 24,
+        "x": 0,
+        "y": 18
+      },
+      "id": 5,
+      "interactive": true,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": false,
+        "min": false,
+        "rightSide": true,
+        "show": true,
+        "total": false,
+        "values": true
+      },
+      "legend_counts": true,
+      "lines": false,
+      "linewidth": 2,
+      "nullPointMode": "connected",
+      "options": false,
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "resolution": 100,
+      "scale": 1,
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "spyable": true,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "refId": "A",
+          "target": "aliasByNode(summarize(statsd.fakesite.timers.ads_timer.*, '4min', 'avg'), 4)"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "timezone": "browser",
+      "title": "client side full page load",
+      "tooltip": {
+        "msResolution": false,
+        "query_as_alias": true,
+        "shared": false,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "ms",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      },
+      "zerofill": true
+    }
+  ],
+  "refresh": false,
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "demo"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "collapse": false,
+    "enable": true,
+    "notice": false,
+    "now": true,
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "status": "Stable",
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "2h",
+      " 6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ],
+    "type": "timepicker"
+  },
+  "timezone": "browser",
+  "title": "Big Dashboard",
+  "uid": "000000003",
+  "version": 16
+}

+ 73 - 0
devenv/datasources/default/default.yaml

@@ -0,0 +1,73 @@
+apiVersion: 1
+
+datasources:
+  - name: Graphite
+    type: graphite
+    access: proxy
+    url: http://localhost:8080
+    jsonData:
+      graphiteVersion: "1.1"
+  
+  - name: Prometheus
+    type: prometheus
+    access: proxy
+    isDefault: true
+    url: http://localhost:9090
+  
+  - name: InfluxDB
+    type: influxdb
+    access: proxy
+    database: site
+    user: grafana
+    password: grafana
+    url: http://localhost:8086
+    jsonData: 
+      timeInterval: "15s"
+
+  - name: OpenTsdb
+    type: opentsdb
+    access: proxy
+    url: http://localhost:4242
+    jsonData: 
+      tsdbResolution: 1
+      tsdbVersion: 1
+
+  - name: Elastic
+    type: elasticsearch
+    access: proxy
+    database: "[metrics-]YYYY.MM.DD"
+    url: http://localhost:9200
+    jsonData:
+      interval: Daily
+      timeField: "@timestamp"
+  
+  - name: MySQL
+    type: mysql
+    url: localhost:3306
+    database: grafana
+    user: grafana
+    password: password
+
+  - name: MSSQL
+    type: mssql
+    url: localhost:1433
+    database: grafana
+    user: grafana
+    password: "Password!"
+
+  - name: Postgres
+    type: postgres
+    url: localhost:5432
+    database: grafana
+    user: grafana
+    password: password
+    jsonData:
+      sslmode: "disable"
+
+  - name: Cloudwatch
+    type: cloudwatch
+    editable: true
+    jsonData:
+      authType: credentials
+      defaultRegion: eu-west-2
+

+ 61 - 0
devenv/setup.sh

@@ -0,0 +1,61 @@
+#/bin/bash
+
+bulkDashboard() {
+
+    requiresJsonnet
+
+    COUNTER=0
+    MAX=400
+    while [  $COUNTER -lt $MAX ]; do
+        jsonnet -o "dashboards/bulk-testing/dashboard${COUNTER}.json" -e "local bulkDash = import 'dashboards/bulk-testing/bulkdash.jsonnet'; bulkDash + {  uid: 'uid-${COUNTER}',  title: 'title-${COUNTER}' }"
+        let COUNTER=COUNTER+1 
+    done
+
+    ln -s -f -r ./dashboards/bulk-testing/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
+}
+
+requiresJsonnet() {
+    if ! type "jsonnet" > /dev/null; then
+        echo "you need you install jsonnet to run this script"
+        echo "follow the instructions on https://github.com/google/jsonnet"
+        exit 1
+    fi
+}
+
+defaultDashboards() {
+    echo "not implemented yet"
+}
+
+defaultDatasources() {
+    echo "setting up all default datasources using provisioning"
+
+    ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml
+}
+
+usage() {
+	echo -e "install.sh\n\tThis script installs my basic setup for a debian laptop\n"
+	echo "Usage:"
+	echo "  bulk-dashboards                     - create and provisioning 400 dashboards"
+    echo "  default-datasources                 - provisiong all core datasources"
+}
+
+main() {
+	local cmd=$1
+
+	if [[ -z "$cmd" ]]; then
+		usage
+		exit 1
+	fi
+
+	if [[ $cmd == "bulk-dashboards" ]]; then
+		bulkDashboard
+    elif [[ $cmd == "default-datasources" ]]; then
+		defaultDatasources
+    elif [[ $cmd == "default-dashboards" ]]; then
+		bulkDashboard
+	else
+		usage
+	fi
+}
+
+main "$@"

+ 1 - 1
docker/blocks/mysql/docker-compose.yaml

@@ -1,5 +1,5 @@
   mysql:
-    image: mysql:latest
+    image: mysql:5.6
     environment:
       MYSQL_ROOT_PASSWORD: rootpass
       MYSQL_DATABASE: grafana

+ 2 - 2
docker/blocks/mysql_tests/Dockerfile

@@ -1,3 +1,3 @@
-FROM mysql:latest
+FROM mysql:5.6
 ADD setup.sql /docker-entrypoint-initdb.d
-CMD ["mysqld"]
+CMD ["mysqld"]

+ 2 - 2
docker/blocks/postgres/docker-compose.yaml

@@ -1,5 +1,5 @@
   postgrestest:
-    image: postgres:latest
+    image: postgres:9.3
     environment:
       POSTGRES_USER: grafana
       POSTGRES_PASSWORD: password
@@ -13,4 +13,4 @@
     network_mode: bridge
     environment:
       FD_DATASOURCE: postgres
-      FD_PORT: 5432
+      FD_PORT: 5432

+ 2 - 2
docker/blocks/postgres_tests/Dockerfile

@@ -1,3 +1,3 @@
-FROM postgres:latest
+FROM postgres:9.3
 ADD setup.sql /docker-entrypoint-initdb.d
-CMD ["postgres"]
+CMD ["postgres"]

+ 1 - 1
docker/blocks/postgres_tests/setup.sql

@@ -1,3 +1,3 @@
 CREATE DATABASE grafanadstest;
 REVOKE CONNECT ON DATABASE grafanadstest FROM PUBLIC;
-GRANT CONNECT ON DATABASE grafanadstest TO grafanatest;
+GRANT CONNECT ON DATABASE grafanadstest TO grafanatest;

+ 15 - 10
pkg/api/index.go

@@ -92,17 +92,22 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 		data.Theme = "light"
 	}
 
-	if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
+	if hasEditPermissionInFoldersQuery.Result {
+		children := []*dtos.NavLink{
+			{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
+		}
+
+		if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR {
+			children = append(children, &dtos.NavLink{Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"})
+			children = append(children, &dtos.NavLink{Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"})
+		}
+
 		data.NavTree = append(data.NavTree, &dtos.NavLink{
-			Text: "Create",
-			Id:   "create",
-			Icon: "fa fa-fw fa-plus",
-			Url:  setting.AppSubUrl + "/dashboard/new",
-			Children: []*dtos.NavLink{
-				{Text: "Dashboard", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/dashboard/new"},
-				{Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", Icon: "gicon gicon-folder-new", Url: setting.AppSubUrl + "/dashboards/folder/new"},
-				{Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "gicon gicon-dashboard-import", Url: setting.AppSubUrl + "/dashboard/import"},
-			},
+			Text:     "Create",
+			Id:       "create",
+			Icon:     "fa fa-fw fa-plus",
+			Url:      setting.AppSubUrl + "/dashboard/new",
+			Children: children,
 		})
 	}
 

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

@@ -47,9 +47,15 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade
 		log.Error("Cannot read directory", "error", err)
 	}
 
+	absPath, err := filepath.Abs(path)
+	if err != nil {
+		log.Error("Could not create absolute path ", "path", path)
+		absPath = path //if .Abs return an error we fallback to path
+	}
+
 	return &fileReader{
 		Cfg:              cfg,
-		Path:             path,
+		Path:             absPath,
 		log:              log,
 		dashboardService: dashboards.NewProvisioningService(),
 	}, nil

+ 50 - 28
pkg/services/provisioning/dashboards/file_reader_test.go

@@ -3,6 +3,7 @@ package dashboards
 import (
 	"os"
 	"path/filepath"
+	"runtime"
 	"testing"
 	"time"
 
@@ -15,14 +16,59 @@ import (
 )
 
 var (
-	defaultDashboards = "./testdata/test-dashboards/folder-one"
-	brokenDashboards  = "./testdata/test-dashboards/broken-dashboards"
-	oneDashboard      = "./testdata/test-dashboards/one-dashboard"
-	containingId      = "./testdata/test-dashboards/containing-id"
+	defaultDashboards = "testdata/test-dashboards/folder-one"
+	brokenDashboards  = "testdata/test-dashboards/broken-dashboards"
+	oneDashboard      = "testdata/test-dashboards/one-dashboard"
+	containingId      = "testdata/test-dashboards/containing-id"
 
 	fakeService *fakeDashboardProvisioningService
 )
 
+func TestCreatingNewDashboardFileReader(t *testing.T) {
+	Convey("creating new dashboard file reader", t, func() {
+		cfg := &DashboardsAsConfig{
+			Name:    "Default",
+			Type:    "file",
+			OrgId:   1,
+			Folder:  "",
+			Options: map[string]interface{}{},
+		}
+
+		Convey("using path parameter", func() {
+			cfg.Options["path"] = defaultDashboards
+			reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
+			So(err, ShouldBeNil)
+			So(reader.Path, ShouldNotEqual, "")
+		})
+
+		Convey("using folder as options", func() {
+			cfg.Options["folder"] = defaultDashboards
+			reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
+			So(err, ShouldBeNil)
+			So(reader.Path, ShouldNotEqual, "")
+		})
+
+		Convey("using full path", func() {
+			cfg.Options["folder"] = "/var/lib/grafana/dashboards"
+			reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
+			So(err, ShouldBeNil)
+
+			if runtime.GOOS != "windows" {
+				So(reader.Path, ShouldEqual, "/var/lib/grafana/dashboards")
+			}
+			So(filepath.IsAbs(reader.Path), ShouldBeTrue)
+		})
+
+		Convey("using relative path", func() {
+			cfg.Options["folder"] = defaultDashboards
+			reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
+			So(err, ShouldBeNil)
+
+			So(filepath.IsAbs(reader.Path), ShouldBeTrue)
+		})
+	})
+}
+
 func TestDashboardFileReader(t *testing.T) {
 	Convey("Dashboard file reader", t, func() {
 		bus.ClearBusHandlers()
@@ -170,30 +216,6 @@ func TestDashboardFileReader(t *testing.T) {
 			})
 		})
 
-		Convey("Can use bpth path and folder as dashboard path", func() {
-			cfg := &DashboardsAsConfig{
-				Name:    "Default",
-				Type:    "file",
-				OrgId:   1,
-				Folder:  "",
-				Options: map[string]interface{}{},
-			}
-
-			Convey("using path parameter", func() {
-				cfg.Options["path"] = defaultDashboards
-				reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
-				So(err, ShouldBeNil)
-				So(reader.Path, ShouldEqual, defaultDashboards)
-			})
-
-			Convey("using folder as options", func() {
-				cfg.Options["folder"] = defaultDashboards
-				reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
-				So(err, ShouldBeNil)
-				So(reader.Path, ShouldEqual, defaultDashboards)
-			})
-		})
-
 		Reset(func() {
 			dashboards.NewProvisioningService = origNewDashboardProvisioningService
 		})

+ 6 - 6
pkg/services/provisioning/datasources/config_reader_test.go

@@ -13,12 +13,12 @@ import (
 var (
 	logger log.Logger = log.New("fake.log")
 
-	twoDatasourcesConfig            = "./test-configs/two-datasources"
-	twoDatasourcesConfigPurgeOthers = "./test-configs/insert-two-delete-two"
-	doubleDatasourcesConfig         = "./test-configs/double-default"
-	allProperties                   = "./test-configs/all-properties"
-	versionZero                     = "./test-configs/version-0"
-	brokenYaml                      = "./test-configs/broken-yaml"
+	twoDatasourcesConfig            = "testdata/two-datasources"
+	twoDatasourcesConfigPurgeOthers = "testdata/insert-two-delete-two"
+	doubleDatasourcesConfig         = "testdata/double-default"
+	allProperties                   = "testdata/all-properties"
+	versionZero                     = "testdata/version-0"
+	brokenYaml                      = "testdata/broken-yaml"
 
 	fakeRepo *fakeRepository
 )

+ 0 - 0
pkg/services/provisioning/datasources/test-configs/all-properties/all-properties.yaml → pkg/services/provisioning/datasources/testdata/all-properties/all-properties.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/all-properties/not.yaml.txt → pkg/services/provisioning/datasources/testdata/all-properties/not.yaml.txt


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/all-properties/sample.yaml → pkg/services/provisioning/datasources/testdata/all-properties/sample.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/all-properties/second.yaml → pkg/services/provisioning/datasources/testdata/all-properties/second.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/broken-yaml/broken.yaml → pkg/services/provisioning/datasources/testdata/broken-yaml/broken.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/broken-yaml/commented.yaml → pkg/services/provisioning/datasources/testdata/broken-yaml/commented.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/double-default/default-1.yaml → pkg/services/provisioning/datasources/testdata/double-default/default-1.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/double-default/default-2.yaml → pkg/services/provisioning/datasources/testdata/double-default/default-2.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/insert-two-delete-two/one-datasources.yaml → pkg/services/provisioning/datasources/testdata/insert-two-delete-two/one-datasources.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/insert-two-delete-two/two-datasources.yml → pkg/services/provisioning/datasources/testdata/insert-two-delete-two/two-datasources.yml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/two-datasources/two-datasources.yaml → pkg/services/provisioning/datasources/testdata/two-datasources/two-datasources.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/version-0/version-0.yaml → pkg/services/provisioning/datasources/testdata/version-0/version-0.yaml


+ 0 - 0
pkg/services/provisioning/datasources/test-configs/zero-datasources/placeholder-for-git → pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git


+ 8 - 0
pkg/tsdb/influxdb/query_part_test.go

@@ -76,5 +76,13 @@ func TestInfluxdbQueryPart(t *testing.T) {
 			res := part.Render(query, queryContext, "mean(value)")
 			So(res, ShouldEqual, `mean(value) AS "test"`)
 		})
+
+		Convey("render count distinct", func() {
+			part, err := NewQueryPart("count", []string{})
+			So(err, ShouldBeNil)
+
+			res := part.Render(query, queryContext, "distinct(value)")
+			So(res, ShouldEqual, `count(distinct(value))`)
+		})
 	})
 }

+ 3 - 3
pkg/tsdb/mysql/mysql_test.go

@@ -601,7 +601,7 @@ func TestMySQL(t *testing.T) {
 					Queries: []*tsdb.Query{
 						{
 							Model: simplejson.NewFromAny(map[string]interface{}{
-								"rawSql": `SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values ORDER BY 1`,
+								"rawSql": `SELECT $__time(time), CONCAT(measurement, ' - value one') as metric, valueOne FROM metric_values ORDER BY 1,2`,
 								"format": "time_series",
 							}),
 							RefId: "A",
@@ -615,8 +615,8 @@ func TestMySQL(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				So(len(queryResult.Series), ShouldEqual, 2)
-				So(queryResult.Series[0].Name, ShouldEqual, "Metric B - value one")
-				So(queryResult.Series[1].Name, ShouldEqual, "Metric A - value one")
+				So(queryResult.Series[0].Name, ShouldEqual, "Metric A - value one")
+				So(queryResult.Series[1].Name, ShouldEqual, "Metric B - value one")
 			})
 
 			Convey("When doing a metric query grouping by time should return correct series", func() {

+ 1 - 1
public/app/containers/Explore/ElapsedTime.tsx

@@ -41,6 +41,6 @@ export default class ElapsedTime extends PureComponent<any, any> {
     const { elapsed } = this.state;
     const { className, time } = this.props;
     const value = (time || elapsed) / 1000;
-    return <span className={className}>{value.toFixed(1)}s</span>;
+    return <span className={`elapsed-time ${className}`}>{value.toFixed(1)}s</span>;
   }
 }

+ 135 - 76
public/app/containers/Explore/Explore.tsx

@@ -4,10 +4,10 @@ import colors from 'app/core/utils/colors';
 import TimeSeries from 'app/core/time_series2';
 
 import ElapsedTime from './ElapsedTime';
-import Legend from './Legend';
 import QueryRows from './QueryRows';
 import Graph from './Graph';
 import Table from './Table';
+import TimePicker, { DEFAULT_RANGE } from './TimePicker';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
 import { decodePathComponent } from 'app/core/utils/location_util';
@@ -16,39 +16,30 @@ function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
     const datapoints = seriesData.datapoints || [];
     const alias = seriesData.target;
-
     const colorIndex = index % colors.length;
     const color = colors[colorIndex];
 
     const series = new TimeSeries({
-      datapoints: datapoints,
-      alias: alias,
-      color: color,
+      datapoints,
+      alias,
+      color,
       unit: seriesData.unit,
     });
 
-    if (datapoints && datapoints.length > 0) {
-      const last = datapoints[datapoints.length - 1][1];
-      const from = options.range.from;
-      if (last - from < -10000) {
-        series.isOutsideRange = true;
-      }
-    }
-
     return series;
   });
 }
 
-function parseInitialQueries(initial) {
-  if (!initial) {
-    return [];
-  }
+function parseInitialState(initial) {
   try {
     const parsed = JSON.parse(decodePathComponent(initial));
-    return parsed.queries.map(q => q.query);
+    return {
+      queries: parsed.queries.map(q => q.query),
+      range: parsed.range,
+    };
   } catch (e) {
     console.error(e);
-    return [];
+    return { queries: [], range: DEFAULT_RANGE };
   }
 }
 
@@ -60,6 +51,8 @@ interface IExploreState {
   latency: number;
   loading: any;
   queries: any;
+  queryError: any;
+  range: any;
   requestOptions: any;
   showingGraph: boolean;
   showingTable: boolean;
@@ -72,7 +65,7 @@ export class Explore extends React.Component<any, IExploreState> {
 
   constructor(props) {
     super(props);
-    const initialQueries = parseInitialQueries(props.routeParams.initial);
+    const { range, queries } = parseInitialState(props.routeParams.initial);
     this.state = {
       datasource: null,
       datasourceError: null,
@@ -80,11 +73,14 @@ export class Explore extends React.Component<any, IExploreState> {
       graphResult: null,
       latency: 0,
       loading: false,
-      queries: ensureQueries(initialQueries),
+      queries: ensureQueries(queries),
+      queryError: null,
+      range: range || { ...DEFAULT_RANGE },
       requestOptions: null,
       showingGraph: true,
       showingTable: true,
       tableResult: null,
+      ...props.initialState,
     };
   }
 
@@ -98,6 +94,10 @@ export class Explore extends React.Component<any, IExploreState> {
     }
   }
 
+  componentDidCatch(error) {
+    console.error(error);
+  }
+
   handleAddQueryRow = index => {
     const { queries } = this.state;
     const nextQueries = [
@@ -119,10 +119,32 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState({ queries: nextQueries });
   };
 
+  handleChangeTime = nextRange => {
+    const range = {
+      from: nextRange.from,
+      to: nextRange.to,
+    };
+    this.setState({ range }, () => this.handleSubmit());
+  };
+
+  handleClickCloseSplit = () => {
+    const { onChangeSplit } = this.props;
+    if (onChangeSplit) {
+      onChangeSplit(false);
+    }
+  };
+
   handleClickGraphButton = () => {
     this.setState(state => ({ showingGraph: !state.showingGraph }));
   };
 
+  handleClickSplit = () => {
+    const { onChangeSplit } = this.props;
+    if (onChangeSplit) {
+      onChangeSplit(true, this.state);
+    }
+  };
+
   handleClickTableButton = () => {
     this.setState(state => ({ showingTable: !state.showingTable }));
   };
@@ -147,17 +169,17 @@ export class Explore extends React.Component<any, IExploreState> {
   };
 
   async runGraphQuery() {
-    const { datasource, queries } = this.state;
+    const { datasource, queries, range } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
-    this.setState({ latency: 0, loading: true, graphResult: null });
+    this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
     const now = Date.now();
     const options = buildQueryOptions({
       format: 'time_series',
       interval: datasource.interval,
       instant: false,
-      now,
+      range,
       queries: queries.map(q => q.query),
     });
     try {
@@ -165,24 +187,25 @@ export class Explore extends React.Component<any, IExploreState> {
       const result = makeTimeSeriesList(res.data, options);
       const latency = Date.now() - now;
       this.setState({ latency, loading: false, graphResult: result, requestOptions: options });
-    } catch (error) {
-      console.error(error);
-      this.setState({ loading: false, graphResult: error });
+    } catch (response) {
+      console.error(response);
+      const queryError = response.data ? response.data.error : response;
+      this.setState({ loading: false, queryError });
     }
   }
 
   async runTableQuery() {
-    const { datasource, queries } = this.state;
+    const { datasource, queries, range } = this.state;
     if (!hasQuery(queries)) {
       return;
     }
-    this.setState({ latency: 0, loading: true, tableResult: null });
+    this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
     const now = Date.now();
     const options = buildQueryOptions({
       format: 'table',
       interval: datasource.interval,
       instant: true,
-      now,
+      range,
       queries: queries.map(q => q.query),
     });
     try {
@@ -190,9 +213,10 @@ export class Explore extends React.Component<any, IExploreState> {
       const tableModel = res.data[0];
       const latency = Date.now() - now;
       this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
-    } catch (error) {
-      console.error(error);
-      this.setState({ loading: false, tableResult: null });
+    } catch (response) {
+      console.error(response);
+      const queryError = response.data ? response.data.error : response;
+      this.setState({ loading: false, queryError });
     }
   }
 
@@ -202,6 +226,7 @@ export class Explore extends React.Component<any, IExploreState> {
   };
 
   render() {
+    const { position, split } = this.props;
     const {
       datasource,
       datasourceError,
@@ -210,59 +235,93 @@ export class Explore extends React.Component<any, IExploreState> {
       latency,
       loading,
       queries,
+      queryError,
+      range,
       requestOptions,
       showingGraph,
       showingTable,
       tableResult,
     } = this.state;
     const showingBoth = showingGraph && showingTable;
-    const graphHeight = showingBoth ? '200px' : null;
-    const graphButtonClassName = showingBoth || showingGraph ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
-    const tableButtonClassName = showingBoth || showingTable ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
+    const graphHeight = showingBoth ? '200px' : '400px';
+    const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
+    const tableButtonActive = showingBoth || showingTable ? 'active' : '';
+    const exploreClass = split ? 'explore explore-split' : 'explore';
     return (
-      <div className="explore">
-        <div className="page-body page-full">
-          <h2 className="page-sub-heading">Explore</h2>
-          {datasourceLoading ? <div>Loading datasource...</div> : null}
-
-          {datasourceError ? <div title={datasourceError}>Error connecting to datasource.</div> : null}
-
-          {datasource ? (
-            <div className="m-r-3">
-              <div className="nav m-b-1">
-                <div className="pull-right">
-                  {loading || latency ? <ElapsedTime time={latency} className="" /> : null}
-                  <button type="submit" className="m-l-1 btn btn-primary" onClick={this.handleSubmit}>
-                    <i className="fa fa-return" /> Run Query
-                  </button>
-                </div>
-                <div>
-                  <button className={graphButtonClassName} onClick={this.handleClickGraphButton}>
-                    Graph
-                  </button>
-                  <button className={tableButtonClassName} onClick={this.handleClickTableButton}>
-                    Table
-                  </button>
-                </div>
-              </div>
-              <QueryRows
-                queries={queries}
-                request={this.request}
-                onAddQueryRow={this.handleAddQueryRow}
-                onChangeQuery={this.handleChangeQuery}
-                onExecuteQuery={this.handleSubmit}
-                onRemoveQueryRow={this.handleRemoveQueryRow}
-              />
-              <main className="m-t-2">
-                {showingGraph ? (
-                  <Graph data={graphResult} id="explore-1" options={requestOptions} height={graphHeight} />
-                ) : null}
-                {showingGraph ? <Legend data={graphResult} /> : null}
-                {showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
-              </main>
+      <div className={exploreClass}>
+        <div className="navbar">
+          {position === 'left' ? (
+            <div>
+              <a className="navbar-page-btn">
+                <i className="fa fa-rocket" />
+                Explore
+              </a>
+            </div>
+          ) : (
+            <div className="navbar-buttons explore-first-button">
+              <button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
+                Close Split
+              </button>
+            </div>
+          )}
+          <div className="navbar__spacer" />
+          {position === 'left' && !split ? (
+            <div className="navbar-buttons">
+              <button className="btn navbar-button" onClick={this.handleClickSplit}>
+                Split
+              </button>
             </div>
           ) : null}
+          <div className="navbar-buttons">
+            <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
+              Graph
+            </button>
+            <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
+              Table
+            </button>
+          </div>
+          <TimePicker range={range} onChangeTime={this.handleChangeTime} />
+          <div className="navbar-buttons relative">
+            <button className="btn navbar-button--primary" onClick={this.handleSubmit}>
+              Run Query <i className="fa fa-level-down run-icon" />
+            </button>
+            {loading || latency ? <ElapsedTime time={latency} className="text-info" /> : null}
+          </div>
         </div>
+
+        {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
+
+        {datasourceError ? (
+          <div className="explore-container" title={datasourceError}>
+            Error connecting to datasource.
+          </div>
+        ) : null}
+
+        {datasource ? (
+          <div className="explore-container">
+            <QueryRows
+              queries={queries}
+              request={this.request}
+              onAddQueryRow={this.handleAddQueryRow}
+              onChangeQuery={this.handleChangeQuery}
+              onExecuteQuery={this.handleSubmit}
+              onRemoveQueryRow={this.handleRemoveQueryRow}
+            />
+            {queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
+            <main className="m-t-2">
+              {showingGraph ? (
+                <Graph
+                  data={graphResult}
+                  id={`explore-graph-${position}`}
+                  options={requestOptions}
+                  height={graphHeight}
+                  split={split}
+                />
+              ) : null}
+              {showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
+            </main>
+          </div>
+        ) : null}
       </div>
     );
   }

+ 23 - 10
public/app/containers/Explore/Graph.tsx

@@ -1,10 +1,13 @@
 import $ from 'jquery';
 import React, { Component } from 'react';
-
-import TimeSeries from 'app/core/time_series2';
+import moment from 'moment';
 
 import 'vendor/flot/jquery.flot';
 import 'vendor/flot/jquery.flot.time';
+import * as dateMath from 'app/core/utils/datemath';
+import TimeSeries from 'app/core/time_series2';
+
+import Legend from './Legend';
 
 // Copied from graph.ts
 function time_format(ticks, min, max) {
@@ -72,6 +75,7 @@ class Graph extends Component<any, any> {
     if (
       prevProps.data !== this.props.data ||
       prevProps.options !== this.props.options ||
+      prevProps.split !== this.props.split ||
       prevProps.height !== this.props.height
     ) {
       this.draw();
@@ -84,14 +88,22 @@ class Graph extends Component<any, any> {
       return;
     }
     const series = data.map((ts: TimeSeries) => ({
+      color: ts.color,
       label: ts.label,
       data: ts.getFlotPairs('null'),
     }));
 
     const $el = $(`#${this.props.id}`);
     const ticks = $el.width() / 100;
-    const min = userOptions.range.from.valueOf();
-    const max = userOptions.range.to.valueOf();
+    let { from, to } = userOptions.range;
+    if (!moment.isMoment(from)) {
+      from = dateMath.parse(from, false);
+    }
+    if (!moment.isMoment(to)) {
+      to = dateMath.parse(to, true);
+    }
+    const min = from.valueOf();
+    const max = to.valueOf();
     const dynamicOptions = {
       xaxis: {
         mode: 'time',
@@ -111,12 +123,13 @@ class Graph extends Component<any, any> {
   }
 
   render() {
-    const style = {
-      height: this.props.height || '400px',
-      width: this.props.width || '100%',
-    };
-
-    return <div id={this.props.id} style={style} />;
+    const { data, height } = this.props;
+    return (
+      <div className="panel-container">
+        <div id={this.props.id} className="explore-graph" style={{ height }} />
+        <Legend data={data} />
+      </div>
+    );
   }
 }
 

+ 1 - 1
public/app/containers/Explore/QueryField.tsx

@@ -50,7 +50,7 @@ class Portal extends React.Component {
   constructor(props) {
     super(props);
     this.node = document.createElement('div');
-    this.node.classList.add(`query-field-portal-${props.index}`);
+    this.node.classList.add('explore-typeahead', `explore-typeahead-${props.index}`);
     document.body.appendChild(this.node);
   }
 

+ 3 - 2
public/app/containers/Explore/QueryRows.tsx

@@ -48,10 +48,10 @@ class QueryRow extends PureComponent<any, any> {
     return (
       <div className="query-row">
         <div className="query-row-tools">
-          <button className="btn btn-small btn-inverse" onClick={this.handleClickAddButton}>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.handleClickAddButton}>
             <i className="fa fa-plus" />
           </button>
-          <button className="btn btn-small btn-inverse" onClick={this.handleClickRemoveButton}>
+          <button className="btn navbar-button navbar-button--tight" onClick={this.handleClickRemoveButton}>
             <i className="fa fa-minus" />
           </button>
         </div>
@@ -60,6 +60,7 @@ class QueryRow extends PureComponent<any, any> {
             initialQuery={edited ? null : query}
             onPressEnter={this.handlePressEnter}
             onQueryChange={this.handleChangeQuery}
+            placeholder="Enter a PromQL query"
             request={request}
           />
         </div>

+ 74 - 0
public/app/containers/Explore/TimePicker.jest.tsx

@@ -0,0 +1,74 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import sinon from 'sinon';
+
+import * as rangeUtil from 'app/core/utils/rangeutil';
+import TimePicker, { DEFAULT_RANGE, parseTime } from './TimePicker';
+
+describe('<TimePicker />', () => {
+  it('renders closed with default values', () => {
+    const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE);
+    const wrapper = shallow(<TimePicker />);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false);
+  });
+
+  it('renders with relative range', () => {
+    const range = {
+      from: 'now-7h',
+      to: 'now',
+    };
+    const rangeString = rangeUtil.describeTimeRange(range);
+    const wrapper = shallow(<TimePicker range={range} isOpen />);
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.state('fromRaw')).toBe(range.from);
+    expect(wrapper.state('toRaw')).toBe(range.to);
+    expect(wrapper.find('.timepicker-from').props().value).toBe(range.from);
+    expect(wrapper.find('.timepicker-to').props().value).toBe(range.to);
+  });
+
+  it('renders with epoch (millies) range converted to ISO-ish', () => {
+    const range = {
+      from: '1',
+      to: '1000',
+    };
+    const rangeString = rangeUtil.describeTimeRange({
+      from: parseTime(range.from),
+      to: parseTime(range.to),
+    });
+    const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
+    expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
+  });
+
+  it('moves ranges forward and backward by half the range on arrow click', () => {
+    const range = {
+      from: '2000',
+      to: '4000',
+    };
+    const rangeString = rangeUtil.describeTimeRange({
+      from: parseTime(range.from),
+      to: parseTime(range.to),
+    });
+
+    const onChangeTime = sinon.spy();
+    const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
+    expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
+    expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:02');
+    expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:04');
+
+    wrapper.find('.timepicker-left').simulate('click');
+    expect(onChangeTime.calledOnce).toBe(true);
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
+
+    wrapper.find('.timepicker-right').simulate('click');
+    expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
+    expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
+  });
+});

+ 245 - 0
public/app/containers/Explore/TimePicker.tsx

@@ -0,0 +1,245 @@
+import React, { PureComponent } from 'react';
+import moment from 'moment';
+
+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',
+};
+
+export function parseTime(value, isUtc = false, asString = false) {
+  if (value.indexOf('now') !== -1) {
+    return value;
+  }
+  if (!isNaN(value)) {
+    const epoch = parseInt(value);
+    const m = isUtc ? moment.utc(epoch) : moment(epoch);
+    return asString ? m.format(DATE_FORMAT) : m;
+  }
+  return undefined;
+}
+
+export default class TimePicker extends PureComponent<any, any> {
+  dropdownEl: any;
+  constructor(props) {
+    super(props);
+
+    const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from;
+    const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to;
+    const range = {
+      from: parseTime(fromRaw),
+      to: parseTime(toRaw),
+    };
+    this.state = {
+      fromRaw: parseTime(fromRaw, props.isUtc, true),
+      isOpen: props.isOpen,
+      isUtc: props.isUtc,
+      rangeString: rangeUtil.describeTimeRange(range),
+      refreshInterval: '',
+      toRaw: parseTime(toRaw, props.isUtc, true),
+    };
+  }
+
+  move(direction) {
+    const { onChangeTime } = this.props;
+    const { fromRaw, toRaw } = this.state;
+    const range = {
+      from: dateMath.parse(fromRaw, false),
+      to: dateMath.parse(toRaw, true),
+    };
+
+    const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
+    let to, from;
+    if (direction === -1) {
+      to = range.to.valueOf() - timespan;
+      from = range.from.valueOf() - timespan;
+    } else if (direction === 1) {
+      to = range.to.valueOf() + timespan;
+      from = range.from.valueOf() + timespan;
+      if (to > Date.now() && range.to < Date.now()) {
+        to = Date.now();
+        from = range.from.valueOf();
+      }
+    } else {
+      to = range.to.valueOf();
+      from = range.from.valueOf();
+    }
+
+    const rangeString = rangeUtil.describeTimeRange(range);
+    // No need to convert to UTC again
+    to = moment(to);
+    from = moment(from);
+
+    this.setState(
+      {
+        rangeString,
+        fromRaw: from.format(DATE_FORMAT),
+        toRaw: to.format(DATE_FORMAT),
+      },
+      () => {
+        onChangeTime({ to, from });
+      }
+    );
+  }
+
+  handleChangeFrom = e => {
+    this.setState({
+      fromRaw: e.target.value,
+    });
+  };
+
+  handleChangeTo = e => {
+    this.setState({
+      toRaw: e.target.value,
+    });
+  };
+
+  handleClickApply = () => {
+    const { onChangeTime } = this.props;
+    const { toRaw, fromRaw } = this.state;
+    const range = {
+      from: dateMath.parse(fromRaw, false),
+      to: dateMath.parse(toRaw, true),
+    };
+    const rangeString = rangeUtil.describeTimeRange(range);
+    this.setState(
+      {
+        isOpen: false,
+        rangeString,
+      },
+      () => {
+        if (onChangeTime) {
+          onChangeTime(range);
+        }
+      }
+    );
+  };
+
+  handleClickLeft = () => this.move(-1);
+  handleClickPicker = () => {
+    this.setState(state => ({
+      isOpen: !state.isOpen,
+    }));
+  };
+  handleClickRight = () => this.move(1);
+  handleClickRefresh = () => {};
+  handleClickRelativeOption = range => {
+    const { onChangeTime } = this.props;
+    const rangeString = rangeUtil.describeTimeRange(range);
+    this.setState(
+      {
+        toRaw: range.to,
+        fromRaw: range.from,
+        isOpen: false,
+        rangeString,
+      },
+      () => {
+        if (onChangeTime) {
+          onChangeTime(range);
+        }
+      }
+    );
+  };
+
+  getTimeOptions() {
+    return rangeUtil.getRelativeTimesList({}, this.state.rangeString);
+  }
+
+  dropdownRef = el => {
+    this.dropdownEl = el;
+  };
+
+  renderDropdown() {
+    const { fromRaw, isOpen, toRaw } = this.state;
+    if (!isOpen) {
+      return null;
+    }
+    const timeOptions = this.getTimeOptions();
+    return (
+      <div ref={this.dropdownRef} className="gf-timepicker-dropdown">
+        <div className="gf-timepicker-absolute-section">
+          <h3 className="section-heading">Custom range</h3>
+
+          <label className="small">From:</label>
+          <div className="gf-form-inline">
+            <div className="gf-form max-width-28">
+              <input
+                type="text"
+                className="gf-form-input input-large timepicker-from"
+                value={fromRaw}
+                onChange={this.handleChangeFrom}
+              />
+            </div>
+          </div>
+
+          <label className="small">To:</label>
+          <div className="gf-form-inline">
+            <div className="gf-form max-width-28">
+              <input
+                type="text"
+                className="gf-form-input input-large timepicker-to"
+                value={toRaw}
+                onChange={this.handleChangeTo}
+              />
+            </div>
+          </div>
+
+          {/* <label className="small">Refreshing every:</label>
+          <div className="gf-form-inline">
+            <div className="gf-form max-width-28">
+              <select className="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
+            </div>
+          </div> */}
+          <div className="gf-form">
+            <button className="btn gf-form-btn btn-secondary" onClick={this.handleClickApply}>
+              Apply
+            </button>
+          </div>
+        </div>
+
+        <div className="gf-timepicker-relative-section">
+          <h3 className="section-heading">Quick ranges</h3>
+          {Object.keys(timeOptions).map(section => {
+            const group = timeOptions[section];
+            return (
+              <ul key={section}>
+                {group.map(option => (
+                  <li className={option.active ? 'active' : ''} key={option.display}>
+                    <a onClick={() => this.handleClickRelativeOption(option)}>{option.display}</a>
+                  </li>
+                ))}
+              </ul>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { isUtc, rangeString, refreshInterval } = this.state;
+    return (
+      <div className="timepicker">
+        <div className="navbar-buttons">
+          <button className="btn navbar-button navbar-button--tight timepicker-left" onClick={this.handleClickLeft}>
+            <i className="fa fa-chevron-left" />
+          </button>
+          <button className="btn navbar-button gf-timepicker-nav-btn" onClick={this.handleClickPicker}>
+            <i className="fa fa-clock-o" />
+            <span className="timepicker-rangestring">{rangeString}</span>
+            {isUtc ? <span className="gf-timepicker-utc">UTC</span> : null}
+            {refreshInterval ? <span className="text-warning">&nbsp; Refresh every {refreshInterval}</span> : null}
+          </button>
+          <button className="btn navbar-button navbar-button--tight timepicker-right" onClick={this.handleClickRight}>
+            <i className="fa fa-chevron-right" />
+          </button>
+        </div>
+        {this.renderDropdown()}
+      </div>
+    );
+  }
+}

+ 33 - 0
public/app/containers/Explore/Wrapper.tsx

@@ -0,0 +1,33 @@
+import React, { PureComponent } from 'react';
+
+import Explore from './Explore';
+
+export default class Wrapper extends PureComponent<any, any> {
+  state = {
+    initialState: null,
+    split: false,
+  };
+
+  handleChangeSplit = (split, initialState) => {
+    this.setState({ split, initialState });
+  };
+
+  render() {
+    // State overrides for props from first Explore
+    const { initialState, split } = this.state;
+    return (
+      <div className="explore-wrapper">
+        <Explore {...this.props} position="left" onChangeSplit={this.handleChangeSplit} split={split} />
+        {split ? (
+          <Explore
+            {...this.props}
+            initialState={initialState}
+            onChangeSplit={this.handleChangeSplit}
+            position="right"
+            split={split}
+          />
+        ) : null}
+      </div>
+    );
+  }
+}

+ 2 - 7
public/app/containers/Explore/utils/query.ts

@@ -1,12 +1,7 @@
-export function buildQueryOptions({ format, interval, instant, now, queries }) {
-  const to = now;
-  const from = to - 1000 * 60 * 60 * 3;
+export function buildQueryOptions({ format, interval, instant, range, queries }) {
   return {
     interval,
-    range: {
-      from,
-      to,
-    },
+    range,
     targets: queries.map(expr => ({
       expr,
       format,

+ 1 - 1
public/app/core/components/manage_dashboards/manage_dashboards.html

@@ -5,7 +5,7 @@
       <i class="gf-form-input-icon fa fa-search"></i>
     </label>
     <div class="page-action-bar__spacer"></div>
-    <a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.isEditor || ctrl.canSave">
+    <a class="btn btn-success" ng-href="{{ctrl.createDashboardUrl()}}" ng-if="ctrl.hasEditPermissionInFolders || ctrl.canSave">
       <i class="fa fa-plus"></i>
       Dashboard
     </a>

+ 6 - 0
public/app/core/components/manage_dashboards/manage_dashboards.ts

@@ -42,9 +42,12 @@ export class ManageDashboardsCtrl {
   // if user has editor role or higher
   isEditor: boolean;
 
+  hasEditPermissionInFolders: boolean;
+
   /** @ngInject */
   constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
     this.isEditor = this.contextSrv.isEditor;
+    this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders;
 
     this.query = {
       query: '',
@@ -80,6 +83,9 @@ export class ManageDashboardsCtrl {
 
         return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
           this.canSave = folder.canSave;
+          if (!this.canSave) {
+            this.hasEditPermissionInFolders = false;
+          }
         });
       });
   }

+ 3 - 3
public/app/core/components/search/search.html

@@ -45,14 +45,14 @@
         </tag-filter>
       </div>
 
-      <div class="search-filter-box" ng-if="ctrl.isEditor">
+      <div class="search-filter-box" ng-if="ctrl.isEditor || ctrl.hasEditPermissionInFolders">
         <a href="dashboard/new" class="search-filter-box-link">
           <i class="gicon gicon-dashboard-new"></i> New dashboard
         </a>
-        <a href="dashboards/folder/new" class="search-filter-box-link">
+        <a href="dashboards/folder/new" class="search-filter-box-link" ng-if="ctrl.isEditor">
           <i class="gicon gicon-folder-new"></i> New folder
         </a>
-        <a href="dashboard/import" class="search-filter-box-link">
+        <a href="dashboard/import" class="search-filter-box-link" ng-if="ctrl.isEditor">
           <i class="gicon gicon-dashboard-import"></i> Import dashboard
         </a>
         <a class="search-filter-box-link" target="_blank" href="https://grafana.com/dashboards?utm_source=grafana_search">

+ 2 - 0
public/app/core/components/search/search.ts

@@ -17,6 +17,7 @@ export class SearchCtrl {
   isLoading: boolean;
   initialFolderFilterTitle: string;
   isEditor: string;
+  hasEditPermissionInFolders: boolean;
 
   /** @ngInject */
   constructor($scope, private $location, private $timeout, private searchSrv: SearchSrv) {
@@ -27,6 +28,7 @@ export class SearchCtrl {
     this.getTags = this.getTags.bind(this);
     this.onTagSelect = this.onTagSelect.bind(this);
     this.isEditor = contextSrv.isEditor;
+    this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders;
   }
 
   closeSearch() {

+ 7 - 2
public/app/core/services/keybindingSrv.ts

@@ -14,7 +14,7 @@ export class KeybindingSrv {
   timepickerOpen = false;
 
   /** @ngInject */
-  constructor(private $rootScope, private $location, private datasourceSrv) {
+  constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv) {
     // clear out all shortcuts on route change
     $rootScope.$on('$routeChangeSuccess', () => {
       Mousetrap.reset();
@@ -182,7 +182,12 @@ export class KeybindingSrv {
         const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
         const datasource = await this.datasourceSrv.get(panel.datasource);
         if (datasource && datasource.supportsExplore) {
-          const exploreState = encodePathComponent(JSON.stringify(datasource.getExploreState(panel)));
+          const range = this.timeSrv.timeRangeForUrl();
+          const state = {
+            ...datasource.getExploreState(panel),
+            range,
+          };
+          const exploreState = encodePathComponent(JSON.stringify(state));
           this.$location.url(`/explore/${exploreState}`);
         }
       }

+ 31 - 0
public/app/features/dashboard/dashboard_import_ctrl.ts

@@ -7,6 +7,7 @@ export class DashboardImportCtrl {
   jsonText: string;
   parseError: string;
   nameExists: boolean;
+  uidExists: boolean;
   dash: any;
   inputs: any[];
   inputsValid: boolean;
@@ -16,6 +17,10 @@ export class DashboardImportCtrl {
   titleTouched: boolean;
   hasNameValidationError: boolean;
   nameValidationError: any;
+  hasUidValidationError: boolean;
+  uidValidationError: any;
+  autoGenerateUid: boolean;
+  autoGenerateUidValue: string;
 
   /** @ngInject */
   constructor(private backendSrv, private validationSrv, navModelSrv, private $location, $routeParams) {
@@ -23,6 +28,9 @@ export class DashboardImportCtrl {
 
     this.step = 1;
     this.nameExists = false;
+    this.uidExists = false;
+    this.autoGenerateUid = true;
+    this.autoGenerateUidValue = 'auto-generated';
 
     // check gnetId in url
     if ($routeParams.gnetId) {
@@ -61,6 +69,7 @@ export class DashboardImportCtrl {
 
     this.inputsValid = this.inputs.length === 0;
     this.titleChanged();
+    this.uidChanged(true);
   }
 
   setDatasourceOptions(input, inputModel) {
@@ -107,6 +116,28 @@ export class DashboardImportCtrl {
       });
   }
 
+  uidChanged(initial) {
+    this.uidExists = false;
+    this.hasUidValidationError = false;
+
+    if (initial === true && this.dash.uid) {
+      this.autoGenerateUidValue = 'value set';
+    }
+
+    this.backendSrv
+      .getDashboardByUid(this.dash.uid)
+      .then(res => {
+        this.uidExists = true;
+        this.hasUidValidationError = true;
+        this.uidValidationError = `Dashboard named '${res.dashboard.title}' in folder '${
+          res.meta.folderTitle
+        }' has the same uid`;
+      })
+      .catch(err => {
+        err.isHandled = true;
+      });
+  }
+
   saveDashboard() {
     var inputs = this.inputs.map(input => {
       return {

+ 25 - 1
public/app/features/dashboard/dashboard_model.ts

@@ -22,8 +22,10 @@ export class DashboardModel {
   editable: any;
   graphTooltip: any;
   time: any;
+  originalTime: any;
   timepicker: any;
   templating: any;
+  originalTemplating: any;
   annotations: any;
   refresh: any;
   snapshot: any;
@@ -68,8 +70,12 @@ export class DashboardModel {
     this.editable = data.editable !== false;
     this.graphTooltip = data.graphTooltip || 0;
     this.time = data.time || { from: 'now-6h', to: 'now' };
+    this.originalTime = _.cloneDeep(this.time);
     this.timepicker = data.timepicker || {};
     this.templating = this.ensureListExist(data.templating);
+    this.originalTemplating = _.map(this.templating.list, variable => {
+      return { name: variable.name, current: _.clone(variable.current) };
+    });
     this.annotations = this.ensureListExist(data.annotations);
     this.refresh = data.refresh;
     this.snapshot = data.snapshot;
@@ -130,7 +136,12 @@ export class DashboardModel {
   }
 
   // cleans meta data and other non persistent state
-  getSaveModelClone() {
+  getSaveModelClone(options?) {
+    let defaults = _.defaults(options || {}, {
+      saveVariables: false,
+      saveTimerange: false,
+    });
+
     // make clone
     var copy: any = {};
     for (var property in this) {
@@ -142,10 +153,23 @@ export class DashboardModel {
     }
 
     // get variable save models
+    //console.log(this.templating.list);
     copy.templating = {
       list: _.map(this.templating.list, variable => (variable.getSaveModel ? variable.getSaveModel() : variable)),
     };
 
+    if (!defaults.saveVariables && copy.templating.list.length === this.originalTemplating.length) {
+      for (let i = 0; i < copy.templating.list.length; i++) {
+        if (copy.templating.list[i].name === this.originalTemplating[i].name) {
+          copy.templating.list[i].current = this.originalTemplating[i].current;
+        }
+      }
+    }
+
+    if (!defaults.saveTimerange) {
+      copy.time = this.originalTime;
+    }
+
     // get panel save models
     copy.panels = _.chain(this.panels)
       .filter(panel => panel.type !== 'add-panel')

+ 30 - 2
public/app/features/dashboard/partials/dashboard_import.html

@@ -80,6 +80,34 @@
         </div>
       </div>
 
+      <div class="gf-form-inline">
+        <div class="gf-form gf-form--grow">
+          <span class="gf-form-label width-15">
+            Unique identifier (uid)
+            <info-popover mode="right-normal">
+                The unique identifier (uid) of a dashboard can be used for uniquely identify a dashboard between multiple Grafana installs.
+                The uid allows having consistent URL’s for accessing dashboards so changing the title of a dashboard will not break any
+                bookmarked links to that dashboard.
+            </info-popover>
+          </span>
+          <input type="text" class="gf-form-input" disabled="disabled" ng-model="ctrl.autoGenerateUidValue" ng-if="ctrl.autoGenerateUid">
+          <a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.autoGenerateUid = false" ng-if="ctrl.autoGenerateUid">change</a>
+          <input type="text" class="gf-form-input" maxlength="40" placeholder="optional, will be auto-generated if empty" ng-model="ctrl.dash.uid" ng-change="ctrl.uidChanged()" ng-if="!ctrl.autoGenerateUid">
+          <label class="gf-form-label text-success" ng-if="!ctrl.autoGenerateUid && !ctrl.hasUidValidationError">
+            <i class="fa fa-check"></i>
+          </label>
+        </div>
+      </div>
+
+      <div class="gf-form-inline" ng-if="ctrl.hasUidValidationError">
+        <div class="gf-form offset-width-15 gf-form--grow">
+          <label class="gf-form-label text-warning gf-form-label--grow">
+            <i class="fa fa-warning"></i>
+            {{ctrl.uidValidationError}}
+          </label>
+        </div>
+      </div>
+
       <div ng-repeat="input in ctrl.inputs">
         <div class="gf-form">
           <label class="gf-form-label width-15">
@@ -104,10 +132,10 @@
     </div>
 
     <div class="gf-form-button-row">
-      <button type="button" class="btn btn-success width-12" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
+      <button type="button" class="btn btn-success width-12" ng-click="ctrl.saveDashboard()" ng-hide="ctrl.nameExists || ctrl.uidExists" ng-disabled="!ctrl.inputsValid">
         <i class="fa fa-save"></i> Import
       </button>
-      <button type="button" class="btn btn-danger width-12" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists" ng-disabled="!ctrl.inputsValid">
+      <button type="button" class="btn btn-danger width-12" ng-click="ctrl.saveDashboard()" ng-show="ctrl.nameExists || ctrl.uidExists" ng-disabled="!ctrl.inputsValid">
         <i class="fa fa-save"></i> Import (Overwrite)
       </button>
       <a class="btn btn-link" ng-click="ctrl.back()">Cancel</a>

+ 59 - 6
public/app/features/dashboard/save_modal.ts

@@ -1,4 +1,5 @@
 import coreModule from 'app/core/core_module';
+import _ from 'lodash';
 
 const template = `
 <div class="modal-body">
@@ -14,19 +15,29 @@ const template = `
   </div>
 
   <form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate>
-    <h6 class="text-center">Add a note to describe your changes</h6>
-    <div class="p-t-2">
+    <div class="p-t-1">
+      <div class="gf-form-group" ng-if="ctrl.timeChange || ctrl.variableChange">
+		    <gf-form-switch class="gf-form"
+			    label="Save current time range" ng-if="ctrl.timeChange" label-class="width-12" switch-class="max-width-6"
+			    checked="ctrl.saveTimerange" on-change="buildUrl()">
+		    </gf-form-switch>
+		    <gf-form-switch class="gf-form"
+			    label="Save current variables" ng-if="ctrl.variableChange" label-class="width-12" switch-class="max-width-6"
+			    checked="ctrl.saveVariables" on-change="buildUrl()">
+		    </gf-form-switch>
+	    </div>
       <div class="gf-form">
         <label class="gf-form-hint">
           <input
             type="text"
             name="message"
             class="gf-form-input"
-            placeholder="Updates to &hellip;"
+            placeholder="Add a note to describe your changes &hellip;"
             give-focus="true"
             ng-model="ctrl.message"
             ng-model-options="{allowInvalid: true}"
             ng-maxlength="this.max"
+            maxlength="64"
             autocomplete="off" />
           <small class="gf-form-hint-text muted" ng-cloak>
             <span ng-class="{'text-error': ctrl.saveForm.message.$invalid && ctrl.saveForm.message.$dirty }">
@@ -40,7 +51,7 @@ const template = `
 
     <div class="gf-form-button-row text-center">
       <button type="submit" class="btn btn-success" ng-disabled="ctrl.saveForm.$invalid">Save</button>
-      <button class="btn btn-inverse" ng-click="ctrl.dismiss();">Cancel</button>
+      <a class="btn btn-link" ng-click="ctrl.dismiss();">Cancel</a>
     </div>
   </form>
 </div>
@@ -48,14 +59,51 @@ const template = `
 
 export class SaveDashboardModalCtrl {
   message: string;
+  saveVariables = false;
+  saveTimerange = false;
+  templating: any;
+  time: any;
+  originalTime: any;
+  current = [];
+  originalCurrent = [];
   max: number;
   saveForm: any;
   dismiss: () => void;
+  timeChange = false;
+  variableChange = false;
 
   /** @ngInject */
   constructor(private dashboardSrv) {
     this.message = '';
     this.max = 64;
+    this.templating = dashboardSrv.dash.templating.list;
+
+    this.compareTemplating();
+    this.compareTime();
+  }
+
+  compareTime() {
+    if (_.isEqual(this.dashboardSrv.dash.time, this.dashboardSrv.dash.originalTime)) {
+      this.timeChange = false;
+    } else {
+      this.timeChange = true;
+    }
+  }
+
+  compareTemplating() {
+    if (this.dashboardSrv.dash.templating.list.length > 0) {
+      for (let i = 0; i < this.dashboardSrv.dash.templating.list.length; i++) {
+        if (
+          this.dashboardSrv.dash.templating.list[i].current.text !==
+          this.dashboardSrv.dash.originalTemplating[i].current.text
+        ) {
+          return (this.variableChange = true);
+        }
+      }
+      return (this.variableChange = false);
+    } else {
+      return (this.variableChange = false);
+    }
   }
 
   save() {
@@ -63,9 +111,14 @@ export class SaveDashboardModalCtrl {
       return;
     }
 
+    var options = {
+      saveVariables: this.saveVariables,
+      saveTimerange: this.saveTimerange,
+      message: this.message,
+    };
+
     var dashboard = this.dashboardSrv.getCurrent();
-    var saveModel = dashboard.getSaveModelClone();
-    var options = { message: this.message };
+    var saveModel = dashboard.getSaveModelClone(options);
 
     return this.dashboardSrv.save(saveModel, options).then(this.dismiss);
   }

+ 1 - 0
public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts

@@ -15,6 +15,7 @@ describe('DashboardImportCtrl', function() {
 
     backendSrv = {
       search: jest.fn().mockReturnValue(Promise.resolve([])),
+      getDashboardByUid: jest.fn().mockReturnValue(Promise.resolve([])),
       get: jest.fn(),
     };
 

+ 59 - 0
public/app/features/dashboard/specs/dashboard_model.jest.ts

@@ -434,4 +434,63 @@ describe('DashboardModel', function() {
       });
     });
   });
+
+  describe('save variables and timeline', () => {
+    let model;
+
+    beforeEach(() => {
+      model = new DashboardModel({
+        templating: {
+          list: [
+            {
+              name: 'Server',
+              current: {
+                selected: true,
+                text: 'server_001',
+                value: 'server_001',
+              },
+            },
+          ],
+        },
+        time: {
+          from: 'now-6h',
+          to: 'now',
+        },
+      });
+      model.templating.list[0] = {
+        name: 'Server',
+        current: {
+          selected: true,
+          text: 'server_002',
+          value: 'server_002',
+        },
+      };
+      model.time = {
+        from: 'now-3h',
+        to: 'now',
+      };
+    });
+
+    it('should not save variables and timeline', () => {
+      let options = {
+        saveVariables: false,
+        saveTimerange: false,
+      };
+      let saveModel = model.getSaveModelClone(options);
+
+      expect(saveModel.templating.list[0].current.text).toBe('server_001');
+      expect(saveModel.time.from).toBe('now-6h');
+    });
+
+    it('should save variables and timeline', () => {
+      let options = {
+        saveVariables: true,
+        saveTimerange: true,
+      };
+      let saveModel = model.getSaveModelClone(options);
+
+      expect(saveModel.templating.list[0].current.text).toBe('server_002');
+      expect(saveModel.time.from).toBe('now-3h');
+    });
+  });
 });

+ 90 - 0
public/app/features/dashboard/specs/save_modal.jest.ts

@@ -0,0 +1,90 @@
+import { SaveDashboardModalCtrl } from '../save_modal';
+
+jest.mock('app/core/services/context_srv', () => ({}));
+
+describe('SaveDashboardModal', () => {
+  describe('save modal checkboxes', () => {
+    it('should show checkboxes', () => {
+      let fakeDashboardSrv = {
+        dash: {
+          templating: {
+            list: [
+              {
+                current: {
+                  selected: true,
+                  tags: Array(0),
+                  text: 'server_001',
+                  value: 'server_001',
+                },
+                name: 'Server',
+              },
+            ],
+          },
+          originalTemplating: [
+            {
+              current: {
+                selected: true,
+                text: 'server_002',
+                value: 'server_002',
+              },
+              name: 'Server',
+            },
+          ],
+          time: {
+            from: 'now-3h',
+            to: 'now',
+          },
+          originalTime: {
+            from: 'now-6h',
+            to: 'now',
+          },
+        },
+      };
+      let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
+
+      expect(modal.timeChange).toBe(true);
+      expect(modal.variableChange).toBe(true);
+    });
+
+    it('should hide checkboxes', () => {
+      let fakeDashboardSrv = {
+        dash: {
+          templating: {
+            list: [
+              {
+                current: {
+                  selected: true,
+                  //tags: Array(0),
+                  text: 'server_002',
+                  value: 'server_002',
+                },
+                name: 'Server',
+              },
+            ],
+          },
+          originalTemplating: [
+            {
+              current: {
+                selected: true,
+                text: 'server_002',
+                value: 'server_002',
+              },
+              name: 'Server',
+            },
+          ],
+          time: {
+            from: 'now-3h',
+            to: 'now',
+          },
+          originalTime: {
+            from: 'now-3h',
+            to: 'now',
+          },
+        },
+      };
+      let modal = new SaveDashboardModalCtrl(fakeDashboardSrv);
+      expect(modal.timeChange).toBe(false);
+      expect(modal.variableChange).toBe(false);
+    });
+  });
+});

+ 6 - 1
public/app/features/panel/metrics_panel_ctrl.ts

@@ -324,7 +324,12 @@ class MetricsPanelCtrl extends PanelCtrl {
   }
 
   explore() {
-    const exploreState = encodePathComponent(JSON.stringify(this.datasource.getExploreState(this.panel)));
+    const range = this.timeSrv.timeRangeForUrl();
+    const state = {
+      ...this.datasource.getExploreState(this.panel),
+      range,
+    };
+    const exploreState = encodePathComponent(JSON.stringify(state));
     this.$location.url(`/explore/${exploreState}`);
   }
 

+ 9 - 11
public/app/features/plugins/plugin_loader.ts

@@ -27,6 +27,13 @@ import 'rxjs/add/observable/from';
 import 'rxjs/add/operator/map';
 import 'rxjs/add/operator/combineAll';
 
+// add cache busting
+const bust = `?_cache=${Date.now()}`;
+function locate(load) {
+  return load.address + bust;
+}
+System.registry.set('plugin-loader', System.newModule({ locate: locate }));
+
 System.config({
   baseURL: 'public',
   defaultExtension: 'js',
@@ -40,23 +47,14 @@ System.config({
     css: 'vendor/plugin-css/css.js',
   },
   meta: {
-    '*': {
+    'plugin*': {
       esModule: true,
       authorization: true,
+      loader: 'plugin-loader',
     },
   },
 });
 
-// add cache busting
-var systemLocate = System.locate;
-System.cacheBust = '?bust=' + Date.now();
-System.locate = function(load) {
-  var System = this;
-  return Promise.resolve(systemLocate.call(this, load)).then(function(address) {
-    return address + System.cacheBust;
-  });
-};
-
 function exposeToPlugin(name: string, component: any) {
   System.registerDynamic(name, [], true, function(require, exports, module) {
     module.exports = component;

+ 23 - 0
public/app/plugins/datasource/influxdb/query_part.ts

@@ -44,6 +44,28 @@ function replaceAggregationAddStrategy(selectParts, partModel) {
   for (var i = 0; i < selectParts.length; i++) {
     var part = selectParts[i];
     if (part.def.category === categories.Aggregations) {
+      if (part.def.type === partModel.def.type) {
+        return;
+      }
+      // count distinct is allowed
+      if (part.def.type === 'count' && partModel.def.type === 'distinct') {
+        break;
+      }
+      // remove next aggregation if distinct was replaced
+      if (part.def.type === 'distinct') {
+        var morePartsAvailable = selectParts.length >= i + 2;
+        if (partModel.def.type !== 'count' && morePartsAvailable) {
+          var nextPart = selectParts[i + 1];
+          if (nextPart.def.category === categories.Aggregations) {
+            selectParts.splice(i + 1, 1);
+          }
+        } else if (partModel.def.type === 'count') {
+          if (!morePartsAvailable || selectParts[i + 1].def.type !== 'count') {
+            selectParts.splice(i + 1, 0, partModel);
+          }
+          return;
+        }
+      }
       selectParts[i] = partModel;
       return;
     }
@@ -434,4 +456,5 @@ export default {
   getCategories: function() {
     return categories;
   },
+  replaceAggregationAdd: replaceAggregationAddStrategy,
 };

+ 144 - 0
public/app/plugins/datasource/influxdb/specs/query_part.jest.ts

@@ -40,5 +40,149 @@ describe('InfluxQueryPart', () => {
       expect(part.text).toBe('alias(test)');
       expect(part.render('mean(value)')).toBe('mean(value) AS "test"');
     });
+
+    it('should nest distinct when count is selected', () => {
+      var selectParts = [
+        queryPart.create({
+          type: 'field',
+          category: queryPart.getCategories().Fields,
+        }),
+        queryPart.create({
+          type: 'count',
+          category: queryPart.getCategories().Aggregations,
+        }),
+      ];
+      var partModel = queryPart.create({
+        type: 'distinct',
+        category: queryPart.getCategories().Aggregations,
+      });
+
+      queryPart.replaceAggregationAdd(selectParts, partModel);
+
+      expect(selectParts[1].text).toBe('distinct()');
+      expect(selectParts[2].text).toBe('count()');
+    });
+
+    it('should convert to count distinct when distinct is selected and count added', () => {
+      var selectParts = [
+        queryPart.create({
+          type: 'field',
+          category: queryPart.getCategories().Fields,
+        }),
+        queryPart.create({
+          type: 'distinct',
+          category: queryPart.getCategories().Aggregations,
+        }),
+      ];
+      var partModel = queryPart.create({
+        type: 'count',
+        category: queryPart.getCategories().Aggregations,
+      });
+
+      queryPart.replaceAggregationAdd(selectParts, partModel);
+
+      expect(selectParts[1].text).toBe('distinct()');
+      expect(selectParts[2].text).toBe('count()');
+    });
+
+    it('should replace count distinct if an aggregation is selected', () => {
+      var selectParts = [
+        queryPart.create({
+          type: 'field',
+          category: queryPart.getCategories().Fields,
+        }),
+        queryPart.create({
+          type: 'distinct',
+          category: queryPart.getCategories().Aggregations,
+        }),
+        queryPart.create({
+          type: 'count',
+          category: queryPart.getCategories().Aggregations,
+        }),
+      ];
+      var partModel = queryPart.create({
+        type: 'mean',
+        category: queryPart.getCategories().Selectors,
+      });
+
+      queryPart.replaceAggregationAdd(selectParts, partModel);
+
+      expect(selectParts[1].text).toBe('mean()');
+      expect(selectParts).toHaveLength(2);
+    });
+
+    it('should not allowed nested counts when count distinct is selected', () => {
+      var selectParts = [
+        queryPart.create({
+          type: 'field',
+          category: queryPart.getCategories().Fields,
+        }),
+        queryPart.create({
+          type: 'distinct',
+          category: queryPart.getCategories().Aggregations,
+        }),
+        queryPart.create({
+          type: 'count',
+          category: queryPart.getCategories().Aggregations,
+        }),
+      ];
+      var partModel = queryPart.create({
+        type: 'count',
+        category: queryPart.getCategories().Aggregations,
+      });
+
+      queryPart.replaceAggregationAdd(selectParts, partModel);
+
+      expect(selectParts[1].text).toBe('distinct()');
+      expect(selectParts[2].text).toBe('count()');
+      expect(selectParts).toHaveLength(3);
+    });
+
+    it('should not remove count distinct when distinct is added', () => {
+      var selectParts = [
+        queryPart.create({
+          type: 'field',
+          category: queryPart.getCategories().Fields,
+        }),
+        queryPart.create({
+          type: 'distinct',
+          category: queryPart.getCategories().Aggregations,
+        }),
+        queryPart.create({
+          type: 'count',
+          category: queryPart.getCategories().Aggregations,
+        }),
+      ];
+      var partModel = queryPart.create({
+        type: 'distinct',
+        category: queryPart.getCategories().Aggregations,
+      });
+
+      queryPart.replaceAggregationAdd(selectParts, partModel);
+
+      expect(selectParts[1].text).toBe('distinct()');
+      expect(selectParts[2].text).toBe('count()');
+      expect(selectParts).toHaveLength(3);
+    });
+
+    it('should remove distinct when sum aggregation is selected', () => {
+      var selectParts = [
+        queryPart.create({
+          type: 'field',
+          category: queryPart.getCategories().Fields,
+        }),
+        queryPart.create({
+          type: 'distinct',
+          category: queryPart.getCategories().Aggregations,
+        }),
+      ];
+      var partModel = queryPart.create({
+        type: 'sum',
+        category: queryPart.getCategories().Aggregations,
+      });
+      queryPart.replaceAggregationAdd(selectParts, partModel);
+
+      expect(selectParts[1].text).toBe('sum()');
+    });
   });
 });

+ 11 - 10
public/app/plugins/datasource/prometheus/datasource.ts

@@ -7,6 +7,15 @@ import PrometheusMetricFindQuery from './metric_find_query';
 import { ResultTransformer } from './result_transformer';
 import { BackendSrv } from 'app/core/services/backend_srv';
 
+export function alignRange(start, end, step) {
+  const alignedEnd = Math.ceil(end / step) * step;
+  const alignedStart = Math.floor(start / step) * step;
+  return {
+    end: alignedEnd,
+    start: alignedStart,
+  };
+}
+
 export function prometheusRegularEscape(value) {
   return value.replace(/'/g, "\\\\'");
 }
@@ -109,15 +118,6 @@ export class PrometheusDatasource {
     return this.templateSrv.variableExists(target.expr);
   }
 
-  clampRange(start, end, step) {
-    const clampedEnd = Math.ceil(end / step) * step;
-    const clampedRange = Math.floor((end - start) / step) * step;
-    return {
-      end: clampedEnd,
-      start: clampedEnd - clampedRange,
-    };
-  }
-
   query(options) {
     var start = this.getPrometheusTime(options.range.from, false);
     var end = this.getPrometheusTime(options.range.to, true);
@@ -164,6 +164,7 @@ export class PrometheusDatasource {
           legendFormat: activeTargets[index].legendFormat,
           start: start,
           end: end,
+          query: queries[index].expr,
           responseListLength: responseList.length,
           responseIndex: index,
           refId: activeTargets[index].refId,
@@ -205,7 +206,7 @@ export class PrometheusDatasource {
     query.requestId = options.panelId + target.refId;
 
     // Align query interval with step
-    const adjusted = this.clampRange(start, end, query.step);
+    const adjusted = alignRange(start, end, query.step);
     query.start = adjusted.start;
     query.end = adjusted.end;
 

+ 8 - 3
public/app/plugins/datasource/prometheus/result_transformer.ts

@@ -123,11 +123,16 @@ export class ResultTransformer {
   }
 
   createMetricLabel(labelData, options) {
+    let label = '';
     if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) {
-      return this.getOriginalMetricName(labelData);
+      label = this.getOriginalMetricName(labelData);
+    } else {
+      label = this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData);
     }
-
-    return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}';
+    if (!label || label === '{}') {
+      label = options.query;
+    }
+    return label;
   }
 
   renderTemplate(aliasPattern, aliasData) {

+ 24 - 1
public/app/plugins/datasource/prometheus/specs/datasource.jest.ts

@@ -1,7 +1,7 @@
 import _ from 'lodash';
 import moment from 'moment';
 import q from 'q';
-import { PrometheusDatasource, prometheusSpecialRegexEscape, prometheusRegularEscape } from '../datasource';
+import { alignRange, PrometheusDatasource, prometheusSpecialRegexEscape, prometheusRegularEscape } from '../datasource';
 
 describe('PrometheusDatasource', () => {
   let ctx: any = {};
@@ -142,6 +142,29 @@ describe('PrometheusDatasource', () => {
     });
   });
 
+  describe('alignRange', function() {
+    it('does not modify already aligned intervals with perfect step', function() {
+      const range = alignRange(0, 3, 3);
+      expect(range.start).toEqual(0);
+      expect(range.end).toEqual(3);
+    });
+    it('does modify end-aligned intervals to reflect number of steps possible', function() {
+      const range = alignRange(1, 6, 3);
+      expect(range.start).toEqual(0);
+      expect(range.end).toEqual(6);
+    });
+    it('does align intervals that are a multiple of steps', function() {
+      const range = alignRange(1, 4, 3);
+      expect(range.start).toEqual(0);
+      expect(range.end).toEqual(6);
+    });
+    it('does align intervals that are not a multiple of steps', function() {
+      const range = alignRange(1, 5, 3);
+      expect(range.start).toEqual(0);
+      expect(range.end).toEqual(6);
+    });
+  });
+
   describe('Prometheus regular escaping', function() {
     it('should not escape simple string', function() {
       expect(prometheusRegularEscape('cryptodepression')).toEqual('cryptodepression');

+ 8 - 31
public/app/plugins/datasource/prometheus/specs/datasource_specs.ts

@@ -44,7 +44,7 @@ describe('PrometheusDatasource', function() {
     };
     // Interval alignment with step
     var urlExpected =
-      'proxied/api/v1/query_range?query=' + encodeURIComponent('test{job="testjob"}') + '&start=120&end=240&step=60';
+      'proxied/api/v1/query_range?query=' + encodeURIComponent('test{job="testjob"}') + '&start=60&end=240&step=60';
     var response = {
       status: 'success',
       data: {
@@ -181,7 +181,7 @@ describe('PrometheusDatasource', function() {
     var urlExpected =
       'proxied/api/v1/query_range?query=' +
       encodeURIComponent('ALERTS{alertstate="firing"}') +
-      '&start=120&end=180&step=60';
+      '&start=60&end=180&step=60';
     var options = {
       annotation: {
         expr: 'ALERTS{alertstate="firing"}',
@@ -348,7 +348,7 @@ describe('PrometheusDatasource', function() {
         interval: '5s',
       };
       // times get rounded up to interval
-      var urlExpected = 'proxied/api/v1/query_range?query=test&start=100&end=450&step=50';
+      var urlExpected = 'proxied/api/v1/query_range?query=test&start=50&end=450&step=50';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -384,8 +384,8 @@ describe('PrometheusDatasource', function() {
         ],
         interval: '10s',
       };
-      // times get rounded up to interval
-      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=200&end=500&step=100';
+      // times get aligned to interval
+      var urlExpected = 'proxied/api/v1/query_range?query=test' + '&start=0&end=500&step=100';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -511,7 +511,7 @@ describe('PrometheusDatasource', function() {
         },
       };
       var urlExpected =
-        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[100s])') + '&start=200&end=500&step=100';
+        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[100s])') + '&start=0&end=500&step=100';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -539,7 +539,7 @@ describe('PrometheusDatasource', function() {
         },
       };
       var urlExpected =
-        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[50s])') + '&start=100&end=450&step=50';
+        'proxied/api/v1/query_range?query=' + encodeURIComponent('rate(test[50s])') + '&start=50&end=450&step=50';
       ctx.$httpBackend.expect('GET', urlExpected).respond(response);
       ctx.ds.query(query);
       ctx.$httpBackend.verifyNoOutstandingExpectation();
@@ -613,29 +613,6 @@ describe('PrometheusDatasource', function() {
       expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
     });
   });
-
-  describe('Step alignment of intervals', function() {
-    it('does not modify already aligned intervals with perfect step', function() {
-      const range = ctx.ds.clampRange(0, 3, 3);
-      expect(range.start).to.be(0);
-      expect(range.end).to.be(3);
-    });
-    it('does modify end-aligned intervals to reflect number of steps possible', function() {
-      const range = ctx.ds.clampRange(1, 6, 3);
-      expect(range.start).to.be(3);
-      expect(range.end).to.be(6);
-    });
-    it('does align intervals that are a multiple of steps', function() {
-      const range = ctx.ds.clampRange(1, 4, 3);
-      expect(range.start).to.be(3);
-      expect(range.end).to.be(6);
-    });
-    it('does align intervals that are not a multiple of steps', function() {
-      const range = ctx.ds.clampRange(1, 5, 3);
-      expect(range.start).to.be(3);
-      expect(range.end).to.be(6);
-    });
-  });
 });
 
 describe('PrometheusDatasource for POST', function() {
@@ -667,7 +644,7 @@ describe('PrometheusDatasource for POST', function() {
     var urlExpected = 'proxied/api/v1/query_range';
     var dataExpected = $.param({
       query: 'test{job="testjob"}',
-      start: 2 * 60,
+      start: 1 * 60,
       end: 3 * 60,
       step: 60,
     });

+ 1 - 1
public/app/plugins/panel/singlestat/editor.html

@@ -61,7 +61,7 @@
     <div class="gf-form-inline">
       <div class="gf-form max-width-21">
         <label class="gf-form-label width-8">Thresholds
-          <tip>Define two threshold values&lt;br /&gt; 50,80 will produce: &lt;50 = Green, 50:80 = Yellow, &gt;80 = Red</tip>
+          <tip>Define two threshold values&lt;br /&gt; 50,80 will produce: value &lt; 50 = Green, 50 &lt;= value &lt; 80 = Yellow, value &gt;= 80 = Red</tip>
         </label>
         <input type="text" class="gf-form-input" ng-model="ctrl.panel.thresholds" ng-blur="ctrl.render()" placeholder="50,80"></input>
       </div>

+ 2 - 0
public/app/plugins/panel/singlestat/module.ts

@@ -714,11 +714,13 @@ function getColorForValue(data, value) {
   if (!_.isFinite(value)) {
     return null;
   }
+
   for (var i = data.thresholds.length; i > 0; i--) {
     if (value >= data.thresholds[i - 1]) {
       return data.colorMap[i];
     }
   }
+
   return _.first(data.colorMap);
 }
 

+ 1 - 2
public/app/routes/routes.ts

@@ -3,7 +3,6 @@ import './ReactContainer';
 
 import ServerStats from 'app/containers/ServerStats/ServerStats';
 import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
-// import Explore from 'app/containers/Explore/Explore';
 import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
 import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
 
@@ -114,7 +113,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
     .when('/explore/:initial?', {
       template: '<react-container />',
       resolve: {
-        component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
+        component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Wrapper'),
       },
     })
     .when('/org', {

+ 12 - 0
public/sass/_variables.dark.scss

@@ -45,6 +45,10 @@ $brand-warning: $brand-primary;
 $brand-danger: $red;
 
 $query-blue: $blue;
+$query-red: $red;
+$query-green: $green;
+$query-purple: $purple;
+$query-orange: $orange;
 
 // Status colors
 // -------------------------
@@ -176,6 +180,9 @@ $btn-inverse-bg-hl: lighten($dark-3, 4%);
 $btn-inverse-text-color: $link-color;
 $btn-inverse-text-shadow: 0px 1px 0 rgba(0, 0, 0, 0.1);
 
+$btn-active-bg: $gray-4;
+$btn-active-text-color: $blue-dark;
+
 $btn-link-color: $gray-3;
 
 $iconContainerBackground: $black;
@@ -204,6 +211,11 @@ $input-invalid-border-color: lighten($red, 5%);
 $search-shadow: 0 0 30px 0 $black;
 $search-filter-box-bg: $gray-blue;
 
+// Typeahead
+$typeahead-shadow: 0 5px 10px 0 $black;
+$typeahead-selected-bg: $dark-4;
+$typeahead-selected-color: $blue;
+
 // Dropdowns
 // -------------------------
 $dropdownBackground: $dark-3;

+ 12 - 0
public/sass/_variables.light.scss

@@ -46,6 +46,10 @@ $brand-warning: $orange;
 $brand-danger: $red;
 
 $query-blue: $blue-dark;
+$query-red: $red;
+$query-green: $green;
+$query-purple: $purple;
+$query-orange: $orange;
 
 // Status colors
 // -------------------------
@@ -173,6 +177,9 @@ $btn-inverse-bg-hl: darken($gray-6, 5%);
 $btn-inverse-text-color: $gray-1;
 $btn-inverse-text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
 
+$btn-active-bg: $white;
+$btn-active-text-color: $blue-dark;
+
 $btn-link-color: $gray-1;
 
 $btn-divider-left: $gray-4;
@@ -226,6 +233,11 @@ $tab-border-color: $gray-5;
 $search-shadow: 0 5px 30px 0 $gray-4;
 $search-filter-box-bg: $gray-7;
 
+// Typeahead
+$typeahead-shadow: 0 5px 10px 0 $gray-5;
+$typeahead-selected-bg: lighten($blue, 25%);
+$typeahead-selected-color: $blue-dark;
+
 // Dropdowns
 // -------------------------
 $dropdownBackground: $white;

+ 14 - 0
public/sass/components/_dashboard_grid.scss

@@ -18,6 +18,20 @@
     height: 100% !important;
     transform: translate(0px, 0px) !important;
   }
+
+  // Disable grid interaction indicators in fullscreen panels
+
+  .panel-header:hover {
+    background-color: inherit;
+  }
+
+  .panel-title-container {
+    cursor: pointer;
+  }
+
+  .react-resizable-handle {
+    display: none;
+  }
 }
 
 @include media-breakpoint-down(sm) {

+ 2 - 1
public/sass/components/_view_states.scss

@@ -10,7 +10,8 @@
 
 .playlist-active,
 .user-activity-low {
-  .react-resizable-handle .add-row-panel-hint,
+  .react-resizable-handle,
+  .add-row-panel-hint,
   .dash-row-menu-container,
   .navbar-button--refresh,
   .navbar-buttons--zoom,

+ 163 - 244
public/sass/pages/_explore.scss

@@ -1,11 +1,89 @@
 .explore {
+  width: 100%;
+
+  &-container {
+    padding: 2rem;
+  }
+
+  &-wrapper {
+    display: flex;
+
+    > .explore-split {
+      width: 50%;
+    }
+  }
+
+  // Push split button a bit
+  .explore-first-button {
+    margin-left: 15px;
+  }
+
+  // Graph panel needs a bit extra padding at top
+  .panel-container {
+    padding: $panel-padding;
+    padding-top: 10px;
+  }
+
+  // Make sure wrap buttons around on small screens
+  .navbar {
+    flex-wrap: wrap;
+    height: auto;
+  }
+
+  .navbar-page-btn {
+    margin-right: 1rem;
+
+    // Explore icon in header
+    .fa {
+      font-size: 100%;
+      opacity: 0.75;
+      margin-right: 0.5em;
+    }
+  }
+
+  // Toggle mode
+  .navbar-button.active {
+    color: $btn-active-text-color;
+    background-color: $btn-active-bg;
+  }
+
+  .elapsed-time {
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 3.5rem;
+    text-align: center;
+    font-size: 0.8rem;
+  }
+
   .graph-legend {
     flex-wrap: wrap;
   }
+
+  .timepicker {
+    display: flex;
+
+    &-rangestring {
+      margin-left: 0.5em;
+    }
+  }
+
+  .run-icon {
+    margin-left: 0.5em;
+    transform: rotate(90deg);
+  }
+
+  .relative {
+    position: relative;
+  }
+}
+
+.explore + .explore {
+  border-left: 1px dotted $table-border;
 }
 
 .query-row {
-  position: relative;
+  display: flex;
 
   & + & {
     margin-top: 0.5rem;
@@ -13,17 +91,12 @@
 }
 
 .query-row-tools {
-  position: absolute;
-  left: -4rem;
-  top: 0.33rem;
-  > * {
-    margin-right: 0.25rem;
-  }
+  width: 4rem;
 }
 
 .query-field {
-  font-size: 14px;
-  font-family: Consolas, Menlo, Courier, monospace;
+  font-size: $font-size-root;
+  font-family: $font-family-monospace;
   height: auto;
 }
 
@@ -33,54 +106,52 @@
   padding: 6px 7px 4px;
   width: 100%;
   cursor: text;
-  line-height: 1.5;
-  color: rgba(0, 0, 0, 0.65);
-  background-color: #fff;
+  line-height: $line-height-base;
+  color: $text-color-weak;
+  background-color: $panel-bg;
   background-image: none;
-  border: 1px solid lightgray;
-  border-radius: 3px;
+  border: $panel-border;
+  border-radius: $border-radius;
   transition: all 0.3s;
 }
 
-.explore {
+.explore-typeahead {
   .typeahead {
     position: absolute;
     z-index: auto;
     top: -10000px;
     left: -10000px;
     opacity: 0;
-    border-radius: 4px;
+    border-radius: $border-radius;
     transition: opacity 0.75s;
-    border: 1px solid #e4e4e4;
+    border: $panel-border;
     max-height: calc(66vh);
     overflow-y: scroll;
     max-width: calc(66%);
     overflow-x: hidden;
     outline: none;
     list-style: none;
-    background: #fff;
-    color: rgba(0, 0, 0, 0.65);
+    background: $panel-bg;
+    color: $text-color;
     transition: opacity 0.4s ease-out;
+    box-shadow: $typeahead-shadow;
   }
 
   .typeahead-group__title {
-    color: rgba(0, 0, 0, 0.43);
-    font-size: 12px;
-    line-height: 1.5;
-    padding: 8px 16px;
+    color: $text-color-weak;
+    font-size: $font-size-sm;
+    line-height: $line-height-base;
+    padding: $input-padding-y $input-padding-x;
   }
 
   .typeahead-item {
-    line-height: 200%;
     height: auto;
-    font-family: Consolas, Menlo, Courier, monospace;
-    padding: 0 16px 0 28px;
-    font-size: 12px;
+    font-family: $font-family-monospace;
+    padding: $input-padding-y $input-padding-x;
+    padding-left: $input-padding-x-lg;
+    font-size: $font-size-sm;
     text-overflow: ellipsis;
     overflow: hidden;
-    margin-left: -1px;
-    left: 1px;
-    position: relative;
     z-index: 1;
     display: block;
     white-space: nowrap;
@@ -90,234 +161,82 @@
   }
 
   .typeahead-item__selected {
-    background-color: #ecf6fd;
-    color: #108ee9;
+    background-color: $typeahead-selected-bg;
+    color: $typeahead-selected-color;
   }
 }
 
 /* SYNTAX */
 
-/**
- * prism.js Coy theme for JavaScript, CoffeeScript, CSS and HTML
- * Based on https://github.com/tshedor/workshop-wp-theme (Example: http://workshop.kansan.com/category/sessions/basics or http://workshop.timshedor.com/category/sessions/basics);
- * @author Tim  Shedor
- */
-
-code[class*='language-'],
-pre[class*='language-'] {
-  color: black;
-  background: none;
-  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
-  text-align: left;
-  white-space: pre;
-  word-spacing: normal;
-  word-break: normal;
-  word-wrap: normal;
-  line-height: 1.5;
-
-  -moz-tab-size: 4;
-  -o-tab-size: 4;
-  tab-size: 4;
-
-  -webkit-hyphens: none;
-  -moz-hyphens: none;
-  -ms-hyphens: none;
-  hyphens: none;
-}
-
-/* Code blocks */
-pre[class*='language-'] {
-  position: relative;
-  margin: 0.5em 0;
-  overflow: visible;
-  padding: 0;
-}
-pre[class*='language-'] > code {
-  position: relative;
-  border-left: 10px solid #358ccb;
-  box-shadow: -1px 0px 0px 0px #358ccb, 0px 0px 0px 1px #dfdfdf;
-  background-color: #fdfdfd;
-  background-image: linear-gradient(transparent 50%, rgba(69, 142, 209, 0.04) 50%);
-  background-size: 3em 3em;
-  background-origin: content-box;
-  background-attachment: local;
-}
-
-code[class*='language'] {
-  max-height: inherit;
-  height: inherit;
-  padding: 0 1em;
-  display: block;
-  overflow: auto;
-}
-
-/* Margin bottom to accomodate shadow */
-:not(pre) > code[class*='language-'],
-pre[class*='language-'] {
-  background-color: #fdfdfd;
-  -webkit-box-sizing: border-box;
-  -moz-box-sizing: border-box;
-  box-sizing: border-box;
-  margin-bottom: 1em;
-}
-
-/* Inline code */
-:not(pre) > code[class*='language-'] {
-  position: relative;
-  padding: 0.2em;
-  border-radius: 0.3em;
-  color: #c92c2c;
-  border: 1px solid rgba(0, 0, 0, 0.1);
-  display: inline;
-  white-space: normal;
-}
-
-pre[class*='language-']:before,
-pre[class*='language-']:after {
-  content: '';
-  z-index: -2;
-  display: block;
-  position: absolute;
-  bottom: 0.75em;
-  left: 0.18em;
-  width: 40%;
-  height: 20%;
-  max-height: 13em;
-  box-shadow: 0px 13px 8px #979797;
-  -webkit-transform: rotate(-2deg);
-  -moz-transform: rotate(-2deg);
-  -ms-transform: rotate(-2deg);
-  -o-transform: rotate(-2deg);
-  transform: rotate(-2deg);
-}
-
-:not(pre) > code[class*='language-']:after,
-pre[class*='language-']:after {
-  right: 0.75em;
-  left: auto;
-  -webkit-transform: rotate(2deg);
-  -moz-transform: rotate(2deg);
-  -ms-transform: rotate(2deg);
-  -o-transform: rotate(2deg);
-  transform: rotate(2deg);
-}
-
-.token.comment,
-.token.block-comment,
-.token.prolog,
-.token.doctype,
-.token.cdata {
-  color: #7d8b99;
-}
-
-.token.punctuation {
-  color: #5f6364;
-}
-
-.token.property,
-.token.tag,
-.token.boolean,
-.token.number,
-.token.function-name,
-.token.constant,
-.token.symbol,
-.token.deleted {
-  color: #c92c2c;
-}
-
-.token.selector,
-.token.attr-name,
-.token.string,
-.token.char,
-.token.function,
-.token.builtin,
-.token.inserted {
-  color: #2f9c0a;
-}
-
-.token.operator,
-.token.entity,
-.token.url,
-.token.variable {
-  color: #a67f59;
-  background: rgba(255, 255, 255, 0.5);
-}
-
-.token.atrule,
-.token.attr-value,
-.token.keyword,
-.token.class-name {
-  color: #1990b8;
-}
-
-.token.regex,
-.token.important {
-  color: #e90;
-}
-
-.language-css .token.string,
-.style .token.string {
-  color: #a67f59;
-  background: rgba(255, 255, 255, 0.5);
-}
+.explore {
+  .token.comment,
+  .token.block-comment,
+  .token.prolog,
+  .token.doctype,
+  .token.cdata {
+    color: $text-color-weak;
+  }
 
-.token.important {
-  font-weight: normal;
-}
+  .token.punctuation {
+    color: $text-color-weak;
+  }
 
-.token.bold {
-  font-weight: bold;
-}
-.token.italic {
-  font-style: italic;
-}
+  .token.property,
+  .token.tag,
+  .token.boolean,
+  .token.number,
+  .token.function-name,
+  .token.constant,
+  .token.symbol,
+  .token.deleted {
+    color: $query-red;
+  }
 
-.token.entity {
-  cursor: help;
-}
+  .token.selector,
+  .token.attr-name,
+  .token.string,
+  .token.char,
+  .token.function,
+  .token.builtin,
+  .token.inserted {
+    color: $query-green;
+  }
 
-.namespace {
-  opacity: 0.7;
-}
+  .token.operator,
+  .token.entity,
+  .token.url,
+  .token.variable {
+    color: $query-purple;
+  }
 
-@media screen and (max-width: 767px) {
-  pre[class*='language-']:before,
-  pre[class*='language-']:after {
-    bottom: 14px;
-    box-shadow: none;
+  .token.atrule,
+  .token.attr-value,
+  .token.keyword,
+  .token.class-name {
+    color: $query-blue;
   }
-}
 
-/* Plugin styles */
-.token.tab:not(:empty):before,
-.token.cr:before,
-.token.lf:before {
-  color: #e0d7d1;
-}
+  .token.regex,
+  .token.important {
+    color: $query-orange;
+  }
 
-/* Plugin styles: Line Numbers */
-pre[class*='language-'].line-numbers {
-  padding-left: 0;
-}
+  .token.important {
+    font-weight: normal;
+  }
 
-pre[class*='language-'].line-numbers code {
-  padding-left: 3.8em;
-}
+  .token.bold {
+    font-weight: bold;
+  }
+  .token.italic {
+    font-style: italic;
+  }
 
-pre[class*='language-'].line-numbers .line-numbers-rows {
-  left: 0;
-}
+  .token.entity {
+    cursor: help;
+  }
 
-/* Plugin styles: Line Highlight */
-pre[class*='language-'][data-line] {
-  padding-top: 0;
-  padding-bottom: 0;
-  padding-left: 0;
-}
-pre[data-line] code {
-  position: relative;
-  padding-left: 4em;
-}
-pre .line-highlight {
-  margin-top: 0;
+  .namespace {
+    opacity: 0.7;
+  }
 }

+ 13 - 12
public/vendor/plugin-css/css.js

@@ -1,6 +1,7 @@
 "use strict";
 
 if (typeof window !== 'undefined') {
+  var bust = '?_cache=' + Date.now();
   var waitSeconds = 100;
 
   var head = document.getElementsByTagName('head')[0];
@@ -13,8 +14,8 @@ if (typeof window !== 'undefined') {
   }
 
   var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/);
-  var webkitLoadCheck = function(link, callback) {
-    setTimeout(function() {
+  var webkitLoadCheck = function (link, callback) {
+    setTimeout(function () {
       for (var i = 0; i < document.styleSheets.length; i++) {
         var sheet = document.styleSheets[i];
         if (sheet.href === link.href) {
@@ -25,17 +26,17 @@ if (typeof window !== 'undefined') {
     }, 10);
   };
 
-  var noop = function() {};
+  var noop = function () { };
 
-  var loadCSS = function(url) {
-    return new Promise(function(resolve, reject) {
-      var timeout = setTimeout(function() {
+  var loadCSS = function (url) {
+    return new Promise(function (resolve, reject) {
+      var timeout = setTimeout(function () {
         reject('Unable to load CSS');
       }, waitSeconds * 1000);
-      var _callback = function(error) {
+      var _callback = function (error) {
         clearTimeout(timeout);
         link.onload = link.onerror = noop;
-        setTimeout(function() {
+        setTimeout(function () {
           if (error) {
             reject(error);
           }
@@ -47,22 +48,22 @@ if (typeof window !== 'undefined') {
       var link = document.createElement('link');
       link.type = 'text/css';
       link.rel = 'stylesheet';
-      link.href = url;
+      link.href = url + bust;
       if (!isWebkit) {
-        link.onload = function() {
+        link.onload = function () {
           _callback();
         }
       } else {
         webkitLoadCheck(link, _callback);
       }
-      link.onerror = function(event) {
+      link.onerror = function (event) {
         _callback(event.error || new Error('Error loading CSS file.'));
       };
       head.appendChild(link);
     });
   };
 
-  exports.fetch = function(load) {
+  exports.fetch = function (load) {
     // dont reload styles loaded in the head
     for (var i = 0; i < linkHrefs.length; i++)
       if (load.address == linkHrefs[i])