浏览代码

Merge remote-tracking branch 'origin/master' into reminder_refactoring

Marcus Efraimsson 7 年之前
父节点
当前提交
a0e1a1a1f9
共有 68 个文件被更改,包括 2025 次插入179 次删除
  1. 2 1
      .circleci/config.yml
  2. 3 0
      CHANGELOG.md
  3. 11 6
      build.go
  4. 1 0
      devenv/docker/ha_test/.gitignore
  5. 137 0
      devenv/docker/ha_test/README.md
  6. 156 0
      devenv/docker/ha_test/alerts.sh
  7. 57 0
      devenv/docker/ha_test/docker-compose.yaml
  8. 202 0
      devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet
  9. 8 0
      devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml
  10. 172 0
      devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json
  11. 11 0
      devenv/docker/ha_test/grafana/provisioning/datasources/datasources.yaml
  12. 39 0
      devenv/docker/ha_test/prometheus/prometheus.yml
  13. 7 3
      pkg/api/dashboard.go
  14. 1 1
      pkg/api/folder.go
  15. 11 5
      pkg/api/index.go
  16. 28 1
      pkg/components/imguploader/s3uploader.go
  17. 8 4
      pkg/components/null/float.go
  18. 4 0
      pkg/services/alerting/notifiers/base.go
  19. 1 1
      pkg/services/alerting/notifiers/kafka.go
  20. 1 1
      pkg/services/alerting/notifiers/opsgenie.go
  21. 1 1
      pkg/services/alerting/notifiers/pagerduty.go
  22. 26 22
      pkg/services/provisioning/dashboards/file_reader.go
  23. 4 3
      pkg/services/provisioning/dashboards/file_reader_linux_test.go
  24. 2 1
      pkg/services/provisioning/dashboards/file_reader_test.go
  25. 9 5
      pkg/social/social.go
  26. 1 1
      pkg/tsdb/cloudwatch/metric_find_query.go
  27. 21 8
      pkg/tsdb/elasticsearch/response_parser.go
  28. 5 5
      pkg/tsdb/elasticsearch/time_series_query.go
  29. 39 0
      public/app/core/components/LayoutSelector/LayoutSelector.tsx
  30. 18 0
      public/app/features/dashboard/specs/time_srv.test.ts
  31. 2 1
      public/app/features/dashboard/submenu/submenu.html
  32. 6 0
      public/app/features/dashboard/time_srv.ts
  33. 5 4
      public/app/features/explore/Explore.tsx
  34. 14 5
      public/app/features/org/partials/orgUsers.html
  35. 31 0
      public/app/features/plugins/PluginActionBar.test.tsx
  36. 62 0
      public/app/features/plugins/PluginActionBar.tsx
  37. 25 0
      public/app/features/plugins/PluginList.test.tsx
  38. 32 0
      public/app/features/plugins/PluginList.tsx
  39. 33 0
      public/app/features/plugins/PluginListItem.test.tsx
  40. 39 0
      public/app/features/plugins/PluginListItem.tsx
  41. 32 0
      public/app/features/plugins/PluginListPage.test.tsx
  42. 56 0
      public/app/features/plugins/PluginListPage.tsx
  43. 59 0
      public/app/features/plugins/__mocks__/pluginMocks.ts
  44. 40 0
      public/app/features/plugins/__snapshots__/PluginActionBar.test.tsx.snap
  45. 210 0
      public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap
  46. 106 0
      public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap
  47. 18 0
      public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
  48. 0 1
      public/app/features/plugins/all.ts
  49. 0 45
      public/app/features/plugins/partials/plugin_list.html
  50. 0 30
      public/app/features/plugins/plugin_list_ctrl.ts
  51. 51 0
      public/app/features/plugins/state/actions.ts
  52. 27 0
      public/app/features/plugins/state/reducers.ts
  53. 31 0
      public/app/features/plugins/state/selectors.test.ts
  54. 10 0
      public/app/features/plugins/state/selectors.ts
  55. 58 0
      public/app/features/templating/TextBoxVariable.ts
  56. 2 0
      public/app/features/templating/all.ts
  57. 8 0
      public/app/features/templating/partials/editor.html
  58. 1 1
      public/app/plugins/datasource/cloudwatch/partials/config.html
  59. 5 3
      public/app/routes/routes.ts
  60. 2 0
      public/app/store/configureStore.ts
  61. 3 1
      public/app/types/index.ts
  62. 28 0
      public/app/types/plugins.ts
  63. 25 0
      public/sass/components/_buttons.scss
  64. 6 12
      public/sass/components/_form_select_box.scss
  65. 1 1
      public/sass/pages/_explore.scss
  66. 4 1
      public/views/index.template.html
  67. 5 4
      scripts/grunt/default_task.js
  68. 2 1
      scripts/grunt/options/exec.js

+ 2 - 1
.circleci/config.yml

@@ -83,13 +83,14 @@ jobs:
       - checkout
       - run: 'go get -u github.com/alecthomas/gometalinter'
       - run: 'go get -u github.com/tsenart/deadcode'
+      - run: 'go get -u github.com/jgautheron/goconst/cmd/goconst'
       - run: 'go get -u github.com/gordonklaus/ineffassign'
       - run: 'go get -u github.com/opennota/check/cmd/structcheck'
       - run: 'go get -u github.com/mdempsky/unconvert'
       - run: 'go get -u github.com/opennota/check/cmd/varcheck'
       - run:
           name: run linters
-          command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...'
+          command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=goconst --enable=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...'
       - run:
           name: run go vet
           command: 'go vet ./pkg/...'

+ 3 - 0
CHANGELOG.md

@@ -6,12 +6,15 @@
 
 ### Minor
 
+* **Provisioning**: Dashboard Provisioning now support symlinks that changes target [#12534](https://github.com/grafana/grafana/issues/12534), thx [@auhlig](https://github.com/auhlig)
 * **OAuth**: Allow oauth email attribute name to be configurable [#12986](https://github.com/grafana/grafana/issues/12986), thx [@bobmshannon](https://github.com/bobmshannon)
 * **Tags**: Default sort order for GetDashboardTags [#11681](https://github.com/grafana/grafana/pull/11681), thx [@Jonnymcc](https://github.com/Jonnymcc)
 * **Prometheus**: Label completion queries respect dashboard time range  [#12251](https://github.com/grafana/grafana/pull/12251), thx [@mtanda](https://github.com/mtanda)
 * **Prometheus**: Allow to display annotations based on Prometheus series value [#10159](https://github.com/grafana/grafana/issues/10159), thx [@mtanda](https://github.com/mtanda)
 * **Prometheus**: Adhoc-filtering for Prometheus dashboards [#13212](https://github.com/grafana/grafana/issues/13212)
 * **Singlestat**: Fix gauge display accuracy for percents [#13270](https://github.com/grafana/grafana/issues/13270), thx [@tianon](https://github.com/tianon)
+* **Dashboard**: Prevent auto refresh from starting when loading dashboard with absolute time range [#12030](https://github.com/grafana/grafana/issues/12030)
+* **Templating**: New templating variable type `Text box` that allows free text input [#3173](https://github.com/grafana/grafana/issues/3173)
 
 # 5.3.0 (unreleased)
 

+ 11 - 6
build.go

@@ -22,6 +22,11 @@ import (
 	"time"
 )
 
+const (
+	windows = "windows"
+	linux   = "linux"
+)
+
 var (
 	//versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`)
 	goarch  string
@@ -110,13 +115,13 @@ func main() {
 		case "package":
 			grunt(gruntBuildArg("build")...)
 			grunt(gruntBuildArg("package")...)
-			if goos == "linux" {
+			if goos == linux {
 				createLinuxPackages()
 			}
 
 		case "package-only":
 			grunt(gruntBuildArg("package")...)
-			if goos == "linux" {
+			if goos == linux {
 				createLinuxPackages()
 			}
 
@@ -378,7 +383,7 @@ func ensureGoPath() {
 }
 
 func grunt(params ...string) {
-	if runtime.GOOS == "windows" {
+	if runtime.GOOS == windows {
 		runPrint(`.\node_modules\.bin\grunt`, params...)
 	} else {
 		runPrint("./node_modules/.bin/grunt", params...)
@@ -420,7 +425,7 @@ func build(binaryName, pkg string, tags []string) {
 		binary = fmt.Sprintf("./bin/%s", binaryName)
 	}
 
-	if goos == "windows" {
+	if goos == windows {
 		binary += ".exe"
 	}
 
@@ -484,11 +489,11 @@ func clean() {
 
 func setBuildEnv() {
 	os.Setenv("GOOS", goos)
-	if goos == "windows" {
+	if goos == windows {
 		// require windows >=7
 		os.Setenv("CGO_CFLAGS", "-D_WIN32_WINNT=0x0601")
 	}
-	if goarch != "amd64" || goos != "linux" {
+	if goarch != "amd64" || goos != linux {
 		// needed for all other archs
 		cgo = true
 	}

+ 1 - 0
devenv/docker/ha_test/.gitignore

@@ -0,0 +1 @@
+grafana/provisioning/dashboards/alerts/alert-*

+ 137 - 0
devenv/docker/ha_test/README.md

@@ -0,0 +1,137 @@
+# Grafana High Availability (HA) test setup
+
+A set of docker compose services which together creates a Grafana HA test setup with capability of easily
+scaling up/down number of Grafana instances.
+
+Included services
+
+* Grafana
+* Mysql - Grafana configuration database and session storage
+* Prometheus - Monitoring of Grafana and used as datasource of provisioned alert rules
+* Nginx - Reverse proxy for Grafana and Prometheus. Enables browsing Grafana/Prometheus UI using a hostname
+
+## Prerequisites
+
+### Build grafana docker container
+
+Build a Grafana docker container from current branch and commit and tag it as grafana/grafana:dev.
+
+```bash
+$ cd <grafana repo>
+$ make build-docker-full
+```
+
+### Virtual host names
+
+#### Alternative 1 - Use dnsmasq
+
+```bash
+$ sudo apt-get install dnsmasq
+$ echo 'address=/loc/127.0.0.1' | sudo tee /etc/dnsmasq.d/dnsmasq-loc.conf > /dev/null
+$ sudo /etc/init.d/dnsmasq restart
+$ ping whatever.loc
+PING whatever.loc (127.0.0.1) 56(84) bytes of data.
+64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.076 ms
+--- whatever.loc ping statistics ---
+1 packet transmitted, 1 received, 0% packet loss, time 1998ms
+```
+
+#### Alternative 2 - Manually update /etc/hosts
+
+Update your `/etc/hosts` to be able to access Grafana and/or Prometheus UI using a hostname.
+
+```bash
+$ cat /etc/hosts
+127.0.0.1       grafana.loc
+127.0.0.1       prometheus.loc
+```
+
+## Start services
+
+```bash
+$ docker-compose up -d
+```
+
+Browse
+* http://grafana.loc/
+* http://prometheus.loc/
+
+Check for any errors
+
+```bash
+$ docker-compose logs | grep error
+```
+
+### Scale Grafana instances up/down
+
+Scale number of Grafana instances to `<instances>`
+
+```bash
+$ docker-compose up --scale grafana=<instances> -d
+# for example 3 instances
+$ docker-compose up --scale grafana=3 -d
+```
+
+## Test alerting
+
+### Create notification channels
+
+Creates default notification channels, if not already exists
+
+```bash
+$ ./alerts.sh setup
+```
+
+### Slack notifications
+
+Disable
+
+```bash
+$ ./alerts.sh slack -d
+```
+
+Enable and configure url
+
+```bash
+$ ./alerts.sh slack -u https://hooks.slack.com/services/...
+```
+
+Enable, configure url and enable reminders
+
+```bash
+$ ./alerts.sh slack -u https://hooks.slack.com/services/... -r -e 10m
+```
+
+### Provision alert dashboards with alert rules
+
+Provision 1 dashboard/alert rule (default)
+
+```bash
+$ ./alerts.sh provision
+```
+
+Provision 10 dashboards/alert rules
+
+```bash
+$ ./alerts.sh provision -a 10
+```
+
+Provision 10 dashboards/alert rules and change condition to `gt > 100`
+
+```bash
+$ ./alerts.sh provision -a 10 -c 100
+```
+
+### Pause/unpause all alert rules
+
+Pause
+
+```bash
+$ ./alerts.sh pause
+```
+
+Unpause
+
+```bash
+$ ./alerts.sh unpause
+```

+ 156 - 0
devenv/docker/ha_test/alerts.sh

@@ -0,0 +1,156 @@
+#!/bin/bash
+
+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
+}
+
+setup() {
+	STATUS=$(curl -s -o /dev/null -w '%{http_code}' http://admin:admin@grafana.loc/api/alert-notifications/1)
+  if [ $STATUS -eq 200 ]; then
+    echo "Email already exists, skipping..."
+  else
+		curl -H "Content-Type: application/json" \
+		-d '{
+			"name": "Email",
+			"type":  "email",
+			"isDefault": false,
+			"sendReminder": false,
+			"uploadImage": true,
+			"settings": {
+				"addresses": "user@test.com"
+			}
+		}' \
+		http://admin:admin@grafana.loc/api/alert-notifications
+  fi
+
+	STATUS=$(curl -s -o /dev/null -w '%{http_code}' http://admin:admin@grafana.loc/api/alert-notifications/2)
+  if [ $STATUS -eq 200 ]; then
+    echo "Slack already exists, skipping..."
+  else
+		curl -H "Content-Type: application/json" \
+		-d '{
+			"name": "Slack",
+			"type":  "slack",
+			"isDefault": false,
+			"sendReminder": false,
+			"uploadImage": true
+		}' \
+		http://admin:admin@grafana.loc/api/alert-notifications
+  fi
+}
+
+slack() {
+	enabled=true
+	url=''
+	remind=false
+	remindEvery='10m'
+
+	while getopts ":e:u:dr" o; do
+    case "${o}" in
+				e)
+            remindEvery=${OPTARG}
+            ;;
+				u)
+            url=${OPTARG}
+            ;;
+				d)
+            enabled=false
+            ;;
+				r)
+            remind=true
+            ;;
+    esac
+	done
+	shift $((OPTIND-1))
+
+	curl -X PUT \
+		-H "Content-Type: application/json" \
+		-d '{
+			"id": 2,
+			"name": "Slack",
+			"type":  "slack",
+			"isDefault": '$enabled',
+			"sendReminder": '$remind',
+			"frequency": "'$remindEvery'",
+			"uploadImage": true,
+			"settings": {
+				"url": "'$url'"
+			}
+		}' \
+		http://admin:admin@grafana.loc/api/alert-notifications/2
+}
+
+provision() {
+	alerts=1
+	condition=65
+	while getopts ":a:c:" o; do
+    case "${o}" in
+        a)
+            alerts=${OPTARG}
+            ;;
+				c)
+            condition=${OPTARG}
+            ;;
+    esac
+	done
+	shift $((OPTIND-1))
+
+	requiresJsonnet
+
+	rm -rf grafana/provisioning/dashboards/alerts/alert-*.json
+	jsonnet -m grafana/provisioning/dashboards/alerts grafana/provisioning/alerts.jsonnet --ext-code alerts=$alerts --ext-code condition=$condition
+}
+
+pause() {
+	curl -H "Content-Type: application/json" \
+  -d '{"paused":true}' \
+  http://admin:admin@grafana.loc/api/admin/pause-all-alerts
+}
+
+unpause() {
+	curl -H "Content-Type: application/json" \
+  -d '{"paused":false}' \
+  http://admin:admin@grafana.loc/api/admin/pause-all-alerts
+}
+
+usage() {
+	echo -e "Usage: ./alerts.sh COMMAND [OPTIONS]\n"
+	echo -e "Commands"
+	echo -e "  setup\t\t creates default alert notification channels"
+	echo -e "  slack\t\t configure slack notification channel"
+	echo -e "    [-d]\t\t\t disable notifier, default enabled"
+	echo -e "    [-u]\t\t\t url"
+	echo -e "    [-r]\t\t\t send reminders"
+	echo -e "    [-e <remind every>]\t\t default 10m\n"
+	echo -e "  provision\t provision alerts"
+	echo -e "    [-a <alert rule count>]\t default 1"
+	echo -e "    [-c <condition value>]\t default 65\n"
+	echo -e "  pause\t\t pause all alerts"
+	echo -e "  unpause\t unpause all alerts"
+}
+
+main() {
+	local cmd=$1
+
+	if [[ $cmd == "setup" ]]; then
+		setup
+	elif [[ $cmd == "slack" ]]; then
+		slack "${@:2}"
+	elif [[ $cmd == "provision" ]]; then
+		provision "${@:2}"
+	elif [[ $cmd == "pause" ]]; then
+		pause
+	elif [[ $cmd == "unpause" ]]; then
+		unpause
+	fi
+
+  if [[ -z "$cmd" ]]; then
+		usage
+	fi
+}
+
+main "$@"

+ 57 - 0
devenv/docker/ha_test/docker-compose.yaml

@@ -0,0 +1,57 @@
+version: "2.1"
+
+services:
+  nginx-proxy:
+    image: jwilder/nginx-proxy
+    ports:
+      - "80:80"
+    volumes:
+      - /var/run/docker.sock:/tmp/docker.sock:ro
+
+  mysql:
+    image: mysql
+    environment:
+      MYSQL_ROOT_PASSWORD: rootpass
+      MYSQL_DATABASE: grafana
+      MYSQL_USER: grafana
+      MYSQL_PASSWORD: password
+    healthcheck:
+      test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
+      timeout: 10s
+      retries: 10
+
+  grafana:
+    image: grafana/grafana:dev
+    volumes:
+      - ./grafana/provisioning/:/etc/grafana/provisioning/
+    environment:
+      - VIRTUAL_HOST=grafana.loc
+      - GF_SERVER_ROOT_URL=http://grafana.loc
+      - GF_DATABASE_TYPE=mysql
+      - GF_DATABASE_HOST=mysql:3306
+      - GF_DATABASE_NAME=grafana
+      - GF_DATABASE_USER=grafana
+      - GF_DATABASE_PASSWORD=password
+      - GF_SESSION_PROVIDER=mysql
+      - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(mysql:3306)/grafana?allowNativePasswords=true
+    ports:
+      - 3000
+    depends_on:
+      mysql:
+        condition: service_healthy
+
+  prometheus:
+    image: prom/prometheus:v2.4.2
+    volumes:
+      - ./prometheus/:/etc/prometheus/
+    environment:
+      - VIRTUAL_HOST=prometheus.loc
+    ports:
+      - 9090
+
+  # mysqld-exporter:
+  #   image: prom/mysqld-exporter
+  #   environment:
+  #     - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
+  #   ports:
+  #     - 9104

+ 202 - 0
devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet

@@ -0,0 +1,202 @@
+local numAlerts = std.extVar('alerts');
+local condition = std.extVar('condition');
+local arr = std.range(1, numAlerts);
+
+local alertDashboardTemplate = {
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "id": null,
+  "links": [],
+  "panels": [
+    {
+      "alert": {
+        "conditions": [
+          {
+            "evaluator": {
+              "params": [
+                65
+              ],
+              "type": "gt"
+            },
+            "operator": {
+              "type": "and"
+            },
+            "query": {
+              "params": [
+                "A",
+                "5m",
+                "now"
+              ]
+            },
+            "reducer": {
+              "params": [],
+              "type": "avg"
+            },
+            "type": "query"
+          }
+        ],
+        "executionErrorState": "alerting",
+        "frequency": "10s",
+        "handler": 1,
+        "name": "bulk alerting",
+        "noDataState": "no_data",
+        "notifications": [
+          {
+            "id": 2
+          }
+        ]
+      },
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "fill": 1,
+      "gridPos": {
+        "h": 9,
+        "w": 12,
+        "x": 0,
+        "y": 0
+      },
+      "id": 2,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "$$hashKey": "object:117",
+          "expr": "go_goroutines",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "refId": "A"
+        }
+      ],
+      "thresholds": [
+        {
+          "colorMode": "critical",
+          "fill": true,
+          "line": true,
+          "op": "gt",
+          "value": 50
+        }
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Panel Title",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ]
+    }
+  ],
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-6h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "",
+  "title": "New dashboard",
+  "uid": null,
+  "version": 0
+};
+
+
+{
+  ['alert-' + std.toString(x) + '.json']:
+    alertDashboardTemplate + {
+      panels: [
+        alertDashboardTemplate.panels[0] +
+        {
+          alert+: {
+            name: 'Alert rule ' + x,
+            conditions: [
+              alertDashboardTemplate.panels[0].alert.conditions[0] +
+              {
+                evaluator+: {
+                  params: [condition]
+                }
+              },
+            ],
+          },
+        },
+      ],
+      uid: 'alert-' + x,
+      title: 'Alert ' + x
+    },
+      for x in arr
+}

+ 8 - 0
devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml

@@ -0,0 +1,8 @@
+apiVersion: 1
+
+providers:
+ - name: 'Alerts'
+   folder: 'Alerts'
+   type: file
+   options:
+     path: /etc/grafana/provisioning/dashboards/alerts

+ 172 - 0
devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json

@@ -0,0 +1,172 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "links": [],
+  "panels": [
+    {
+      "aliasColors": {
+        "Active alerts": "#bf1b00"
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "fill": 1,
+      "gridPos": {
+        "h": 12,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 2,
+      "interval": "",
+      "legend": {
+        "alignAsTable": true,
+        "avg": false,
+        "current": true,
+        "max": false,
+        "min": false,
+        "rightSide": true,
+        "show": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Active grafana instances",
+          "dashes": true,
+          "fill": 0
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "sum(increase(grafana_alerting_notification_sent_total[1m])) by(job)",
+          "format": "time_series",
+          "instant": false,
+          "interval": "1m",
+          "intervalFactor": 1,
+          "legendFormat": "Notifications sent",
+          "refId": "A"
+        },
+        {
+          "expr": "min(grafana_alerting_active_alerts) without(instance)",
+          "format": "time_series",
+          "interval": "1m",
+          "intervalFactor": 1,
+          "legendFormat": "Active alerts",
+          "refId": "B"
+        },
+        {
+          "expr": "count(up{job=\"grafana\"})",
+          "format": "time_series",
+          "intervalFactor": 1,
+          "legendFormat": "Active grafana instances",
+          "refId": "C"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Notifications sent vs active alerts",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": "0",
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": 3
+      }
+    }
+  ],
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-1h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ]
+  },
+  "timezone": "",
+  "title": "Overview",
+  "uid": "xHy7-hAik",
+  "version": 6
+}

+ 11 - 0
devenv/docker/ha_test/grafana/provisioning/datasources/datasources.yaml

@@ -0,0 +1,11 @@
+apiVersion: 1
+
+datasources:
+  - name: Prometheus
+    type: prometheus
+    access: proxy
+    url: http://prometheus:9090
+    jsonData:
+      timeInterval: 10s
+      queryTimeout: 30s
+      httpMethod: POST

+ 39 - 0
devenv/docker/ha_test/prometheus/prometheus.yml

@@ -0,0 +1,39 @@
+# my global config
+global:
+  scrape_interval:     10s # By default, scrape targets every 15 seconds.
+  evaluation_interval: 10s # By default, scrape targets every 15 seconds.
+  # scrape_timeout is set to the global default (10s).
+
+# Load and evaluate rules in this file every 'evaluation_interval' seconds.
+#rule_files:
+# - "alert.rules"
+# - "first.rules"
+# - "second.rules"
+
+# alerting:
+#   alertmanagers:
+#   - scheme: http
+#     static_configs:
+#     - targets:
+#       - "127.0.0.1:9093"
+
+scrape_configs:
+  - job_name: 'prometheus'
+    static_configs:
+      - targets: ['localhost:9090']
+
+  - job_name: 'grafana'
+    dns_sd_configs:
+      - names:
+        - 'grafana'
+        type: 'A'
+        port: 3000
+        refresh_interval: 10s
+
+  # - job_name: 'mysql'
+  #   dns_sd_configs:
+  #     - names:
+  #       - 'mysqld-exporter'
+  #       type: 'A'
+  #       port: 9104
+  #       refresh_interval: 10s

+ 7 - 3
pkg/api/dashboard.go

@@ -22,6 +22,10 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
+const (
+	anonString = "Anonymous"
+)
+
 func isDashboardStarredByUser(c *m.ReqContext, dashID int64) (bool, error) {
 	if !c.IsSignedIn {
 		return false, nil
@@ -64,7 +68,7 @@ func GetDashboard(c *m.ReqContext) Response {
 	}
 
 	// Finding creator and last updater of the dashboard
-	updater, creator := "Anonymous", "Anonymous"
+	updater, creator := anonString, anonString
 	if dash.UpdatedBy > 0 {
 		updater = getUserLogin(dash.UpdatedBy)
 	}
@@ -128,7 +132,7 @@ func getUserLogin(userID int64) string {
 	query := m.GetUserByIdQuery{Id: userID}
 	err := bus.Dispatch(&query)
 	if err != nil {
-		return "Anonymous"
+		return anonString
 	}
 	return query.Result.Login
 }
@@ -403,7 +407,7 @@ func GetDashboardVersion(c *m.ReqContext) Response {
 		return Error(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", query.Version, dashID), err)
 	}
 
-	creator := "Anonymous"
+	creator := anonString
 	if query.Result.CreatedBy > 0 {
 		creator = getUserLogin(query.Result.CreatedBy)
 	}

+ 1 - 1
pkg/api/folder.go

@@ -95,7 +95,7 @@ func toFolderDto(g guardian.DashboardGuardian, folder *m.Folder) dtos.Folder {
 	canAdmin, _ := g.CanAdmin()
 
 	// Finding creator and last updater of the folder
-	updater, creator := "Anonymous", "Anonymous"
+	updater, creator := anonString, anonString
 	if folder.CreatedBy > 0 {
 		creator = getUserLogin(folder.CreatedBy)
 	}

+ 11 - 5
pkg/api/index.go

@@ -11,6 +11,12 @@ import (
 	"github.com/grafana/grafana/pkg/setting"
 )
 
+const (
+	// Themes
+	lightName = "light"
+	darkName  = "dark"
+)
+
 func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 	settings, err := getFrontendSettingsMap(c)
 	if err != nil {
@@ -60,7 +66,7 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 			OrgRole:                    c.OrgRole,
 			GravatarUrl:                dtos.GetGravatarUrl(c.Email),
 			IsGrafanaAdmin:             c.IsGrafanaAdmin,
-			LightTheme:                 prefs.Theme == "light",
+			LightTheme:                 prefs.Theme == lightName,
 			Timezone:                   prefs.Timezone,
 			Locale:                     locale,
 			HelpFlags1:                 c.HelpFlags1,
@@ -88,12 +94,12 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 	}
 
 	themeURLParam := c.Query("theme")
-	if themeURLParam == "light" {
+	if themeURLParam == lightName {
 		data.User.LightTheme = true
-		data.Theme = "light"
-	} else if themeURLParam == "dark" {
+		data.Theme = lightName
+	} else if themeURLParam == darkName {
 		data.User.LightTheme = false
-		data.Theme = "dark"
+		data.Theme = darkName
 	}
 
 	if hasEditPermissionInFoldersQuery.Result {

+ 28 - 1
pkg/components/imguploader/s3uploader.go

@@ -2,12 +2,15 @@ package imguploader
 
 import (
 	"context"
+	"fmt"
 	"os"
 	"time"
 
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws/credentials"
 	"github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds"
+	"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
+	"github.com/aws/aws-sdk-go/aws/defaults"
 	"github.com/aws/aws-sdk-go/aws/ec2metadata"
 	"github.com/aws/aws-sdk-go/aws/endpoints"
 	"github.com/aws/aws-sdk-go/aws/session"
@@ -50,7 +53,7 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string,
 				SecretAccessKey: u.secretKey,
 			}},
 			&credentials.EnvProvider{},
-			&ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(sess), ExpiryWindow: 5 * time.Minute},
+			remoteCredProvider(sess),
 		})
 	cfg := &aws.Config{
 		Region:      aws.String(u.region),
@@ -85,3 +88,27 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string,
 	}
 	return image_url, nil
 }
+
+func remoteCredProvider(sess *session.Session) credentials.Provider {
+	ecsCredURI := os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
+
+	if len(ecsCredURI) > 0 {
+		return ecsCredProvider(sess, ecsCredURI)
+	}
+	return ec2RoleProvider(sess)
+}
+
+func ecsCredProvider(sess *session.Session, uri string) credentials.Provider {
+	const host = `169.254.170.2`
+
+	d := defaults.Get()
+	return endpointcreds.NewProviderClient(
+		*d.Config,
+		d.Handlers,
+		fmt.Sprintf("http://%s%s", host, uri),
+		func(p *endpointcreds.Provider) { p.ExpiryWindow = 5 * time.Minute })
+}
+
+func ec2RoleProvider(sess *session.Session) credentials.Provider {
+	return &ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(sess), ExpiryWindow: 5 * time.Minute}
+}

+ 8 - 4
pkg/components/null/float.go

@@ -8,6 +8,10 @@ import (
 	"strconv"
 )
 
+const (
+	nullString = "null"
+)
+
 // Float is a nullable float64.
 // It does not consider zero values to be null.
 // It will decode to null, not zero, if null.
@@ -68,7 +72,7 @@ func (f *Float) UnmarshalJSON(data []byte) error {
 // It will return an error if the input is not an integer, blank, or "null".
 func (f *Float) UnmarshalText(text []byte) error {
 	str := string(text)
-	if str == "" || str == "null" {
+	if str == "" || str == nullString {
 		f.Valid = false
 		return nil
 	}
@@ -82,7 +86,7 @@ func (f *Float) UnmarshalText(text []byte) error {
 // It will encode null if this Float is null.
 func (f Float) MarshalJSON() ([]byte, error) {
 	if !f.Valid {
-		return []byte("null"), nil
+		return []byte(nullString), nil
 	}
 	return []byte(strconv.FormatFloat(f.Float64, 'f', -1, 64)), nil
 }
@@ -100,7 +104,7 @@ func (f Float) MarshalText() ([]byte, error) {
 // It will encode a blank string if this Float is null.
 func (f Float) String() string {
 	if !f.Valid {
-		return "null"
+		return nullString
 	}
 
 	return fmt.Sprintf("%1.3f", f.Float64)
@@ -109,7 +113,7 @@ func (f Float) String() string {
 // FullString returns float as string in full precision
 func (f Float) FullString() string {
 	if !f.Valid {
-		return "null"
+		return nullString
 	}
 
 	return fmt.Sprintf("%f", f.Float64)

+ 4 - 0
pkg/services/alerting/notifiers/base.go

@@ -10,6 +10,10 @@ import (
 	"github.com/grafana/grafana/pkg/services/alerting"
 )
 
+const (
+	triggMetrString = "Triggered metrics:\n\n"
+)
+
 type NotifierBase struct {
 	Name         string
 	Type         string

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

@@ -61,7 +61,7 @@ func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error {
 
 	state := evalContext.Rule.State
 
-	customData := "Triggered metrics:\n\n"
+	customData := triggMetrString
 	for _, evt := range evalContext.EvalMatches {
 		customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
 	}

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

@@ -95,7 +95,7 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err
 		return err
 	}
 
-	customData := "Triggered metrics:\n\n"
+	customData := triggMetrString
 	for _, evt := range evalContext.EvalMatches {
 		customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
 	}

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

@@ -76,7 +76,7 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
 	if evalContext.Rule.State == m.AlertStateOK {
 		eventType = "resolve"
 	}
-	customData := "Triggered metrics:\n\n"
+	customData := triggMetrString
 	for _, evt := range evalContext.EvalMatches {
 		customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
 	}

+ 26 - 22
pkg/services/provisioning/dashboards/file_reader.go

@@ -43,26 +43,6 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade
 		log.Warn("[Deprecated] The folder property is deprecated. Please use path instead.")
 	}
 
-	if _, err := os.Stat(path); os.IsNotExist(err) {
-		log.Error("Cannot read directory", "error", err)
-	}
-
-	copy := path
-	path, err := filepath.Abs(path)
-	if err != nil {
-		log.Error("Could not create absolute path ", "path", path)
-	}
-
-	path, err = filepath.EvalSymlinks(path)
-	if err != nil {
-		log.Error("Failed to read content of symlinked path: %s", path)
-	}
-
-	if path == "" {
-		path = copy
-		log.Info("falling back to original path due to EvalSymlink/Abs failure")
-	}
-
 	return &fileReader{
 		Cfg:              cfg,
 		Path:             path,
@@ -99,7 +79,8 @@ func (fr *fileReader) ReadAndListen(ctx context.Context) error {
 }
 
 func (fr *fileReader) startWalkingDisk() error {
-	if _, err := os.Stat(fr.Path); err != nil {
+	resolvedPath := fr.resolvePath(fr.Path)
+	if _, err := os.Stat(resolvedPath); err != nil {
 		if os.IsNotExist(err) {
 			return err
 		}
@@ -116,7 +97,7 @@ func (fr *fileReader) startWalkingDisk() error {
 	}
 
 	filesFoundOnDisk := map[string]os.FileInfo{}
-	err = filepath.Walk(fr.Path, createWalkFn(filesFoundOnDisk))
+	err = filepath.Walk(resolvedPath, createWalkFn(filesFoundOnDisk))
 	if err != nil {
 		return err
 	}
@@ -344,6 +325,29 @@ func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time,
 	}, nil
 }
 
+func (fr *fileReader) resolvePath(path string) string {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		fr.log.Error("Cannot read directory", "error", err)
+	}
+
+	copy := path
+	path, err := filepath.Abs(path)
+	if err != nil {
+		fr.log.Error("Could not create absolute path ", "path", path)
+	}
+
+	path, err = filepath.EvalSymlinks(path)
+	if err != nil {
+		fr.log.Error("Failed to read content of symlinked path: %s", path)
+	}
+
+	if path == "" {
+		path = copy
+		fr.log.Info("falling back to original path due to EvalSymlink/Abs failure")
+	}
+	return path
+}
+
 type provisioningMetadata struct {
 	uid   string
 	title string

+ 4 - 3
pkg/services/provisioning/dashboards/file_reader_linux_test.go

@@ -30,10 +30,11 @@ func TestProvsionedSymlinkedFolder(t *testing.T) {
 	want, err := filepath.Abs(containingId)
 
 	if err != nil {
-		t.Errorf("expected err to be nill")
+		t.Errorf("expected err to be nil")
 	}
 
-	if reader.Path != want {
-		t.Errorf("got %s want %s", reader.Path, want)
+	resolvedPath := reader.resolvePath(reader.Path)
+	if resolvedPath != want {
+		t.Errorf("got %s want %s", resolvedPath, want)
 	}
 }

+ 2 - 1
pkg/services/provisioning/dashboards/file_reader_test.go

@@ -67,7 +67,8 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
 			reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
 			So(err, ShouldBeNil)
 
-			So(filepath.IsAbs(reader.Path), ShouldBeTrue)
+			resolvedPath := reader.resolvePath(reader.Path)
+			So(filepath.IsAbs(resolvedPath), ShouldBeTrue)
 		})
 	})
 }

+ 9 - 5
pkg/social/social.go

@@ -46,10 +46,14 @@ func (e *Error) Error() string {
 	return e.s
 }
 
+const (
+	grafanaCom = "grafana_com"
+)
+
 var (
 	SocialBaseUrl = "/login/"
 	SocialMap     = make(map[string]SocialConnector)
-	allOauthes    = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", "grafana_com"}
+	allOauthes    = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom}
 )
 
 func NewOAuthService() {
@@ -82,7 +86,7 @@ func NewOAuthService() {
 		}
 
 		if name == "grafananet" {
-			name = "grafana_com"
+			name = grafanaCom
 		}
 
 		setting.OAuthService.OAuthInfos[name] = info
@@ -159,7 +163,7 @@ func NewOAuthService() {
 			}
 		}
 
-		if name == "grafana_com" {
+		if name == grafanaCom {
 			config = oauth2.Config{
 				ClientID:     info.ClientId,
 				ClientSecret: info.ClientSecret,
@@ -171,7 +175,7 @@ func NewOAuthService() {
 				Scopes:      info.Scopes,
 			}
 
-			SocialMap["grafana_com"] = &SocialGrafanaCom{
+			SocialMap[grafanaCom] = &SocialGrafanaCom{
 				SocialBase: &SocialBase{
 					Config: &config,
 					log:    logger,
@@ -194,7 +198,7 @@ var GetOAuthProviders = func(cfg *setting.Cfg) map[string]bool {
 
 	for _, name := range allOauthes {
 		if name == "grafananet" {
-			name = "grafana_com"
+			name = grafanaCom
 		}
 
 		sec := cfg.Raw.Section("auth." + name)

+ 1 - 1
pkg/tsdb/cloudwatch/metric_find_query.go

@@ -235,7 +235,7 @@ func parseMultiSelectValue(input string) []string {
 func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
 	regions := []string{
 		"ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "ca-central-1", "cn-north-1", "cn-northwest-1",
-		"eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2",
+		"eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2", "us-isob-east-1", "us-iso-east-1",
 	}
 
 	result := make([]suggestData, 0)

+ 21 - 8
pkg/tsdb/elasticsearch/response_parser.go

@@ -13,6 +13,19 @@ import (
 	"github.com/grafana/grafana/pkg/tsdb/elasticsearch/client"
 )
 
+const (
+	// Metric types
+	countType         = "count"
+	percentilesType   = "percentiles"
+	extendedStatsType = "extended_stats"
+	// Bucket types
+	dateHistType    = "date_histogram"
+	histogramType   = "histogram"
+	filtersType     = "filters"
+	termsType       = "terms"
+	geohashGridType = "geohash_grid"
+)
+
 type responseParser struct {
 	Responses []*es.SearchResponse
 	Targets   []*Query
@@ -81,7 +94,7 @@ func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Qu
 		}
 
 		if depth == maxDepth {
-			if aggDef.Type == "date_histogram" {
+			if aggDef.Type == dateHistType {
 				err = rp.processMetrics(esAgg, target, series, props)
 			} else {
 				err = rp.processAggregationDocs(esAgg, aggDef, target, table, props)
@@ -149,7 +162,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query,
 		}
 
 		switch metric.Type {
-		case "count":
+		case countType:
 			newSeries := tsdb.TimeSeries{
 				Tags: make(map[string]string),
 			}
@@ -164,10 +177,10 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query,
 			for k, v := range props {
 				newSeries.Tags[k] = v
 			}
-			newSeries.Tags["metric"] = "count"
+			newSeries.Tags["metric"] = countType
 			*series = append(*series, &newSeries)
 
-		case "percentiles":
+		case percentilesType:
 			buckets := esAgg.Get("buckets").MustArray()
 			if len(buckets) == 0 {
 				break
@@ -198,7 +211,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query,
 				}
 				*series = append(*series, &newSeries)
 			}
-		case "extended_stats":
+		case extendedStatsType:
 			buckets := esAgg.Get("buckets").MustArray()
 
 			metaKeys := make([]string, 0)
@@ -312,9 +325,9 @@ func (rp *responseParser) processAggregationDocs(esAgg *simplejson.Json, aggDef
 
 		for _, metric := range target.Metrics {
 			switch metric.Type {
-			case "count":
+			case countType:
 				addMetricValue(&values, rp.getMetricName(metric.Type), castToNullFloat(bucket.Get("doc_count")))
-			case "extended_stats":
+			case extendedStatsType:
 				metaKeys := make([]string, 0)
 				meta := metric.Meta.MustMap()
 				for k := range meta {
@@ -366,7 +379,7 @@ func (rp *responseParser) processAggregationDocs(esAgg *simplejson.Json, aggDef
 func (rp *responseParser) trimDatapoints(series *tsdb.TimeSeriesSlice, target *Query) {
 	var histogram *BucketAgg
 	for _, bucketAgg := range target.BucketAggs {
-		if bucketAgg.Type == "date_histogram" {
+		if bucketAgg.Type == dateHistType {
 			histogram = bucketAgg
 			break
 		}

+ 5 - 5
pkg/tsdb/elasticsearch/time_series_query.go

@@ -75,15 +75,15 @@ func (e *timeSeriesQuery) execute() (*tsdb.Response, error) {
 		// iterate backwards to create aggregations bottom-down
 		for _, bucketAgg := range q.BucketAggs {
 			switch bucketAgg.Type {
-			case "date_histogram":
+			case dateHistType:
 				aggBuilder = addDateHistogramAgg(aggBuilder, bucketAgg, from, to)
-			case "histogram":
+			case histogramType:
 				aggBuilder = addHistogramAgg(aggBuilder, bucketAgg)
-			case "filters":
+			case filtersType:
 				aggBuilder = addFiltersAgg(aggBuilder, bucketAgg)
-			case "terms":
+			case termsType:
 				aggBuilder = addTermsAgg(aggBuilder, bucketAgg, q.Metrics)
-			case "geohash_grid":
+			case geohashGridType:
 				aggBuilder = addGeoHashGridAgg(aggBuilder, bucketAgg)
 			}
 		}

+ 39 - 0
public/app/core/components/LayoutSelector/LayoutSelector.tsx

@@ -0,0 +1,39 @@
+import React, { SFC } from 'react';
+
+export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
+
+export enum LayoutModes {
+  Grid = 'grid',
+  List = 'list',
+}
+
+interface Props {
+  mode: LayoutMode;
+  onLayoutModeChanged: (mode: LayoutMode) => {};
+}
+
+const LayoutSelector: SFC<Props> = props => {
+  const { mode, onLayoutModeChanged } = props;
+  return (
+    <div className="layout-selector">
+      <button
+        onClick={() => {
+          onLayoutModeChanged(LayoutModes.List);
+        }}
+        className={mode === LayoutModes.List ? 'active' : ''}
+      >
+        <i className="fa fa-list" />
+      </button>
+      <button
+        onClick={() => {
+          onLayoutModeChanged(LayoutModes.Grid);
+        }}
+        className={mode === LayoutModes.Grid ? 'active' : ''}
+      >
+        <i className="fa fa-th" />
+      </button>
+    </div>
+  );
+};
+
+export default LayoutSelector;

+ 18 - 0
public/app/features/dashboard/specs/time_srv.test.ts

@@ -29,6 +29,7 @@ describe('timeSrv', () => {
   beforeEach(() => {
     timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
     timeSrv.init(_dashboard);
+    _dashboard.refresh = false;
   });
 
   describe('timeRange', () => {
@@ -79,6 +80,23 @@ describe('timeSrv', () => {
       expect(time.to.valueOf()).toEqual(new Date('2014-05-20T03:10:22Z').getTime());
     });
 
+    it('should ignore refresh if time absolute', () => {
+      location = {
+        search: jest.fn(() => ({
+          from: '20140410T052010',
+          to: '20140520T031022',
+        })),
+      };
+
+      timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
+
+      // dashboard saved with refresh on
+      _dashboard.refresh = true;
+      timeSrv.init(_dashboard);
+
+      expect(timeSrv.refresh).toBe(false);
+    });
+
     it('should handle formatted dates without time', () => {
       location = {
         search: jest.fn(() => ({

+ 2 - 1
public/app/features/dashboard/submenu/submenu.html

@@ -4,7 +4,8 @@
       <label class="gf-form-label template-variable" ng-hide="variable.hide === 1">
         {{variable.label || variable.name}}
       </label>
-      <value-select-dropdown ng-if="variable.type !== 'adhoc'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
+      <value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
+      <input type="text" ng-if="variable.type === 'textbox'" ng-model="variable.query" class="gf-form-input width-12"  ng-blur="variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ng-keydown="$event.keyCode === 13 && variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ></input>
     </div>
     <ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable"></ad-hoc-filters>
   </div>

+ 6 - 0
public/app/features/dashboard/time_srv.ts

@@ -85,6 +85,12 @@ export class TimeSrv {
     if (params.to) {
       this.time.to = this.parseUrlParam(params.to) || this.time.to;
     }
+    // if absolute ignore refresh option saved to dashboard
+    if (params.to && params.to.indexOf('now') === -1) {
+      this.refresh = false;
+      this.dashboard.refresh = false;
+    }
+    // but if refresh explicitly set then use that
     if (params.refresh) {
       this.refresh = params.refresh || this.refresh;
     }

+ 5 - 4
public/app/features/explore/Explore.tsx

@@ -528,10 +528,11 @@ export class Explore extends React.Component<any, ExploreState> {
           {!datasourceMissing ? (
             <div className="navbar-buttons">
               <Select
-                className="datasource-picker"
                 clearable={false}
+                className="gf-form-input gf-form-input--form-dropdown datasource-picker"
                 onChange={this.onChangeDatasource}
                 options={datasources}
+                isOpen={true}
                 placeholder="Loading datasources..."
                 value={selectedDatasource}
               />
@@ -586,17 +587,17 @@ export class Explore extends React.Component<any, ExploreState> {
             />
             <div className="result-options">
               {supportsGraph ? (
-                <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.onClickGraphButton}>
+                <button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
                   Graph
                 </button>
               ) : null}
               {supportsTable ? (
-                <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.onClickTableButton}>
+                <button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
                   Table
                 </button>
               ) : null}
               {supportsLogs ? (
-                <button className={`btn navbar-button ${logsButtonActive}`} onClick={this.onClickLogsButton}>
+                <button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
                   Logs
                 </button>
               ) : null}

+ 14 - 5
public/app/features/org/partials/orgUsers.html

@@ -2,16 +2,25 @@
 
 <div class="page-container page-body">
   <div class="page-action-bar">
-    <label class="gf-form gf-form--grow gf-form--has-input-icon">
+    <label class="gf-form gf-form--has-input-icon">
       <input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by username or email" />
       <i class="gf-form-input-icon fa fa-search"></i>
     </label>
 
-    <div class="page-action-bar__spacer"></div>
+    <div ng-if="ctrl.pendingInvites.length" style="margin-left: 1rem">
+      <button class="btn toggle-btn active" ng-if="!ctrl.showInvites">
+        Users
+      </button><button class="btn toggle-btn" ng-if="!ctrl.showInvites" ng-click="ctrl.showInvites = true">
+        Pending Invites ({{ctrl.pendingInvites.length}})
+      </button>
+      <button class="btn toggle-btn" ng-if="ctrl.showInvites" ng-click="ctrl.showInvites = false">
+        Users
+      </button><button class="btn toggle-btn active" ng-if="ctrl.showInvites">
+        Pending Invites ({{ctrl.pendingInvites.length}})
+      </button>
+    </div>
 
-    <button class="btn btn-inverse" ng-show="ctrl.pendingInvites.length" ng-click="ctrl.showInvites = true">
-      Pending Invites ({{ctrl.pendingInvites.length}})
-    </button>
+    <div class="page-action-bar__spacer"></div>
 
     <a class="btn btn-success" href="org/users/invite" ng-show="ctrl.canInvite">
       <i class="fa fa-plus"></i>

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

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

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

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

+ 25 - 0
public/app/features/plugins/PluginList.test.tsx

@@ -0,0 +1,25 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import PluginList from './PluginList';
+import { getMockPlugins } from './__mocks__/pluginMocks';
+import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      plugins: getMockPlugins(5),
+      layoutMode: LayoutModes.Grid,
+    },
+    propOverrides
+  );
+
+  return shallow(<PluginList {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 32 - 0
public/app/features/plugins/PluginList.tsx

@@ -0,0 +1,32 @@
+import React, { SFC } from 'react';
+import classNames from 'classnames/bind';
+import PluginListItem from './PluginListItem';
+import { Plugin } from 'app/types';
+import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+interface Props {
+  plugins: Plugin[];
+  layoutMode: LayoutMode;
+}
+
+const PluginList: SFC<Props> = props => {
+  const { plugins, layoutMode } = props;
+
+  const listStyle = classNames({
+    'card-section': true,
+    'card-list-layout-grid': layoutMode === LayoutModes.Grid,
+    'card-list-layout-list': layoutMode === LayoutModes.List,
+  });
+
+  return (
+    <section className={listStyle}>
+      <ol className="card-list">
+        {plugins.map((plugin, index) => {
+          return <PluginListItem plugin={plugin} key={`${plugin.name}-${index}`} />;
+        })}
+      </ol>
+    </section>
+  );
+};
+
+export default PluginList;

+ 33 - 0
public/app/features/plugins/PluginListItem.test.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import PluginListItem from './PluginListItem';
+import { getMockPlugin } from './__mocks__/pluginMocks';
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      plugin: getMockPlugin(),
+    },
+    propOverrides
+  );
+
+  return shallow(<PluginListItem {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render has plugin section', () => {
+    const mockPlugin = getMockPlugin();
+    mockPlugin.hasUpdate = true;
+    const wrapper = setup({
+      plugin: mockPlugin,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 39 - 0
public/app/features/plugins/PluginListItem.tsx

@@ -0,0 +1,39 @@
+import React, { SFC } from 'react';
+import { Plugin } from 'app/types';
+
+interface Props {
+  plugin: Plugin;
+}
+
+const PluginListItem: SFC<Props> = props => {
+  const { plugin } = props;
+
+  return (
+    <li className="card-item-wrapper">
+      <a className="card-item" href={`plugins/${plugin.id}/edit`}>
+        <div className="card-item-header">
+          <div className="card-item-type">
+            <i className={`icon-gf icon-gf-${plugin.type}`} />
+            {plugin.type}
+          </div>
+          {plugin.hasUpdate && (
+            <div className="card-item-notice">
+              <span bs-tooltip="plugin.latestVersion">Update available!</span>
+            </div>
+          )}
+        </div>
+        <div className="card-item-body">
+          <figure className="card-item-figure">
+            <img src={plugin.info.logos.small} />
+          </figure>
+          <div className="card-item-details">
+            <div className="card-item-name">{plugin.name}</div>
+            <div className="card-item-sub-name">{`By ${plugin.info.author.name}`}</div>
+          </div>
+        </div>
+      </a>
+    </li>
+  );
+};
+
+export default PluginListItem;

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

@@ -0,0 +1,32 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { PluginListPage, Props } from './PluginListPage';
+import { NavModel, Plugin } from '../../types';
+import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    plugins: [] as Plugin[],
+    layoutMode: LayoutModes.Grid,
+    loadPlugins: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<PluginListPage {...props} />);
+  const instance = wrapper.instance() as PluginListPage;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 56 - 0
public/app/features/plugins/PluginListPage.tsx

@@ -0,0 +1,56 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import PageHeader from '../../core/components/PageHeader/PageHeader';
+import PluginActionBar from './PluginActionBar';
+import PluginList from './PluginList';
+import { NavModel, Plugin } from '../../types';
+import { loadPlugins } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import { getLayoutMode, getPlugins } from './state/selectors';
+import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+
+export interface Props {
+  navModel: NavModel;
+  plugins: Plugin[];
+  layoutMode: LayoutMode;
+  loadPlugins: typeof loadPlugins;
+}
+
+export class PluginListPage extends PureComponent<Props> {
+  componentDidMount() {
+    this.fetchPlugins();
+  }
+
+  async fetchPlugins() {
+    await this.props.loadPlugins();
+  }
+
+  render() {
+    const { navModel, plugins, layoutMode } = this.props;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        <div className="page-container page-body">
+          <PluginActionBar />
+          {plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    navModel: getNavModel(state.navIndex, 'plugins'),
+    plugins: getPlugins(state.plugins),
+    layoutMode: getLayoutMode(state.plugins),
+  };
+}
+
+const mapDispatchToProps = {
+  loadPlugins,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));

+ 59 - 0
public/app/features/plugins/__mocks__/pluginMocks.ts

@@ -0,0 +1,59 @@
+import { Plugin } from 'app/types';
+
+export const getMockPlugins = (amount: number): Plugin[] => {
+  const plugins = [];
+
+  for (let i = 0; i <= amount; i++) {
+    plugins.push({
+      defaultNavUrl: 'some/url',
+      enabled: false,
+      hasUpdate: false,
+      id: `${i}`,
+      info: {
+        author: {
+          name: 'Grafana Labs',
+          url: 'url/to/GrafanaLabs',
+        },
+        description: 'pretty decent plugin',
+        links: ['one link'],
+        logos: { small: 'small/logo', large: 'large/logo' },
+        screenshots: `screenshot/${i}`,
+        updated: '2018-09-26',
+        version: '1',
+      },
+      latestVersion: `1.${i}`,
+      name: `pretty cool plugin-${i}`,
+      pinned: false,
+      state: '',
+      type: '',
+    });
+  }
+
+  return plugins;
+};
+
+export const getMockPlugin = () => {
+  return {
+    defaultNavUrl: 'some/url',
+    enabled: false,
+    hasUpdate: false,
+    id: '1',
+    info: {
+      author: {
+        name: 'Grafana Labs',
+        url: 'url/to/GrafanaLabs',
+      },
+      description: 'pretty decent plugin',
+      links: ['one link'],
+      logos: { small: 'small/logo', large: 'large/logo' },
+      screenshots: 'screenshot/1',
+      updated: '2018-09-26',
+      version: '1',
+    },
+    latestVersion: '1',
+    name: 'pretty cool plugin 1',
+    pinned: false,
+    state: '',
+    type: '',
+  };
+};

+ 40 - 0
public/app/features/plugins/__snapshots__/PluginActionBar.test.tsx.snap

@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <LayoutSelector
+      mode="grid"
+      onLayoutModeChanged={[Function]}
+    />
+  </div>
+  <div
+    className="page-action-bar__spacer"
+  />
+  <a
+    className="btn btn-success"
+    href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
+    target="_blank"
+  >
+    Find more plugins on Grafana.com
+  </a>
+</div>
+`;

+ 210 - 0
public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap

@@ -0,0 +1,210 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<section
+  className="card-section card-list-layout-grid"
+>
+  <ol
+    className="card-list"
+  >
+    <PluginListItem
+      key="pretty cool plugin-0-0"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "0",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/0",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.0",
+          "name": "pretty cool plugin-0",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-1-1"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "1",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/1",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.1",
+          "name": "pretty cool plugin-1",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-2-2"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "2",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/2",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.2",
+          "name": "pretty cool plugin-2",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-3-3"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "3",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/3",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.3",
+          "name": "pretty cool plugin-3",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-4-4"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "4",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/4",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.4",
+          "name": "pretty cool plugin-4",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-5-5"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "5",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/5",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.5",
+          "name": "pretty cool plugin-5",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+  </ol>
+</section>
+`;

+ 106 - 0
public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap

@@ -0,0 +1,106 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<li
+  className="card-item-wrapper"
+>
+  <a
+    className="card-item"
+    href="plugins/1/edit"
+  >
+    <div
+      className="card-item-header"
+    >
+      <div
+        className="card-item-type"
+      >
+        <i
+          className="icon-gf icon-gf-"
+        />
+      </div>
+    </div>
+    <div
+      className="card-item-body"
+    >
+      <figure
+        className="card-item-figure"
+      >
+        <img
+          src="small/logo"
+        />
+      </figure>
+      <div
+        className="card-item-details"
+      >
+        <div
+          className="card-item-name"
+        >
+          pretty cool plugin 1
+        </div>
+        <div
+          className="card-item-sub-name"
+        >
+          By Grafana Labs
+        </div>
+      </div>
+    </div>
+  </a>
+</li>
+`;
+
+exports[`Render should render has plugin section 1`] = `
+<li
+  className="card-item-wrapper"
+>
+  <a
+    className="card-item"
+    href="plugins/1/edit"
+  >
+    <div
+      className="card-item-header"
+    >
+      <div
+        className="card-item-type"
+      >
+        <i
+          className="icon-gf icon-gf-"
+        />
+      </div>
+      <div
+        className="card-item-notice"
+      >
+        <span
+          bs-tooltip="plugin.latestVersion"
+        >
+          Update available!
+        </span>
+      </div>
+    </div>
+    <div
+      className="card-item-body"
+    >
+      <figure
+        className="card-item-figure"
+      >
+        <img
+          src="small/logo"
+        />
+      </figure>
+      <div
+        className="card-item-details"
+      >
+        <div
+          className="card-item-name"
+        >
+          pretty cool plugin 1
+        </div>
+        <div
+          className="card-item-sub-name"
+        >
+          By Grafana Labs
+        </div>
+      </div>
+    </div>
+  </a>
+</li>
+`;

+ 18 - 0
public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap

@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(PluginActionBar) />
+    <PluginList
+      layoutMode="grid"
+      plugins={Array []}
+    />
+  </div>
+</div>
+`;

+ 0 - 1
public/app/features/plugins/all.ts

@@ -1,6 +1,5 @@
 import './plugin_edit_ctrl';
 import './plugin_page_ctrl';
-import './plugin_list_ctrl';
 import './import_list/import_list';
 import './ds_edit_ctrl';
 import './ds_dashboards_ctrl';

+ 0 - 45
public/app/features/plugins/partials/plugin_list.html

@@ -1,45 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <div class="page-action-bar">
-		<div class="gf-form gf-form--grow">
-			<label class="gf-form--has-input-icon">
-				<input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by name or type" />
-				<i class="gf-form-input-icon fa fa-search"></i>
-			</label>
-			<layout-selector />
-		</div>
-		<div class="page-action-bar__spacer"></div>
-		<a class="btn btn-success" href="https://grafana.com/plugins?utm_source=grafana_plugin_list" target="_blank">
-			Find more plugins on Grafana.com
-		</a>
-	</div>
-
-	<section class="card-section" layout-mode>
-
-		<ol class="card-list" >
-			<li class="card-item-wrapper" ng-repeat="plugin in ctrl.plugins">
-				<a class="card-item" href="plugins/{{plugin.id}}/edit">
-					<div class="card-item-header">
-						<div class="card-item-type">
-							<i class="icon-gf icon-gf-{{plugin.type}}"></i>
-							{{plugin.type}}
-						</div>
-						<div class="card-item-notice" ng-show="plugin.hasUpdate">
-							<span bs-tooltip="plugin.latestVersion">Update available!</span>
-						</div>
-					</div>
-					<div class="card-item-body">
-						<figure class="card-item-figure">
-							<img ng-src="{{plugin.info.logos.small}}">
-						</figure>
-						<div class="card-item-details">
-							<div class="card-item-name">{{plugin.name}}</div>
-							<div class="card-item-sub-name">By {{plugin.info.author.name}}</div>
-						</div>
-					</div>
-				</a>
-			</li>
-		</ol>
-	</section>
-</div>

+ 0 - 30
public/app/features/plugins/plugin_list_ctrl.ts

@@ -1,30 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-
-export class PluginListCtrl {
-  plugins: any[];
-  tabIndex: number;
-  navModel: any;
-  searchQuery: string;
-  allPlugins: any[];
-
-  /** @ngInject */
-  constructor(private backendSrv: any, $location, navModelSrv) {
-    this.tabIndex = 0;
-    this.navModel = navModelSrv.getNav('cfg', 'plugins', 0);
-
-    this.backendSrv.get('api/plugins', { embedded: 0 }).then(plugins => {
-      this.plugins = plugins;
-      this.allPlugins = plugins;
-    });
-  }
-
-  onQueryUpdated() {
-    const regex = new RegExp(this.searchQuery, 'ig');
-    this.plugins = _.filter(this.allPlugins, item => {
-      return regex.test(item.name) || regex.test(item.type);
-    });
-  }
-}
-
-angular.module('grafana.controllers').controller('PluginListCtrl', PluginListCtrl);

+ 51 - 0
public/app/features/plugins/state/actions.ts

@@ -0,0 +1,51 @@
+import { Plugin, StoreState } from 'app/types';
+import { ThunkAction } from 'redux-thunk';
+import { getBackendSrv } from '../../../core/services/backend_srv';
+import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
+
+export enum ActionTypes {
+  LoadPlugins = 'LOAD_PLUGINS',
+  SetPluginsSearchQuery = 'SET_PLUGIN_SEARCH_QUERY',
+  SetLayoutMode = 'SET_LAYOUT_MODE',
+}
+
+export interface LoadPluginsAction {
+  type: ActionTypes.LoadPlugins;
+  payload: Plugin[];
+}
+
+export interface SetPluginsSearchQueryAction {
+  type: ActionTypes.SetPluginsSearchQuery;
+  payload: string;
+}
+
+export interface SetLayoutModeAction {
+  type: ActionTypes.SetLayoutMode;
+  payload: LayoutMode;
+}
+
+export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
+  type: ActionTypes.SetLayoutMode,
+  payload: mode,
+});
+
+export const setPluginsSearchQuery = (query: string): SetPluginsSearchQueryAction => ({
+  type: ActionTypes.SetPluginsSearchQuery,
+  payload: query,
+});
+
+const pluginsLoaded = (plugins: Plugin[]): LoadPluginsAction => ({
+  type: ActionTypes.LoadPlugins,
+  payload: plugins,
+});
+
+export type Action = LoadPluginsAction | SetPluginsSearchQueryAction | SetLayoutModeAction;
+
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
+
+export function loadPlugins(): ThunkResult<void> {
+  return async dispatch => {
+    const result = await getBackendSrv().get('api/plugins', { embedded: 0 });
+    dispatch(pluginsLoaded(result));
+  };
+}

+ 27 - 0
public/app/features/plugins/state/reducers.ts

@@ -0,0 +1,27 @@
+import { Action, ActionTypes } from './actions';
+import { Plugin, PluginsState } from 'app/types';
+import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
+
+export const initialState: PluginsState = {
+  plugins: [] as Plugin[],
+  searchQuery: '',
+  layoutMode: LayoutModes.Grid,
+};
+
+export const pluginsReducer = (state = initialState, action: Action): PluginsState => {
+  switch (action.type) {
+    case ActionTypes.LoadPlugins:
+      return { ...state, plugins: action.payload };
+
+    case ActionTypes.SetPluginsSearchQuery:
+      return { ...state, searchQuery: action.payload };
+
+    case ActionTypes.SetLayoutMode:
+      return { ...state, layoutMode: action.payload };
+  }
+  return state;
+};
+
+export default {
+  plugins: pluginsReducer,
+};

+ 31 - 0
public/app/features/plugins/state/selectors.test.ts

@@ -0,0 +1,31 @@
+import { getPlugins, getPluginsSearchQuery } from './selectors';
+import { initialState } from './reducers';
+import { getMockPlugins } from '../__mocks__/pluginMocks';
+
+describe('Selectors', () => {
+  const mockState = initialState;
+
+  it('should return search query', () => {
+    mockState.searchQuery = 'test';
+    const query = getPluginsSearchQuery(mockState);
+
+    expect(query).toEqual(mockState.searchQuery);
+  });
+
+  it('should return plugins', () => {
+    mockState.plugins = getMockPlugins(5);
+    mockState.searchQuery = '';
+
+    const plugins = getPlugins(mockState);
+
+    expect(plugins).toEqual(mockState.plugins);
+  });
+
+  it('should filter plugins', () => {
+    mockState.searchQuery = 'plugin-1';
+
+    const plugins = getPlugins(mockState);
+
+    expect(plugins.length).toEqual(1);
+  });
+});

+ 10 - 0
public/app/features/plugins/state/selectors.ts

@@ -0,0 +1,10 @@
+export const getPlugins = state => {
+  const regex = new RegExp(state.searchQuery, 'i');
+
+  return state.plugins.filter(item => {
+    return regex.test(item.name) || regex.test(item.info.author.name) || regex.test(item.info.description);
+  });
+};
+
+export const getPluginsSearchQuery = state => state.searchQuery;
+export const getLayoutMode = state => state.layoutMode;

+ 58 - 0
public/app/features/templating/TextBoxVariable.ts

@@ -0,0 +1,58 @@
+import { Variable, assignModelProperties, variableTypes } from './variable';
+
+export class TextBoxVariable implements Variable {
+  query: string;
+  current: any;
+  options: any[];
+  skipUrlSync: boolean;
+
+  defaults = {
+    type: 'textbox',
+    name: '',
+    hide: 2,
+    label: '',
+    query: '',
+    current: {},
+    options: [],
+    skipUrlSync: false,
+  };
+
+  /** @ngInject */
+  constructor(private model, private variableSrv) {
+    assignModelProperties(this, model, this.defaults);
+  }
+
+  getSaveModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  setValue(option) {
+    this.variableSrv.setOptionAsCurrent(this, option);
+  }
+
+  updateOptions() {
+    this.options = [{ text: this.query.trim(), value: this.query.trim() }];
+    this.current = this.options[0];
+    return Promise.resolve();
+  }
+
+  dependsOn(variable) {
+    return false;
+  }
+
+  setValueFromUrl(urlValue) {
+    this.query = urlValue;
+    return this.variableSrv.setOptionFromUrl(this, urlValue);
+  }
+
+  getValueForUrl() {
+    return this.current.value;
+  }
+}
+
+variableTypes['textbox'] = {
+  name: 'Text box',
+  ctor: TextBoxVariable,
+  description: 'Define a textbox variable, where users can enter any arbitrary string',
+};

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

@@ -9,6 +9,7 @@ import { DatasourceVariable } from './datasource_variable';
 import { CustomVariable } from './custom_variable';
 import { ConstantVariable } from './constant_variable';
 import { AdhocVariable } from './adhoc_variable';
+import { TextBoxVariable } from './TextBoxVariable';
 
 coreModule.factory('templateSrv', () => {
   return templateSrv;
@@ -22,4 +23,5 @@ export {
   CustomVariable,
   ConstantVariable,
   AdhocVariable,
+  TextBoxVariable,
 };

+ 8 - 0
public/app/features/templating/partials/editor.html

@@ -155,6 +155,14 @@
 			</div>
 		</div>
 
+		<div ng-if="current.type === 'textbox'" class="gf-form-group">
+			<h5 class="section-heading">Text options</h5>
+			<div class="gf-form">
+				<span class="gf-form-label">Default value</span>
+				<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="default value, if any"></input>
+			</div>
+		</div>
+
 		<div ng-if="current.type === 'query'" class="gf-form-group">
 			<h5 class="section-heading">Query Options</h5>
 

+ 1 - 1
public/app/plugins/datasource/cloudwatch/partials/config.html

@@ -39,7 +39,7 @@
   <div class="gf-form">
     <label class="gf-form-label width-13">Default Region</label>
     <div class="gf-form-select-wrapper max-width-18 gf-form-select-wrapper--has-help-icon">
-      <select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ca-central-1', 'cn-north-1', 'cn-northwest-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'sa-east-1', 'us-east-1', 'us-east-2', 'us-gov-west-1', 'us-west-1', 'us-west-2']"></select>
+      <select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ca-central-1', 'cn-north-1', 'cn-northwest-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'sa-east-1', 'us-east-1', 'us-east-2', 'us-gov-west-1', 'us-west-1', 'us-west-2', 'us-isob-east-1', 'us-iso-east-1']"></select>
       <info-popover mode="right-absolute">
         Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region.
       </info-popover>

+ 5 - 3
public/app/routes/routes.ts

@@ -5,6 +5,7 @@ import ServerStats from 'app/features/admin/ServerStats';
 import AlertRuleList from 'app/features/alerting/AlertRuleList';
 import TeamPages from 'app/features/teams/TeamPages';
 import TeamList from 'app/features/teams/TeamList';
+import PluginListPage from 'app/features/plugins/PluginListPage';
 import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
 import FolderPermissions from 'app/features/folders/FolderPermissions';
 
@@ -245,9 +246,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controllerAs: 'ctrl',
     })
     .when('/plugins', {
-      templateUrl: 'public/app/features/plugins/partials/plugin_list.html',
-      controller: 'PluginListCtrl',
-      controllerAs: 'ctrl',
+      template: '<react-container />',
+      resolve: {
+        component: () => PluginListPage,
+      },
     })
     .when('/plugins/:pluginId/edit', {
       templateUrl: 'public/app/features/plugins/partials/plugin_edit.html',

+ 2 - 0
public/app/store/configureStore.ts

@@ -6,6 +6,7 @@ import alertingReducers from 'app/features/alerting/state/reducers';
 import teamsReducers from 'app/features/teams/state/reducers';
 import foldersReducers from 'app/features/folders/state/reducers';
 import dashboardReducers from 'app/features/dashboard/state/reducers';
+import pluginReducers from 'app/features/plugins/state/reducers';
 
 const rootReducer = combineReducers({
   ...sharedReducers,
@@ -13,6 +14,7 @@ const rootReducer = combineReducers({
   ...teamsReducers,
   ...foldersReducers,
   ...dashboardReducers,
+  ...pluginReducers,
 });
 
 export let store;

+ 3 - 1
public/app/types/index.ts

@@ -6,7 +6,7 @@ import { FolderDTO, FolderState, FolderInfo } from './folders';
 import { DashboardState } from './dashboard';
 import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
 import { DataSource } from './datasources';
-import { PluginMeta } from './plugins';
+import { PluginMeta, Plugin, PluginsState } from './plugins';
 
 export {
   Team,
@@ -33,6 +33,8 @@ export {
   PermissionLevel,
   DataSource,
   PluginMeta,
+  Plugin,
+  PluginsState,
 };
 
 export interface StoreState {

+ 28 - 0
public/app/types/plugins.ts

@@ -12,8 +12,36 @@ export interface PluginInclude {
 }
 
 export interface PluginMetaInfo {
+  author: {
+    name: string;
+    url: string;
+  };
+  description: string;
+  links: string[];
   logos: {
     large: string;
     small: string;
   };
+  screenshots: string;
+  updated: string;
+  version: string;
+}
+
+export interface Plugin {
+  defaultNavUrl: string;
+  enabled: boolean;
+  hasUpdate: boolean;
+  id: string;
+  info: PluginMetaInfo;
+  latestVersion: string;
+  name: string;
+  pinned: boolean;
+  state: string;
+  type: string;
+}
+
+export interface PluginsState {
+  plugins: Plugin[];
+  searchQuery: string;
+  layoutMode: string;
 }

+ 25 - 0
public/sass/components/_buttons.scss

@@ -221,6 +221,31 @@ $btn-service-icon-width: 35px;
   }
 }
 
+//Toggle button
+
+.toggle-btn {
+  background: $input-label-bg;
+  color: $text-color-weak;
+  box-shadow: $card-shadow;
+
+  &:first-child {
+    border-radius: 2px 0 0 2px;
+    margin: 0;
+  }
+  &:last-child {
+    border-radius: 0 2px 2px 0;
+    margin-left: 0 !important;
+  }
+
+  &.active {
+    background-color: lighten($input-label-bg, 5%);
+    color: $link-color;
+    &:hover {
+      cursor: default;
+    }
+  }
+}
+
 //Button animations
 
 .btn-loading span {

+ 6 - 12
public/sass/components/_form_select_box.scss

@@ -3,7 +3,7 @@ $select-menu-max-height: 300px;
 $select-item-font-size: $font-size-base;
 $select-item-bg: $dropdownBackground;
 $select-item-fg: $input-color;
-$select-option-bg: $dropdownBackground;
+$select-option-bg: $menu-dropdown-bg;
 $select-option-color: $input-color;
 $select-noresults-color: $text-color;
 $select-input-bg: $input-bg;
@@ -82,20 +82,14 @@ $select-option-selected-bg: $dropdownLinkBackgroundActive;
     width: auto;
   }
 
+  .Select-option {
+    border-left: 2px solid transparent;
+  }
+
   .Select-option.is-focused {
     background-color: $dropdownLinkBackgroundHover;
     color: $dropdownLinkColorHover;
-
-    &::before {
-      position: absolute;
-      left: 0;
-      top: 0;
-      height: 100%;
-      width: 2px;
-      display: block;
-      content: '';
-      background-image: linear-gradient(to bottom, #ffd500 0%, #ff4400 99%, #ff4400 100%);
-    }
+    @include left-brand-border-gradient();
   }
 }
 

+ 1 - 1
public/sass/pages/_explore.scss

@@ -69,7 +69,7 @@
   }
 
   .datasource-picker {
-    min-width: 10rem;
+    min-width: 200px;
   }
 
   .timepicker {

+ 4 - 1
public/views/index.template.html

@@ -275,7 +275,10 @@
     document.head.insertBefore(myCSS, document.head.childNodes[document.head.childNodes.length - 1].nextSibling);
     // switch loader to show all has loaded
     window.onload = function() {
-      document.getElementsByClassName("preloader")[0].className = "preloader preloader--done";
+      var preloader = document.getElementsByClassName("preloader");
+      if (preloader.length) {
+        preloader[0].className = "preloader preloader--done";
+      }
     };
   </script>
 

+ 5 - 4
scripts/grunt/default_task.js

@@ -1,5 +1,5 @@
 // Lint and build CSS
-module.exports = function(grunt) {
+module.exports = function (grunt) {
   'use strict';
 
   grunt.registerTask('default', [
@@ -18,15 +18,16 @@ module.exports = function(grunt) {
   grunt.registerTask('precommit', [
     'sasslint',
     'exec:tslint',
+    'exec:tsc',
     'no-only-tests'
   ]);
 
-  grunt.registerTask('no-only-tests', function() {
+  grunt.registerTask('no-only-tests', function () {
     var files = grunt.file.expand('public/**/*_specs\.ts', 'public/**/*_specs\.js');
 
-    files.forEach(function(spec) {
+    files.forEach(function (spec) {
       var rows = grunt.file.read(spec).split('\n');
-      rows.forEach(function(row) {
+      rows.forEach(function (row) {
         if (row.indexOf('.only(') > 0) {
           grunt.log.errorlns(row);
           grunt.fail.warn('found only statement in test: ' + spec)

+ 2 - 1
scripts/grunt/options/exec.js

@@ -1,8 +1,9 @@
-module.exports = function(config, grunt) {
+module.exports = function (config, grunt) {
   'use strict';
 
   return {
     tslint: 'node ./node_modules/tslint/lib/tslintCli.js -c tslint.json --project ./tsconfig.json',
+    tsc: 'yarn tsc --noEmit',
     jest: 'node ./node_modules/jest-cli/bin/jest.js --maxWorkers 2',
     webpack: 'node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js',
   };