فهرست منبع

Merge branch 'master' of github.com:grafana/grafana into react-panels

Torkel Ödegaard 7 سال پیش
والد
کامیت
4fd2107071
100فایلهای تغییر یافته به همراه1361 افزوده شده و 958 حذف شده
  1. 38 4
      .circleci/config.yml
  2. 2 0
      .gitignore
  3. 35 2
      CHANGELOG.md
  4. 0 1
      build.go
  5. 0 0
      devenv/bulk-dashboards/bulk-dashboards.yaml
  6. 0 0
      devenv/bulk-dashboards/bulkdash.jsonnet
  7. 9 0
      devenv/dashboards.yaml
  8. 0 9
      devenv/dashboards/dev-dashboards/dev-dashboards.yaml
  9. 14 14
      devenv/datasources.yaml
  10. 0 0
      devenv/dev-dashboards/dashboard_with_rows.json
  11. 10 15
      devenv/setup.sh
  12. 3 1
      docker/blocks/openldap/Dockerfile
  13. 5 4
      docker/blocks/openldap/entrypoint.sh
  14. 25 1
      docker/blocks/openldap/notes.md
  15. 14 0
      docker/blocks/openldap/prepopulate.sh
  16. 9 0
      docker/blocks/openldap/prepopulate/1_units.ldif
  17. 80 0
      docker/blocks/openldap/prepopulate/2_users.ldif
  18. 25 0
      docker/blocks/openldap/prepopulate/3_groups.ldif
  19. 0 10
      docker/blocks/openldap/prepopulate/admin.ldif
  20. 0 5
      docker/blocks/openldap/prepopulate/adminsgroup.ldif
  21. 0 10
      docker/blocks/openldap/prepopulate/editor.ldif
  22. 0 5
      docker/blocks/openldap/prepopulate/usersgroup.ldif
  23. 0 9
      docker/blocks/openldap/prepopulate/viewer.ldif
  24. 3 3
      docs/sources/features/datasources/mssql.md
  25. 3 3
      docs/sources/features/datasources/mysql.md
  26. 26 24
      docs/sources/guides/whats-new-in-v5-2.md
  27. 15 15
      docs/sources/http_api/admin.md
  28. 8 0
      docs/sources/http_api/auth.md
  29. 6 2
      docs/sources/http_api/folder.md
  30. 119 105
      docs/sources/http_api/org.md
  31. 3 3
      docs/sources/index.md
  32. 2 2
      docs/sources/installation/behind_proxy.md
  33. 2 0
      docs/sources/installation/windows.md
  34. 20 30
      docs/sources/reference/scripting.md
  35. 2 1
      docs/versions.json
  36. 2 2
      latest.json
  37. 3 3
      package.json
  38. 1 1
      pkg/api/alerting_test.go
  39. 3 3
      pkg/api/annotations_test.go
  40. 136 147
      pkg/api/api.go
  41. 1 1
      pkg/api/app_routes.go
  42. 1 1
      pkg/api/common.go
  43. 2 2
      pkg/api/common_test.go
  44. 1 1
      pkg/api/dashboard_permission_test.go
  45. 2 2
      pkg/api/dashboard_test.go
  46. 1 0
      pkg/api/dtos/index.go
  47. 1 1
      pkg/api/folder_permission_test.go
  48. 2 2
      pkg/api/folder_test.go
  49. 1 0
      pkg/api/frontendsettings.go
  50. 31 7
      pkg/api/http_server.go
  51. 1 0
      pkg/api/index.go
  52. 4 4
      pkg/cmd/grafana-server/main.go
  53. 2 2
      pkg/cmd/grafana-server/server.go
  54. 1 1
      pkg/extensions/main.go
  55. 17 1
      pkg/login/ext_user.go
  56. 2 0
      pkg/login/ldap.go
  57. 13 3
      pkg/login/ldap_test.go
  58. 9 0
      pkg/metrics/metrics.go
  59. 6 0
      pkg/middleware/auth.go
  60. 8 12
      pkg/middleware/auth_proxy.go
  61. 24 57
      pkg/middleware/middleware_test.go
  62. 1 0
      pkg/models/team_member.go
  63. 6 0
      pkg/models/user_auth.go
  64. 27 3
      pkg/registry/registry.go
  65. 4 4
      pkg/services/alerting/conditions/reducer.go
  66. 21 2
      pkg/services/alerting/conditions/reducer_test.go
  67. 4 4
      pkg/services/alerting/extractor_test.go
  68. 4 1
      pkg/services/alerting/notifier.go
  69. 1 0
      pkg/services/sqlstore/migrations/team_mig.go
  70. 7 0
      pkg/services/sqlstore/sqlstore.go
  71. 9 1
      pkg/services/sqlstore/team.go
  72. 4 3
      pkg/setting/setting.go
  73. 1 0
      pkg/social/github_oauth.go
  74. 9 9
      pkg/tsdb/elasticsearch/client/search_request_test.go
  75. 5 4
      pkg/tsdb/mssql/macros.go
  76. 11 11
      pkg/tsdb/mssql/macros_test.go
  77. 19 13
      pkg/tsdb/mssql/mssql_test.go
  78. 5 4
      pkg/tsdb/mysql/macros.go
  79. 11 11
      pkg/tsdb/mysql/macros_test.go
  80. 21 14
      pkg/tsdb/mysql/mysql_test.go
  81. 1 1
      pkg/tsdb/postgres/macros.go
  82. 2 2
      pkg/tsdb/postgres/macros_test.go
  83. 21 13
      pkg/tsdb/postgres/postgres_test.go
  84. 64 34
      public/app/containers/Explore/QueryField.tsx
  85. 5 1
      public/app/containers/Explore/QueryRows.tsx
  86. 15 4
      public/app/containers/Explore/Typeahead.tsx
  87. 11 10
      public/app/containers/Explore/slate-plugins/prism/index.tsx
  88. 16 2
      public/app/core/config.ts
  89. 2 2
      public/app/core/directives/value_select_dropdown.ts
  90. 0 4
      public/app/core/services/context_srv.ts
  91. 35 0
      public/app/core/specs/table_model.jest.ts
  92. 14 0
      public/app/core/specs/time_series.jest.ts
  93. 159 0
      public/app/core/specs/value_select_dropdown.jest.ts
  94. 0 171
      public/app/core/specs/value_select_dropdown_specs.ts
  95. 5 12
      public/app/core/table_model.ts
  96. 11 11
      public/app/features/annotations/specs/annotations_srv.jest.ts
  97. 1 3
      public/app/features/dashboard/specs/exporter.jest.ts
  98. 67 0
      public/app/features/dashboard/specs/viewstate_srv.jest.ts
  99. 0 65
      public/app/features/dashboard/specs/viewstate_srv_specs.ts
  100. 9 8
      public/app/features/dashlinks/module.ts

+ 38 - 4
.circleci/config.yml

@@ -8,6 +8,9 @@ aliases:
   - &filter-not-release
     tags:
       ignore: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/
+  - &filter-only-master
+    branches:
+      only: master
 
 version: 2
 
@@ -91,9 +94,6 @@ jobs:
       - image: circleci/node:8
     steps:
       - checkout
-      - run:
-          name: install yarn
-          command: 'sudo npm install -g yarn --quiet'
       - restore_cache:
           key: dependency-cache-{{ checksum "yarn.lock" }}
       - run:
@@ -163,7 +163,7 @@ jobs:
     steps:
       - checkout
       - run:
-          name: build and package grafana
+          name: build, test and package grafana enterprise
           command: './scripts/build/build_enterprise.sh'
       - run:
           name: sign packages
@@ -171,6 +171,26 @@ jobs:
       - run:
           name: sha-sum packages
           command: 'go run build.go sha-dist'
+      - run:
+          name: move enterprise packages into their own folder
+          command: 'mv dist enterprise-dist'
+      - persist_to_workspace:
+          root: .
+          paths:
+            - enterprise-dist/grafana-enterprise*
+
+  deploy-enterprise-master:
+    docker:
+      - image: circleci/python:2.7-stretch
+    steps:
+      - attach_workspace:
+          at: .
+      - run:
+          name: install awscli
+          command: 'sudo pip install awscli'
+      - run:
+          name: deploy to s3
+          command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master'
 
   deploy-master:
     docker:
@@ -224,6 +244,8 @@ workflows:
     jobs:
       - build-all:
           filters: *filter-not-release
+      - build-enterprise:
+          filters: *filter-only-master
       - codespell:
           filters: *filter-not-release
       - gometalinter:
@@ -248,6 +270,18 @@ workflows:
           filters:
            branches:
              only: master
+      - deploy-enterprise-master:
+          requires:
+            - build-all
+            - test-backend
+            - test-frontend
+            - codespell
+            - gometalinter
+            - mysql-integration-test
+            - postgres-integration-test
+            - build-enterprise
+          filters: *filter-only-master
+
   release:
     jobs:
       - build-all:

+ 2 - 0
.gitignore

@@ -43,6 +43,8 @@ fig.yml
 docker-compose.yml
 docker-compose.yaml
 /conf/provisioning/**/custom.yaml
+/conf/provisioning/**/dev.yaml
+/conf/ldap_dev.toml
 profile.cov
 /grafana
 /local

+ 35 - 2
CHANGELOG.md

@@ -7,8 +7,32 @@
 
 * **Api**: Delete nonexistent datasource should return 404 [#12313](https://github.com/grafana/grafana/issues/12313), thx [@AustinWinstanley](https://github.com/AustinWinstanley)
 * **Dashboard**: Fix selecting current dashboard from search should not reload dashboard [#12248](https://github.com/grafana/grafana/issues/12248)
+* **Singlestat**: Make colorization of prefix and postfix optional in singlestat [#11892](https://github.com/grafana/grafana/pull/11892), thx [@ApsOps](https://github.com/ApsOps)
+* **Table**: Make table sorting stable when null values exist [#12362](https://github.com/grafana/grafana/pull/12362), thx [@bz2](https://github.com/bz2)
+* **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379)
+* **Prometheus**: Heatmap - fix unhandled error when some points are missing [#12484](https://github.com/grafana/grafana/issues/12484)
+* **Variables**: Skip unneeded extra query request when de-selecting variable values used for repeated panels [#8186](https://github.com/grafana/grafana/issues/8186), thx [@mtanda](https://github.com/mtanda)
+* **Postgres/MySQL/MSSQL**: Use floor rounding in $__timeGroup macro function [#12460](https://github.com/grafana/grafana/issues/12460), thx [@svenklemm](https://github.com/svenklemm)
+* **MySQL/MSSQL**: Use datetime format instead of epoch for $__timeFilter, $__timeFrom and $__timeTo macros [#11618](https://github.com/grafana/grafana/issues/11618) [#11619](https://github.com/grafana/grafana/issues/11619), thx [@AustinWinstanley](https://github.com/AustinWinstanley)
+* **Github OAuth**: Allow changes of user info at Github to be synched to Grafana when signing in [#11818](https://github.com/grafana/grafana/issues/11818), thx [@rwaweber](https://github.com/rwaweber)
+* **Alerting**: Fix diff and percent_diff reducers [#11563](https://github.com/grafana/grafana/issues/11563), thx [@jessetane](https://github.com/jessetane)
+
+# 5.2.2 (unreleased)
 
-# 5.2.0 (unreleased)
+### Minor
+
+* **Prometheus**: Fix graph panel bar width issue in aligned prometheus queries [#12379](https://github.com/grafana/grafana/issues/12379)
+* **Dashboard**: Dashboard links not updated when changing variables [#12506](https://github.com/grafana/grafana/issues/12506)
+
+# 5.2.1 (2018-06-29)
+
+### Minor
+
+* **Auth Proxy**: Important security fix for whitelist of IP address feature [#12444](https://github.com/grafana/grafana/pull/12444)
+* **UI**: Fix - Grafana footer overlapping page [#12430](https://github.com/grafana/grafana/issues/12430)
+* **Logging**: Errors should be reported before crashing [#12438](https://github.com/grafana/grafana/issues/12438)
+
+# 5.2.0-stable (2018-06-27)
 
 ### Minor
 
@@ -16,6 +40,10 @@
 * **Render**: Enhance error message if phantomjs executable is not found [#11868](https://github.com/grafana/grafana/issues/11868)
 * **Dashboard**: Set correct text in drop down when variable is present in url [#11968](https://github.com/grafana/grafana/issues/11968)
 
+### 5.2.0-beta3 fixes
+
+* **LDAP**: Handle "dn" ldap attribute more gracefully [#12385](https://github.com/grafana/grafana/pull/12385), reverts [#10970](https://github.com/grafana/grafana/pull/10970)
+
 # 5.2.0-beta3 (2018-06-21)
 
 ### Minor
@@ -57,6 +85,7 @@
 ### New Features
 
 * **Elasticsearch**: Alerting support [#5893](https://github.com/grafana/grafana/issues/5893), thx [@WPH95](https://github.com/WPH95)
+* **Build**: Crosscompile and packages Grafana on arm, windows, linux and darwin [#11920](https://github.com/grafana/grafana/pull/11920), thx [@fg2it](https://github.com/fg2it)
 * **Login**: Change admin password after first login [#11882](https://github.com/grafana/grafana/issues/11882)
 * **Alert list panel**: Updated to support filtering alerts by name, dashboard title, folder, tags [#11500](https://github.com/grafana/grafana/issues/11500), [#8168](https://github.com/grafana/grafana/issues/8168), [#6541](https://github.com/grafana/grafana/issues/6541)
 
@@ -92,6 +121,10 @@
 * **Dashboard list panel**: Search dashboards by folder [#11525](https://github.com/grafana/grafana/issues/11525)
 * **Sidenav**: Always show server admin link in sidenav if grafana admin [#11657](https://github.com/grafana/grafana/issues/11657)
 
+# 5.1.5 (2018-06-27)
+
+* **Docker**: Config keys ending with _FILE are not respected [#170](https://github.com/grafana/grafana-docker/issues/170)
+
 # 5.1.4 (2018-06-19)
 
 * **Permissions**: Important security fix for API keys with viewer role [#12343](https://github.com/grafana/grafana/issues/12343)
@@ -1319,7 +1352,7 @@ Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated
 **New features**
 - [Issue #1623](https://github.com/grafana/grafana/issues/1623). Share Dashboard: Dashboard snapshot sharing (dash and data snapshot), save to local or save to public snapshot dashboard snapshots.raintank.io site
 - [Issue #1622](https://github.com/grafana/grafana/issues/1622). Share Panel: The share modal now has an embed option, gives you an iframe that you can use to embedd a single graph on another web site
-- [Issue #718](https://github.com/grafana/grafana/issues/718).   Dashboard: When saving a dashboard and another user has made changes in between the user is promted with a warning if he really wants to overwrite the other's changes
+- [Issue #718](https://github.com/grafana/grafana/issues/718).   Dashboard: When saving a dashboard and another user has made changes in between the user is prompted with a warning if he really wants to overwrite the other's changes
 - [Issue #1331](https://github.com/grafana/grafana/issues/1331). Graph & Singlestat: New axis/unit format selector and more units (kbytes, Joule, Watt, eV), and new design for graph axis & grid tab and single stat options tab views
 - [Issue #1241](https://github.com/grafana/grafana/issues/1242). Timepicker: New option in timepicker (under dashboard settings), to change ``now`` to be for example ``now-1m``, useful when you want to ignore last minute because it contains incomplete data
 - [Issue #171](https://github.com/grafana/grafana/issues/171).   Panel: Different time periods, panels can override dashboard relative time and/or add a time shift

+ 0 - 1
build.go

@@ -465,7 +465,6 @@ func ldflags() string {
 	b.WriteString(fmt.Sprintf(" -X main.version=%s", version))
 	b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha()))
 	b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp()))
-	b.WriteString(fmt.Sprintf(" -X main.enterprise=%t", enterprise))
 	return b.String()
 }
 

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


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


+ 9 - 0
devenv/dashboards.yaml

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

+ 0 - 9
devenv/dashboards/dev-dashboards/dev-dashboards.yaml

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

+ 14 - 14
devenv/datasources/default/default.yaml → devenv/datasources.yaml

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

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


+ 10 - 15
devenv/setup.sh

@@ -23,41 +23,36 @@ requiresJsonnet() {
 }
 
 defaultDashboards() {
-		requiresJsonnet
-
-		ln -s -f -r ./dashboards/dev-dashboards/dev-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
+		ln -s -f ../../../devenv/dashboards.yaml ../conf/provisioning/dashboards/dev.yaml
 }
 
 defaultDatasources() {
 		echo "setting up all default datasources using provisioning"
 
-		ln -s -f -r ./datasources/default/default.yaml ../conf/provisioning/datasources/custom.yaml
+		ln -s -f ../../../devenv/datasources.yaml ../conf/provisioning/datasources/dev.yaml
 }
 
 usage() {
-	echo -e "install.sh\n\tThis script installs my basic setup for a debian laptop\n"
+	echo -e "install.sh\n\tThis script setups dev provision for datasources and dashboards"
 	echo "Usage:"
 	echo "  bulk-dashboards                     - create and provisioning 400 dashboards"
-	echo "  default-datasources                 - provisiong all core datasources"
+	echo "  no args                             - provisiong core datasources and dev dashboards"
 }
 
 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
-		defaultDashboards
 	else
+		defaultDashboards
+		defaultDatasources
+	fi
+
+  if [[ -z "$cmd" ]]; then
 		usage
 	fi
+
 }
 
 main "$@"

+ 3 - 1
docker/blocks/openldap/Dockerfile

@@ -8,7 +8,8 @@ ENV OPENLDAP_VERSION 2.4.40
 
 RUN apt-get update && \
     DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
-        slapd=${OPENLDAP_VERSION}* && \
+        slapd=${OPENLDAP_VERSION}* \
+        ldap-utils && \
     apt-get clean && \
     rm -rf /var/lib/apt/lists/*
 
@@ -22,6 +23,7 @@ COPY modules/ /etc/ldap.dist/modules
 COPY prepopulate/ /etc/ldap.dist/prepopulate
 
 COPY entrypoint.sh /entrypoint.sh
+COPY prepopulate.sh /prepopulate.sh
 
 ENTRYPOINT ["/entrypoint.sh"]
 

+ 5 - 4
docker/blocks/openldap/entrypoint.sh

@@ -76,13 +76,14 @@ EOF
         IFS=","; declare -a modules=($SLAPD_ADDITIONAL_MODULES); unset IFS
 
         for module in "${modules[@]}"; do
-             slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/modules/${module}.ldif" >/dev/null 2>&1
+          echo "Adding module ${module}"
+          slapadd -n0 -F /etc/ldap/slapd.d -l "/etc/ldap/modules/${module}.ldif" >/dev/null 2>&1
         done
     fi
 
-    for file in `ls /etc/ldap/prepopulate/*.ldif`; do
-        slapadd -F /etc/ldap/slapd.d -l "$file"
-    done
+    # This needs to run in background
+    # Will prepopulate entries after ldap daemon has started
+    ./prepopulate.sh &
 
     chown -R openldap:openldap /etc/ldap/slapd.d/ /var/lib/ldap/ /var/run/slapd/
 else

+ 25 - 1
docker/blocks/openldap/notes.md

@@ -1,6 +1,6 @@
 # Notes on OpenLdap Docker Block
 
-Any ldif files added to the prepopulate subdirectory will be automatically imported into the OpenLdap database. 
+Any ldif files added to the prepopulate subdirectory will be automatically imported into the OpenLdap database.
 
 The ldif files add three users, `ldapviewer`, `ldapeditor` and `ldapadmin`. Two groups, `admins` and `users`, are added that correspond with the group mappings in the default conf/ldap.toml. `ldapadmin` is a member of `admins` and `ldapeditor` is a member of `users`.
 
@@ -22,3 +22,27 @@ enabled = true
 config_file = conf/ldap.toml
 ; allow_sign_up = true
 ```
+
+Test groups & users
+
+admins
+  ldap-admin
+  ldap-torkel
+  ldap-daniel
+backend
+  ldap-carl
+  ldap-torkel
+  ldap-leo
+frontend
+  ldap-torkel
+  ldap-tobias
+  ldap-daniel
+editors
+  ldap-editors
+
+
+no groups
+  ldap-viewer
+
+
+

+ 14 - 0
docker/blocks/openldap/prepopulate.sh

@@ -0,0 +1,14 @@
+#!/bin/bash
+
+echo "Pre-populating ldap entries, first waiting for ldap to start"
+
+sleep 3
+
+adminUserDn="cn=admin,dc=grafana,dc=org"
+adminPassword="grafana"
+
+for file in `ls /etc/ldap/prepopulate/*.ldif`; do
+  ldapadd -x -D $adminUserDn -w $adminPassword -f "$file"
+done
+
+

+ 9 - 0
docker/blocks/openldap/prepopulate/1_units.ldif

@@ -0,0 +1,9 @@
+dn: ou=groups,dc=grafana,dc=org
+ou: Groups
+objectclass: top
+objectclass: organizationalUnit
+
+dn: ou=users,dc=grafana,dc=org
+ou: Users
+objectclass: top
+objectclass: organizationalUnit

+ 80 - 0
docker/blocks/openldap/prepopulate/2_users.ldif

@@ -0,0 +1,80 @@
+# ldap-admin
+dn: cn=ldap-admin,ou=users,dc=grafana,dc=org
+mail: ldap-admin@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-admin
+cn: ldap-admin
+
+dn: cn=ldap-editor,ou=users,dc=grafana,dc=org
+mail: ldap-editor@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-editor
+cn: ldap-editor
+
+dn: cn=ldap-viewer,ou=users,dc=grafana,dc=org
+mail: ldap-viewer@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-viewer
+cn: ldap-viewer
+
+dn: cn=ldap-carl,ou=users,dc=grafana,dc=org
+mail: ldap-carl@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-carl
+cn: ldap-carl
+
+dn: cn=ldap-daniel,ou=users,dc=grafana,dc=org
+mail: ldap-daniel@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-daniel
+cn: ldap-daniel
+
+dn: cn=ldap-leo,ou=users,dc=grafana,dc=org
+mail: ldap-leo@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-leo
+cn: ldap-leo
+
+dn: cn=ldap-tobias,ou=users,dc=grafana,dc=org
+mail: ldap-tobias@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-tobias
+cn: ldap-tobias
+
+dn: cn=ldap-torkel,ou=users,dc=grafana,dc=org
+mail: ldap-torkel@grafana.com
+userPassword: grafana
+objectClass: person
+objectClass: top
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+sn: ldap-torkel
+cn: ldap-torkel

+ 25 - 0
docker/blocks/openldap/prepopulate/3_groups.ldif

@@ -0,0 +1,25 @@
+dn: cn=admins,ou=groups,dc=grafana,dc=org
+cn: admins
+objectClass: groupOfNames
+objectClass: top
+member: cn=ldap-admin,ou=users,dc=grafana,dc=org
+member: cn=ldap-torkel,ou=users,dc=grafana,dc=org
+
+dn: cn=editors,ou=groups,dc=grafana,dc=org
+cn: editors
+objectClass: groupOfNames
+member: cn=ldap-editor,ou=users,dc=grafana,dc=org
+
+dn: cn=backend,ou=groups,dc=grafana,dc=org
+cn: backend
+objectClass: groupOfNames
+member: cn=ldap-carl,ou=users,dc=grafana,dc=org
+member: cn=ldap-leo,ou=users,dc=grafana,dc=org
+member: cn=ldap-torkel,ou=users,dc=grafana,dc=org
+
+dn: cn=frontend,ou=groups,dc=grafana,dc=org
+cn: frontend
+objectClass: groupOfNames
+member: cn=ldap-torkel,ou=users,dc=grafana,dc=org
+member: cn=ldap-daniel,ou=users,dc=grafana,dc=org
+member: cn=ldap-leo,ou=users,dc=grafana,dc=org

+ 0 - 10
docker/blocks/openldap/prepopulate/admin.ldif

@@ -1,10 +0,0 @@
-dn: cn=ldapadmin,dc=grafana,dc=org
-mail: ldapadmin@grafana.com
-userPassword: grafana
-objectClass: person
-objectClass: top
-objectClass: inetOrgPerson
-objectClass: organizationalPerson
-sn: ldapadmin
-cn: ldapadmin
-memberOf: cn=admins,dc=grafana,dc=org

+ 0 - 5
docker/blocks/openldap/prepopulate/adminsgroup.ldif

@@ -1,5 +0,0 @@
-dn: cn=admins,dc=grafana,dc=org
-cn: admins
-member: cn=ldapadmin,dc=grafana,dc=org
-objectClass: groupOfNames
-objectClass: top

+ 0 - 10
docker/blocks/openldap/prepopulate/editor.ldif

@@ -1,10 +0,0 @@
-dn: cn=ldapeditor,dc=grafana,dc=org
-mail: ldapeditor@grafana.com
-userPassword: grafana
-objectClass: person
-objectClass: top
-objectClass: inetOrgPerson
-objectClass: organizationalPerson
-sn: ldapeditor
-cn: ldapeditor
-memberOf: cn=users,dc=grafana,dc=org

+ 0 - 5
docker/blocks/openldap/prepopulate/usersgroup.ldif

@@ -1,5 +0,0 @@
-dn: cn=users,dc=grafana,dc=org
-cn: users
-member: cn=ldapeditor,dc=grafana,dc=org
-objectClass: groupOfNames
-objectClass: top

+ 0 - 9
docker/blocks/openldap/prepopulate/viewer.ldif

@@ -1,9 +0,0 @@
-dn: cn=ldapviewer,dc=grafana,dc=org
-mail: ldapviewer@grafana.com
-userPassword: grafana
-objectClass: person
-objectClass: top
-objectClass: inetOrgPerson
-objectClass: organizationalPerson
-sn: ldapviewer
-cn: ldapviewer

+ 3 - 3
docs/sources/features/datasources/mssql.md

@@ -77,9 +77,9 @@ Macro example | Description
 ------------ | -------------
 *$__time(dateColumn)* | Will be replaced by an expression to rename the column to *time*. For example, *dateColumn as time*
 *$__timeEpoch(dateColumn)* | Will be replaced by an expression to convert a DATETIME column type to unix timestamp and rename it to *time*. <br/>For example, *DATEDIFF(second, '1970-01-01', dateColumn) AS time*
-*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. <br/>For example, *dateColumn >= DATEADD(s, 1494410783, '1970-01-01') AND dateColumn <= DATEADD(s, 1494410783, '1970-01-01')*
-*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *DATEADD(second, 1494410783, '1970-01-01')*
-*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *DATEADD(second, 1494410783, '1970-01-01')*
+*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. <br/>For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'*
+*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'*
+*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'*
 *$__timeGroup(dateColumn,'5m'[, fillvalue])* | Will be replaced by an expression usable in GROUP BY clause. Providing a *fillValue* of *NULL* or *floating value* will automatically fill empty series in timerange with that value. <br/>For example, *CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)\*300*.
 *$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so all null values will be converted to the fill value (all null values would be set to zero using this example).
 *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*

+ 3 - 3
docs/sources/features/datasources/mysql.md

@@ -60,9 +60,9 @@ Macro example | Description
 ------------ | -------------
 *$__time(dateColumn)* | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, *UNIX_TIMESTAMP(dateColumn) as time_sec*
 *$__timeEpoch(dateColumn)* | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, *UNIX_TIMESTAMP(dateColumn) as time_sec*
-*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn > FROM_UNIXTIME(1494410783) AND dateColumn < FROM_UNIXTIME(1494497183)*
-*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *FROM_UNIXTIME(1494410783)*
-*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *FROM_UNIXTIME(1494497183)*
+*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'*
+*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'*
+*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'*
 *$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *cast(cast(UNIX_TIMESTAMP(dateColumn)/(300) as signed)*300 as signed),*
 *$__timeGroup(dateColumn,'5m',0)* | Same as above but with a fill parameter so all null values will be converted to the fill value (all null values would be set to zero using this example).
 *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*

+ 26 - 24
docs/sources/guides/whats-new-in-v5-2.md

@@ -14,14 +14,14 @@ weight = -8
 
 Grafana v5.2 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements.
 
-* [Elasticsearch alerting]({{< relref "#elasticsearch-alerting" >}}) it's finally here!
-* [Cross platform build support]({{< relref "#cross-platform-build-support" >}}) enables native builds of Grafana for many more platforms!
-* [Improved Docker image]({{< relref "#improved-docker-image" >}}) with support for docker secrets
-* [Security]({{< relref "#security" >}}) make your Grafana instance more secure
-* [Prometheus]({{< relref "#prometheus" >}}) with alignment enhancements
-* [InfluxDB]({{< relref "#influxdb" >}}) with support for a new function
-* [Alerting]({{< relref "#alerting" >}}) with alert notification channel type for Discord
-* [Dashboards & Panels]({{< relref "#dashboards-panels" >}}) with save & import enhancements
+- [Elasticsearch alerting]({{< relref "#elasticsearch-alerting" >}}) it's finally here!
+- [Native builds for ARM]({{< relref "#native-builds-for-arm" >}}) native builds of Grafana for many more platforms!
+- [Improved Docker image]({{< relref "#improved-docker-image" >}}) with support for docker secrets
+- [Security]({{< relref "#security" >}}) make your Grafana instance more secure
+- [Prometheus]({{< relref "#prometheus" >}}) with alignment enhancements
+- [InfluxDB]({{< relref "#influxdb" >}}) now supports the `mode` function
+- [Alerting]({{< relref "#alerting" >}}) with alert notification channel type for Discord
+- [Dashboards & Panels]({{< relref "#dashboards-panels" >}}) with save & import enhancements
 
 ## Elasticsearch alerting
 
@@ -32,16 +32,18 @@ the most requested features by our community and now it's finally here. Please t
 
 <div class="clearfix"></div>
 
-## Cross platform build support
+## Native builds for ARM
 
-Grafana v5.2 brings an improved build pipeline with cross platform support. This enables native builds of Grafana for ARMv7 (x32), ARM64 (x64),
-MacOS/Darwin (x64) and Windows (x64) in both stable and nightly builds.
+Grafana v5.2 brings an improved build pipeline with cross-platform support. This enables native builds of Grafana for ARMv7 (x32) and ARM64 (x64).
+We've been longing for native ARM build support for ages. With the help from our amazing community this is now finally available.
+Please try it out and let us know what you think.
 
-We've been longing for native ARM build support for a long time. With the help from our amazing community this is now finally available.
+Another great addition with the improved build pipeline is that binaries for MacOS/Darwin (x64) and Windows (x64) are now automatically built and
+published for both stable and nightly builds.
 
 ## Improved Docker image
 
-The Grafana docker image now includes support for Docker secrets which enables you to supply Grafana with configuration through files. More
+The Grafana docker image adds support for Docker secrets which enables you to supply Grafana with configuration through files. More
 information in the [Installing using Docker documentation](/installation/docker/#reading-secrets-from-files-support-for-docker-secrets).
 
 ## Security
@@ -49,18 +51,18 @@ information in the [Installing using Docker documentation](/installation/docker/
 {{< docs-imagebox img="/img/docs/v52/login_change_password.png" max-width="800px" class="docs-image--right" >}}
 
 Starting from Grafana v5.2, when you login with the administrator account using the default password you'll be presented with a form to change the password.
-By this we hope to encourage users to follow Grafana's best practices and change the default administrator password.
+We hope this encourages users to follow Grafana's best practices and change the default administrator password.
 
 <div class="clearfix"></div>
 
 ## Prometheus
 
 The Prometheus datasource now aligns the start/end of the query sent to Prometheus with the step, which ensures PromQL expressions with *rate*
-functions get consistent results, and thus avoid graphs jumping around on reload.
+functions get consistent results, and thus avoids graphs jumping around on reload.
 
 ## InfluxDB
 
-The InfluxDB datasource now includes support for the *mode* function which allows to return the most frequent value in a list of field values.
+The InfluxDB datasource now includes support for the *mode* function which returns the most frequent value in a list of field values.
 
 ## Alerting
 
@@ -72,9 +74,9 @@ By popular demand Grafana now includes support for an alert notification channel
 
 {{< docs-imagebox img="/img/docs/v52/dashboard_save_modal.png" max-width="800px" class="docs-image--right" >}}
 
-Starting from Grafana v5.2 a modified time range or variable are no longer saved by default. To save a modified
-time range or variable you'll need to actively select that when saving a dashboard, see screenshot.
-This should hopefully make it easier to have sane defaults of time and variables in dashboards and make it more explicit
+Starting from Grafana v5.2, a modified time range or variable are no longer saved by default. To save a modified
+time range or variable, you'll need to actively select that when saving a dashboard, see screenshot.
+This should hopefully make it easier to have sane defaults for time and variables in dashboards and make it more explicit
 when you actually want to overwrite those settings.
 
 <div class="clearfix"></div>
@@ -83,13 +85,13 @@ when you actually want to overwrite those settings.
 
 {{< docs-imagebox img="/img/docs/v52/dashboard_import.png" max-width="800px" class="docs-image--right" >}}
 
-Grafana v5.2 adds support for specifying an existing folder or create a new one when importing a dashboard, a long awaited feature since
-Grafana v5.0 introduced support for dashboard folders and permissions. The import dashboard page have also got some general improvements
+Grafana v5.2 adds support for specifying an existing folder or creating a new one when importing a dashboard - a long-awaited feature since
+Grafana v5.0 introduced support for dashboard folders and permissions. The import dashboard page has also got some general improvements
 and should now make it more clear if a possible import will overwrite an existing dashboard, or not.
 
-This release also adds some improvements for those users only having editor or admin permissions in certain folders. Now the links to
-*Create Dashboard* and *Import Dashboard* is available in side navigation, dashboard search and manage dashboards/folder page for a
-user that has editor role in an organization or edit permission in at least one folder.
+This release also adds some improvements for those users only having editor or admin permissions in certain folders. The links to
+*Create Dashboard* and *Import Dashboard* are now available in the side navigation, in dashboard search and on the manage dashboards/folder page for a
+user that has editor role in an organization or the edit permission in at least one folder.
 
 <div class="clearfix"></div>
 

+ 15 - 15
docs/sources/http_api/admin.md

@@ -36,11 +36,10 @@ HTTP/1.1 200
 Content-Type: application/json
 
 {
-"DEFAULT":
-{
-  "app_mode":"production"},
-  "analytics":
-  {
+  "DEFAULT": {
+    "app_mode":"production"
+  },
+  "analytics": {
     "google_analytics_ua_id":"",
     "reporting_enabled":"false"
   },
@@ -195,15 +194,16 @@ HTTP/1.1 200
 Content-Type: application/json
 
 {
-  "user_count":2,
-  "org_count":1,
-  "dashboard_count":4,
-  "db_snapshot_count":2,
-  "db_tag_count":6,
-  "data_source_count":1,
-  "playlist_count":1,
-  "starred_db_count":2,
-  "grafana_admin_count":2
+  "users":2,
+  "orgs":1,
+  "dashboards":4,
+  "snapshots":2,
+  "tags":6,
+  "datasources":1,
+  "playlists":1,
+  "stars":2,
+  "alerts":2,
+  "activeUsers":1
 }
 ```
 
@@ -340,4 +340,4 @@ HTTP/1.1 200
 Content-Type: application/json
 
 {state: "new state", message: "alerts pause/un paused", "alertsAffected": 100}
-```
+```

+ 8 - 0
docs/sources/http_api/auth.md

@@ -44,6 +44,14 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 
 The `Authorization` header value should be `Bearer <your api key>`.
 
+The API Token can also be passed as a Basic authorization password with the special username `api_key`:
+
+curl example:
+```bash
+?curl http://api_key:eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk@localhost:3000/api/org
+{"id":1,"name":"Main Org."}
+```
+
 # Auth HTTP resources / actions
 
 ## Api Keys

+ 6 - 2
docs/sources/http_api/folder.md

@@ -19,6 +19,10 @@ The unique identifier (uid) of a folder can be used for uniquely identify folder
 
 The uid can have a maximum length of 40 characters.
 
+## A note about the General folder
+
+The General folder (id=0) is special and is not part of the Folder API which means
+that you cannot use this API for retrieving information about the General folder.
 
 ## Get all folders
 
@@ -273,14 +277,14 @@ Status Codes:
 
 ## Get folder by id
 
-`GET /api/folders/:id`
+`GET /api/folders/id/:id`
 
 Will return the folder identified by id.
 
 **Example Request**:
 
 ```http
-GET /api/folders/1 HTTP/1.1
+GET /api/folders/id/1 HTTP/1.1
 Accept: application/json
 Content-Type: application/json
 Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk

+ 119 - 105
docs/sources/http_api/org.md

@@ -12,7 +12,13 @@ parent = "http_api"
 
 # Organisation API
 
-## Get current Organisation
+The Organisation HTTP API is divided in two resources, `/api/org` (current organisation)
+and `/api/orgs` (admin organisations). One big difference between these are that
+the admin of all organisations API only works with basic authentication, see [Admin Organisations API](#admin-organisations-api) for more information.
+
+## Current Organisation API
+
+### Get current Organisation
 
 `GET /api/org/`
 
@@ -37,20 +43,18 @@ Content-Type: application/json
 }
 ```
 
-## Get Organisation by Id
+### Get all users within the current organisation
 
-`GET /api/orgs/:orgId`
+`GET /api/org/users`
 
 **Example Request**:
 
 ```http
-GET /api/orgs/1 HTTP/1.1
+GET /api/org/users HTTP/1.1
 Accept: application/json
 Content-Type: application/json
 Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 ```
-Note: The api will only work when you pass the admin name and password
-to the request http url, like http://admin:admin@localhost:3000/api/orgs/1
 
 **Example Response**:
 
@@ -58,33 +62,33 @@ to the request http url, like http://admin:admin@localhost:3000/api/orgs/1
 HTTP/1.1 200
 Content-Type: application/json
 
-{
-  "id":1,
-  "name":"Main Org.",
-  "address":{
-    "address1":"",
-    "address2":"",
-    "city":"",
-    "zipCode":"",
-    "state":"",
-    "country":""
+[
+  {
+    "orgId":1,
+    "userId":1,
+    "email":"admin@mygraf.com",
+    "login":"admin",
+    "role":"Admin"
   }
-}
+]
 ```
-## Get Organisation by Name
 
-`GET /api/orgs/name/:orgName`
+### Updates the given user
+
+`PATCH /api/org/users/:userId`
 
 **Example Request**:
 
 ```http
-GET /api/orgs/name/Main%20Org%2E HTTP/1.1
+PATCH /api/org/users/1 HTTP/1.1
 Accept: application/json
 Content-Type: application/json
 Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+  "role": "Viewer",
+}
 ```
-Note: The api will only work when you pass the admin name and password
-to the request http url, like http://admin:admin@localhost:3000/api/orgs/name/Main%20Org%2E
 
 **Example Response**:
 
@@ -92,39 +96,21 @@ to the request http url, like http://admin:admin@localhost:3000/api/orgs/name/Ma
 HTTP/1.1 200
 Content-Type: application/json
 
-{
-  "id":1,
-  "name":"Main Org.",
-  "address":{
-    "address1":"",
-    "address2":"",
-    "city":"",
-    "zipCode":"",
-    "state":"",
-    "country":""
-  }
-}
+{"message":"Organization user updated"}
 ```
 
-## Create Organisation
+### Delete user in current organisation
 
-`POST /api/orgs`
+`DELETE /api/org/users/:userId`
 
 **Example Request**:
 
 ```http
-POST /api/orgs HTTP/1.1
+DELETE /api/org/users/1 HTTP/1.1
 Accept: application/json
 Content-Type: application/json
 Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
-
-{
-  "name":"New Org."
-}
 ```
-Note: The api will work in the following two ways
-1) Need to set GF_USERS_ALLOW_ORG_CREATE=true
-2) Set the config users.allow_org_create to true in ini file
 
 **Example Response**:
 
@@ -132,14 +118,10 @@ Note: The api will work in the following two ways
 HTTP/1.1 200
 Content-Type: application/json
 
-{
-  "orgId":"1",
-  "message":"Organization created"
-}
+{"message":"User removed from organization"}
 ```
 
-
-## Update current Organisation
+### Update current Organisation
 
 `PUT /api/org`
 
@@ -165,17 +147,24 @@ Content-Type: application/json
 {"message":"Organization updated"}
 ```
 
-## Get all users within the actual organisation
+### Add a new user to the current organisation
 
-`GET /api/org/users`
+`POST /api/org/users`
+
+Adds a global user to the current organisation.
 
 **Example Request**:
 
 ```http
-GET /api/org/users HTTP/1.1
+POST /api/org/users HTTP/1.1
 Accept: application/json
 Content-Type: application/json
 Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+  "role": "Admin",
+  "loginOrEmail": "admin"
+}
 ```
 
 **Example Response**:
@@ -184,35 +173,29 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 HTTP/1.1 200
 Content-Type: application/json
 
-[
-  {
-    "orgId":1,
-    "userId":1,
-    "email":"admin@mygraf.com",
-    "login":"admin",
-    "role":"Admin"
-  }
-]
+{"message":"User added to organization"}
 ```
 
-## Add a new user to the actual organisation
+## Admin Organisations API
 
-`POST /api/org/users`
+The Admin Organisations HTTP API does not currently work with an API Token. API Tokens are currently
+only linked to an organization and an organization role. They cannot be given the permission of server
+admin, only users can be given that permission. So in order to use these API calls you will have to
+use Basic Auth and the Grafana user must have the Grafana Admin permission (The default admin user
+is called `admin` and has permission to use this API).
+
+### Get Organisation by Id
+
+`GET /api/orgs/:orgId`
 
-Adds a global user to the actual organisation.
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
 
 **Example Request**:
 
 ```http
-POST /api/org/users HTTP/1.1
+GET /api/orgs/1 HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
-
-{
-  "role": "Admin",
-  "loginOrEmail": "admin"
-}
 ```
 
 **Example Response**:
@@ -221,24 +204,31 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 HTTP/1.1 200
 Content-Type: application/json
 
-{"message":"User added to organization"}
+{
+  "id":1,
+  "name":"Main Org.",
+  "address":{
+    "address1":"",
+    "address2":"",
+    "city":"",
+    "zipCode":"",
+    "state":"",
+    "country":""
+  }
+}
 ```
+### Get Organisation by Name
 
-## Updates the given user
+`GET /api/orgs/name/:orgName`
 
-`PATCH /api/org/users/:userId`
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
 
 **Example Request**:
 
 ```http
-PATCH /api/org/users/1 HTTP/1.1
+GET /api/orgs/name/Main%20Org%2E HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
-
-{
-  "role": "Viewer",
-}
 ```
 
 **Example Response**:
@@ -247,21 +237,40 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 HTTP/1.1 200
 Content-Type: application/json
 
-{"message":"Organization user updated"}
+{
+  "id":1,
+  "name":"Main Org.",
+  "address":{
+    "address1":"",
+    "address2":"",
+    "city":"",
+    "zipCode":"",
+    "state":"",
+    "country":""
+  }
+}
 ```
 
-## Delete user in actual organisation
+### Create Organisation
 
-`DELETE /api/org/users/:userId`
+`POST /api/orgs`
+
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
 
 **Example Request**:
 
 ```http
-DELETE /api/org/users/1 HTTP/1.1
+POST /api/orgs HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+  "name":"New Org."
+}
 ```
+Note: The api will work in the following two ways
+1) Need to set GF_USERS_ALLOW_ORG_CREATE=true
+2) Set the config users.allow_org_create to true in ini file
 
 **Example Response**:
 
@@ -269,22 +278,24 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 HTTP/1.1 200
 Content-Type: application/json
 
-{"message":"User removed from organization"}
+{
+  "orgId":"1",
+  "message":"Organization created"
+}
 ```
 
-# Organisations
-
-## Search all Organisations
+### Search all Organisations
 
 `GET /api/orgs`
 
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
+
 **Example Request**:
 
 ```http
 GET /api/orgs HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 ```
 Note: The api will only work when you pass the admin name and password
 to the request http url, like http://admin:admin@localhost:3000/api/orgs
@@ -303,11 +314,12 @@ Content-Type: application/json
 ]
 ```
 
-## Update Organisation
+### Update Organisation
 
 `PUT /api/orgs/:orgId`
 
 Update Organisation, fields *Address 1*, *Address 2*, *City* are not implemented yet.
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
 
 **Example Request**:
 
@@ -315,7 +327,6 @@ Update Organisation, fields *Address 1*, *Address 2*, *City* are not implemented
 PUT /api/orgs/1 HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 
 {
   "name":"Main Org 2."
@@ -331,16 +342,17 @@ Content-Type: application/json
 {"message":"Organization updated"}
 ```
 
-## Delete Organisation
+### Delete Organisation
 
 `DELETE /api/orgs/:orgId`
 
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
+
 **Example Request**:
 
 ```http
 DELETE /api/orgs/1 HTTP/1.1
 Accept: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 ```
 
 **Example Response**:
@@ -352,17 +364,18 @@ Content-Type: application/json
 {"message":"Organization deleted"}
 ```
 
-## Get Users in Organisation
+### Get Users in Organisation
 
 `GET /api/orgs/:orgId/users`
 
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
+
 **Example Request**:
 
 ```http
 GET /api/orgs/1/users HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 ```
 Note: The api will only work when you pass the admin name and password
 to the request http url, like http://admin:admin@localhost:3000/api/orgs/1/users
@@ -384,25 +397,24 @@ Content-Type: application/json
 ]
 ```
 
-## Add User in Organisation
+### Add User in Organisation
 
 `POST /api/orgs/:orgId/users`
 
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
+
 **Example Request**:
 
 ```http
 POST /api/orgs/1/users HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 
 {
   "loginOrEmail":"user",
   "role":"Viewer"
 }
 ```
-Note: The api will only work when you pass the admin name and password
-to the request http url, like http://admin:admin@localhost:3000/api/orgs/1/users
 
 **Example Response**:
 
@@ -413,17 +425,18 @@ Content-Type: application/json
 {"message":"User added to organization"}
 ```
 
-## Update Users in Organisation
+### Update Users in Organisation
 
 `PATCH /api/orgs/:orgId/users/:userId`
 
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
+
 **Example Request**:
 
 ```http
 PATCH /api/orgs/1/users/2 HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 
 {
   "role":"Admin"
@@ -439,17 +452,18 @@ Content-Type: application/json
 {"message":"Organization user updated"}
 ```
 
-## Delete User in Organisation
+### Delete User in Organisation
 
 `DELETE /api/orgs/:orgId/users/:userId`
 
+Only works with Basic Authentication (username and password), see [introduction](#admin-organisations-api).
+
 **Example Request**:
 
 ```http
 DELETE /api/orgs/1/users/2 HTTP/1.1
 Accept: application/json
 Content-Type: application/json
-Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
 ```
 
 **Example Response**:

+ 3 - 3
docs/sources/index.md

@@ -60,9 +60,9 @@ aliases = ["v1.1", "guides/reference/admin"]
         <h4>Provisioning</h4>
         <p>A guide to help you automate your Grafana setup & configuration.</p>
     </a>
-    <a href="{{< relref "guides/whats-new-in-v5.md" >}}" class="nav-cards__item nav-cards__item--guide">
-        <h4>What's new in v5.0</h4>
-        <p>Article on all the new cool features and enhancements in v5.0</p>
+    <a href="{{< relref "guides/whats-new-in-v5-2.md" >}}" class="nav-cards__item nav-cards__item--guide">
+        <h4>What's new in v5.2</h4>
+        <p>Article on all the new cool features and enhancements in v5.2</p>
     </a>
     <a href="{{< relref "tutorials/screencasts.md" >}}" class="nav-cards__item nav-cards__item--guide">
         <h4>Screencasts</h4>

+ 2 - 2
docs/sources/installation/behind_proxy.md

@@ -26,7 +26,7 @@ Otherwise Grafana will not behave correctly. See example below.
 ## Examples
 Here are some example configurations for running Grafana behind a reverse proxy.
 
-### Grafana configuration (ex http://foo.bar.com)
+### Grafana configuration (ex http://foo.bar)
 
 ```bash
 [server]
@@ -47,7 +47,7 @@ server {
 }
 ```
 
-### Examples with **sub path** (ex http://foo.bar.com/grafana)
+### Examples with **sub path** (ex http://foo.bar/grafana)
 
 #### Grafana configuration with sub path
 ```bash

+ 2 - 0
docs/sources/installation/windows.md

@@ -19,6 +19,8 @@ installation.
 
 ## Configure
 
+**Important:** After you've downloaded the zip file and before extracting it, make sure to open properties for that file (right-click Properties) and check the `unblock` checkbox and `Ok`.
+
 The zip file contains a folder with the current Grafana version. Extract
 this folder to anywhere you want Grafana to run from.  Go into the
 `conf` directory and copy `sample.ini` to `custom.ini`. You should edit

+ 20 - 30
docs/sources/reference/scripting.md

@@ -21,42 +21,32 @@ If you open scripted.js you can see how it reads url parameters from ARGS variab
 ## Example
 
 ```javascript
-var rows = 1;
 var seriesName = 'argName';
 
-if(!_.isUndefined(ARGS.rows)) {
-  rows = parseInt(ARGS.rows, 10);
-}
-
 if(!_.isUndefined(ARGS.name)) {
   seriesName = ARGS.name;
 }
 
-for (var i = 0; i < rows; i++) {
-
-  dashboard.rows.push({
-    title: 'Scripted Graph ' + i,
-    height: '300px',
-    panels: [
-      {
-        title: 'Events',
-        type: 'graph',
-        span: 12,
-        fill: 1,
-        linewidth: 2,
-        targets: [
-          {
-            'target': "randomWalk('" + seriesName + "')"
-          },
-          {
-            'target': "randomWalk('random walk2')"
-          }
-        ],
-      }
-    ]
-  });
-
-}
+dashboard.panels.push({
+  title: 'Events',
+  type: 'graph',
+  fill: 1,
+  linewidth: 2,
+  gridPos: {
+    h: 10,
+    w: 24,
+    x: 0,
+    y: 10,
+  },
+  targets: [
+    {
+      'target': "randomWalk('" + seriesName + "')"
+    },
+    {
+      'target': "randomWalk('random walk2')"
+    }
+  ]
+});
 
 return dashboard;
 ```

+ 2 - 1
docs/versions.json

@@ -1,5 +1,6 @@
 [
-  { "version": "v5.1", "path": "/", "archived": false, "current": true },
+  { "version": "v5.2", "path": "/", "archived": false, "current": true },
+  { "version": "v5.1", "path": "/v5.1", "archived": true },
   { "version": "v5.0", "path": "/v5.0", "archived": true },
   { "version": "v4.6", "path": "/v4.6", "archived": true },
   { "version": "v4.5", "path": "/v4.5", "archived": true },

+ 2 - 2
latest.json

@@ -1,4 +1,4 @@
 {
-  "stable": "5.1.3",
-  "testing": "5.1.3"
+  "stable": "5.2.0",
+  "testing": "5.2.0"
 }

+ 3 - 3
package.json

@@ -4,7 +4,7 @@
     "company": "Grafana Labs"
   },
   "name": "grafana",
-  "version": "5.2.0-pre1",
+  "version": "5.3.0-pre1",
   "repository": {
     "type": "git",
     "url": "http://github.com/grafana/grafana.git"
@@ -154,12 +154,12 @@
     "file-saver": "^1.3.3",
     "immutable": "^3.8.2",
     "jquery": "^3.2.1",
-    "lodash": "^4.17.4",
+    "lodash": "^4.17.10",
     "mini-css-extract-plugin": "^0.4.0",
     "mobx": "^3.4.1",
     "mobx-react": "^4.3.5",
     "mobx-state-tree": "^1.3.1",
-    "moment": "^2.18.1",
+    "moment": "^2.22.2",
     "mousetrap": "^1.6.0",
     "mousetrap-global-bind": "^1.1.0",
     "optimize-css-assets-webpack-plugin": "^4.0.2",

+ 1 - 1
pkg/api/alerting_test.go

@@ -135,7 +135,7 @@ func postAlertScenario(desc string, url string, routePattern string, role m.Role
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.OrgId = TestOrgID

+ 3 - 3
pkg/api/annotations_test.go

@@ -223,7 +223,7 @@ func postAnnotationScenario(desc string, url string, routePattern string, role m
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.OrgId = TestOrgID
@@ -246,7 +246,7 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m.
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.OrgId = TestOrgID
@@ -269,7 +269,7 @@ func deleteAnnotationsScenario(desc string, url string, routePattern string, rol
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.OrgId = TestOrgID

+ 136 - 147
pkg/api/api.go

@@ -9,9 +9,7 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 )
 
-// Register adds http routes
 func (hs *HTTPServer) registerRoutes() {
-	macaronR := hs.macaron
 	reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
@@ -21,15 +19,12 @@ func (hs *HTTPServer) registerRoutes() {
 	quota := middleware.Quota
 	bind := binding.Bind
 
-	// automatically set HEAD for every GET
-	macaronR.SetAutoHead(true)
-
 	r := hs.RouteRegister
 
 	// not logged in views
 	r.Get("/", reqSignedIn, Index)
 	r.Get("/logout", Logout)
-	r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), wrap(LoginPost))
+	r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
 	r.Get("/login/:name", quota("session"), OAuthLogin)
 	r.Get("/login", LoginView)
 	r.Get("/invite/:code", Index)
@@ -88,20 +83,20 @@ func (hs *HTTPServer) registerRoutes() {
 
 	// sign up
 	r.Get("/signup", Index)
-	r.Get("/api/user/signup/options", wrap(GetSignUpOptions))
-	r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), wrap(SignUp))
-	r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), wrap(SignUpStep2))
+	r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
+	r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
+	r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
 
 	// invited
-	r.Get("/api/user/invite/:code", wrap(GetInviteInfoByCode))
-	r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), wrap(CompleteInvite))
+	r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
+	r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
 
 	// reset password
 	r.Get("/user/password/send-reset-email", Index)
 	r.Get("/user/password/reset", Index)
 
-	r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), wrap(SendResetPasswordEmail))
-	r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), wrap(ResetPassword))
+	r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), Wrap(SendResetPasswordEmail))
+	r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), Wrap(ResetPassword))
 
 	// dashboard snapshots
 	r.Get("/dashboard/snapshot/*", Index)
@@ -111,8 +106,8 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
 	r.Get("/api/snapshot/shared-options/", GetSharingOptions)
 	r.Get("/api/snapshots/:key", GetDashboardSnapshot)
-	r.Get("/api/snapshots-delete/:deleteKey", wrap(DeleteDashboardSnapshotByDeleteKey))
-	r.Delete("/api/snapshots/:key", reqEditorRole, wrap(DeleteDashboardSnapshot))
+	r.Get("/api/snapshots-delete/:deleteKey", Wrap(DeleteDashboardSnapshotByDeleteKey))
+	r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
 
 	// api renew session based on remember cookie
 	r.Get("/api/login/ping", quota("session"), LoginAPIPing)
@@ -122,138 +117,138 @@ func (hs *HTTPServer) registerRoutes() {
 
 		// user (signed in)
 		apiRoute.Group("/user", func(userRoute routing.RouteRegister) {
-			userRoute.Get("/", wrap(GetSignedInUser))
-			userRoute.Put("/", bind(m.UpdateUserCommand{}), wrap(UpdateSignedInUser))
-			userRoute.Post("/using/:id", wrap(UserSetUsingOrg))
-			userRoute.Get("/orgs", wrap(GetSignedInUserOrgList))
+			userRoute.Get("/", Wrap(GetSignedInUser))
+			userRoute.Put("/", bind(m.UpdateUserCommand{}), Wrap(UpdateSignedInUser))
+			userRoute.Post("/using/:id", Wrap(UserSetUsingOrg))
+			userRoute.Get("/orgs", Wrap(GetSignedInUserOrgList))
 
-			userRoute.Post("/stars/dashboard/:id", wrap(StarDashboard))
-			userRoute.Delete("/stars/dashboard/:id", wrap(UnstarDashboard))
+			userRoute.Post("/stars/dashboard/:id", Wrap(StarDashboard))
+			userRoute.Delete("/stars/dashboard/:id", Wrap(UnstarDashboard))
 
-			userRoute.Put("/password", bind(m.ChangeUserPasswordCommand{}), wrap(ChangeUserPassword))
-			userRoute.Get("/quotas", wrap(GetUserQuotas))
-			userRoute.Put("/helpflags/:id", wrap(SetHelpFlag))
+			userRoute.Put("/password", bind(m.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword))
+			userRoute.Get("/quotas", Wrap(GetUserQuotas))
+			userRoute.Put("/helpflags/:id", Wrap(SetHelpFlag))
 			// For dev purpose
-			userRoute.Get("/helpflags/clear", wrap(ClearHelpFlags))
+			userRoute.Get("/helpflags/clear", Wrap(ClearHelpFlags))
 
-			userRoute.Get("/preferences", wrap(GetUserPreferences))
-			userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), wrap(UpdateUserPreferences))
+			userRoute.Get("/preferences", Wrap(GetUserPreferences))
+			userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences))
 		})
 
 		// users (admin permission required)
 		apiRoute.Group("/users", func(usersRoute routing.RouteRegister) {
-			usersRoute.Get("/", wrap(SearchUsers))
-			usersRoute.Get("/search", wrap(SearchUsersWithPaging))
-			usersRoute.Get("/:id", wrap(GetUserByID))
-			usersRoute.Get("/:id/orgs", wrap(GetUserOrgList))
+			usersRoute.Get("/", Wrap(SearchUsers))
+			usersRoute.Get("/search", Wrap(SearchUsersWithPaging))
+			usersRoute.Get("/:id", Wrap(GetUserByID))
+			usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList))
 			// query parameters /users/lookup?loginOrEmail=admin@example.com
-			usersRoute.Get("/lookup", wrap(GetUserByLoginOrEmail))
-			usersRoute.Put("/:id", bind(m.UpdateUserCommand{}), wrap(UpdateUser))
-			usersRoute.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg))
+			usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail))
+			usersRoute.Put("/:id", bind(m.UpdateUserCommand{}), Wrap(UpdateUser))
+			usersRoute.Post("/:id/using/:orgId", Wrap(UpdateUserActiveOrg))
 		}, reqGrafanaAdmin)
 
 		// team (admin permission required)
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
-			teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam))
-			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
-			teamsRoute.Delete("/:teamId", wrap(DeleteTeamByID))
-			teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
-			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
-			teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
+			teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam))
+			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam))
+			teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID))
+			teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
+			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember))
+			teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember))
 		}, reqOrgAdmin)
 
 		// team without requirement of user to be org admin
 		apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
-			teamsRoute.Get("/:teamId", wrap(GetTeamByID))
-			teamsRoute.Get("/search", wrap(SearchTeams))
+			teamsRoute.Get("/:teamId", Wrap(GetTeamByID))
+			teamsRoute.Get("/search", Wrap(SearchTeams))
 		})
 
 		// org information available to all users.
 		apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
-			orgRoute.Get("/", wrap(GetOrgCurrent))
-			orgRoute.Get("/quotas", wrap(GetOrgQuotas))
+			orgRoute.Get("/", Wrap(GetOrgCurrent))
+			orgRoute.Get("/quotas", Wrap(GetOrgQuotas))
 		})
 
 		// current org
 		apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
-			orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrgCurrent))
-			orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddressCurrent))
-			orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg))
-			orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg))
-			orgRoute.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg))
+			orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), Wrap(UpdateOrgCurrent))
+			orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddressCurrent))
+			orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg))
+			orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg))
+			orgRoute.Delete("/users/:userId", Wrap(RemoveOrgUserForCurrentOrg))
 
 			// invites
-			orgRoute.Get("/invites", wrap(GetPendingOrgInvites))
-			orgRoute.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), wrap(AddOrgInvite))
-			orgRoute.Patch("/invites/:code/revoke", wrap(RevokeInvite))
+			orgRoute.Get("/invites", Wrap(GetPendingOrgInvites))
+			orgRoute.Post("/invites", quota("user"), bind(dtos.AddInviteForm{}), Wrap(AddOrgInvite))
+			orgRoute.Patch("/invites/:code/revoke", Wrap(RevokeInvite))
 
 			// prefs
-			orgRoute.Get("/preferences", wrap(GetOrgPreferences))
-			orgRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), wrap(UpdateOrgPreferences))
+			orgRoute.Get("/preferences", Wrap(GetOrgPreferences))
+			orgRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateOrgPreferences))
 		}, reqOrgAdmin)
 
 		// current org without requirement of user to be org admin
 		apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
-			orgRoute.Get("/users", wrap(GetOrgUsersForCurrentOrg))
+			orgRoute.Get("/users", Wrap(GetOrgUsersForCurrentOrg))
 		})
 
 		// create new org
-		apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), wrap(CreateOrg))
+		apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), Wrap(CreateOrg))
 
 		// search all orgs
-		apiRoute.Get("/orgs", reqGrafanaAdmin, wrap(SearchOrgs))
+		apiRoute.Get("/orgs", reqGrafanaAdmin, Wrap(SearchOrgs))
 
 		// orgs (admin routes)
 		apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) {
-			orgsRoute.Get("/", wrap(GetOrgByID))
-			orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), wrap(UpdateOrg))
-			orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), wrap(UpdateOrgAddress))
-			orgsRoute.Delete("/", wrap(DeleteOrgByID))
-			orgsRoute.Get("/users", wrap(GetOrgUsers))
-			orgsRoute.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUser))
-			orgsRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUser))
-			orgsRoute.Delete("/users/:userId", wrap(RemoveOrgUser))
-			orgsRoute.Get("/quotas", wrap(GetOrgQuotas))
-			orgsRoute.Put("/quotas/:target", bind(m.UpdateOrgQuotaCmd{}), wrap(UpdateOrgQuota))
+			orgsRoute.Get("/", Wrap(GetOrgByID))
+			orgsRoute.Put("/", bind(dtos.UpdateOrgForm{}), Wrap(UpdateOrg))
+			orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddress))
+			orgsRoute.Delete("/", Wrap(DeleteOrgByID))
+			orgsRoute.Get("/users", Wrap(GetOrgUsers))
+			orgsRoute.Post("/users", bind(m.AddOrgUserCommand{}), Wrap(AddOrgUser))
+			orgsRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser))
+			orgsRoute.Delete("/users/:userId", Wrap(RemoveOrgUser))
+			orgsRoute.Get("/quotas", Wrap(GetOrgQuotas))
+			orgsRoute.Put("/quotas/:target", bind(m.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota))
 		}, reqGrafanaAdmin)
 
 		// orgs (admin routes)
 		apiRoute.Group("/orgs/name/:name", func(orgsRoute routing.RouteRegister) {
-			orgsRoute.Get("/", wrap(GetOrgByName))
+			orgsRoute.Get("/", Wrap(GetOrgByName))
 		}, reqGrafanaAdmin)
 
 		// auth api keys
 		apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) {
-			keysRoute.Get("/", wrap(GetAPIKeys))
-			keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), wrap(AddAPIKey))
-			keysRoute.Delete("/:id", wrap(DeleteAPIKey))
+			keysRoute.Get("/", Wrap(GetAPIKeys))
+			keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), Wrap(AddAPIKey))
+			keysRoute.Delete("/:id", Wrap(DeleteAPIKey))
 		}, reqOrgAdmin)
 
 		// Preferences
 		apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) {
-			prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), wrap(SetHomeDashboard))
+			prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), Wrap(SetHomeDashboard))
 		})
 
 		// Data sources
 		apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) {
-			datasourceRoute.Get("/", wrap(GetDataSources))
-			datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), wrap(AddDataSource))
-			datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), wrap(UpdateDataSource))
-			datasourceRoute.Delete("/:id", wrap(DeleteDataSourceByID))
-			datasourceRoute.Delete("/name/:name", wrap(DeleteDataSourceByName))
-			datasourceRoute.Get("/:id", wrap(GetDataSourceByID))
-			datasourceRoute.Get("/name/:name", wrap(GetDataSourceByName))
+			datasourceRoute.Get("/", Wrap(GetDataSources))
+			datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource))
+			datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource))
+			datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceByID))
+			datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName))
+			datasourceRoute.Get("/:id", Wrap(GetDataSourceByID))
+			datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName))
 		}, reqOrgAdmin)
 
-		apiRoute.Get("/datasources/id/:name", wrap(GetDataSourceIDByName), reqSignedIn)
+		apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIDByName), reqSignedIn)
 
-		apiRoute.Get("/plugins", wrap(GetPluginList))
-		apiRoute.Get("/plugins/:pluginId/settings", wrap(GetPluginSettingByID))
-		apiRoute.Get("/plugins/:pluginId/markdown/:name", wrap(GetPluginMarkdown))
+		apiRoute.Get("/plugins", Wrap(GetPluginList))
+		apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID))
+		apiRoute.Get("/plugins/:pluginId/markdown/:name", Wrap(GetPluginMarkdown))
 
 		apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
-			pluginRoute.Get("/:pluginId/dashboards/", wrap(GetPluginDashboards))
-			pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), wrap(UpdatePluginSetting))
+			pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards))
+			pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
 		}, reqOrgAdmin)
 
 		apiRoute.Get("/frontend/settings/", GetFrontendSettings)
@@ -262,106 +257,106 @@ func (hs *HTTPServer) registerRoutes() {
 
 		// Folders
 		apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
-			folderRoute.Get("/", wrap(GetFolders))
-			folderRoute.Get("/id/:id", wrap(GetFolderByID))
-			folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder))
+			folderRoute.Get("/", Wrap(GetFolders))
+			folderRoute.Get("/id/:id", Wrap(GetFolderByID))
+			folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder))
 
 			folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
-				folderUidRoute.Get("/", wrap(GetFolderByUID))
-				folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder))
-				folderUidRoute.Delete("/", wrap(DeleteFolder))
+				folderUidRoute.Get("/", Wrap(GetFolderByUID))
+				folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), Wrap(UpdateFolder))
+				folderUidRoute.Delete("/", Wrap(DeleteFolder))
 
 				folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) {
-					folderPermissionRoute.Get("/", wrap(GetFolderPermissionList))
-					folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions))
+					folderPermissionRoute.Get("/", Wrap(GetFolderPermissionList))
+					folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), Wrap(UpdateFolderPermissions))
 				})
 			})
 		})
 
 		// Dashboard
 		apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) {
-			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
-			dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUID))
+			dashboardRoute.Get("/uid/:uid", Wrap(GetDashboard))
+			dashboardRoute.Delete("/uid/:uid", Wrap(DeleteDashboardByUID))
 
-			dashboardRoute.Get("/db/:slug", wrap(GetDashboard))
-			dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard))
+			dashboardRoute.Get("/db/:slug", Wrap(GetDashboard))
+			dashboardRoute.Delete("/db/:slug", Wrap(DeleteDashboard))
 
-			dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), wrap(CalculateDashboardDiff))
+			dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), Wrap(CalculateDashboardDiff))
 
-			dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
-			dashboardRoute.Get("/home", wrap(GetHomeDashboard))
+			dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), Wrap(PostDashboard))
+			dashboardRoute.Get("/home", Wrap(GetHomeDashboard))
 			dashboardRoute.Get("/tags", GetDashboardTags)
-			dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), wrap(ImportDashboard))
+			dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), Wrap(ImportDashboard))
 
 			dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) {
-				dashIdRoute.Get("/versions", wrap(GetDashboardVersions))
-				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
-				dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
+				dashIdRoute.Get("/versions", Wrap(GetDashboardVersions))
+				dashIdRoute.Get("/versions/:id", Wrap(GetDashboardVersion))
+				dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), Wrap(RestoreDashboardVersion))
 
 				dashIdRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) {
-					dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList))
-					dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions))
+					dashboardPermissionRoute.Get("/", Wrap(GetDashboardPermissionList))
+					dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), Wrap(UpdateDashboardPermissions))
 				})
 			})
 		})
 
 		// Dashboard snapshots
 		apiRoute.Group("/dashboard/snapshots", func(dashboardRoute routing.RouteRegister) {
-			dashboardRoute.Get("/", wrap(SearchDashboardSnapshots))
+			dashboardRoute.Get("/", Wrap(SearchDashboardSnapshots))
 		})
 
 		// Playlist
 		apiRoute.Group("/playlists", func(playlistRoute routing.RouteRegister) {
-			playlistRoute.Get("/", wrap(SearchPlaylists))
-			playlistRoute.Get("/:id", ValidateOrgPlaylist, wrap(GetPlaylist))
-			playlistRoute.Get("/:id/items", ValidateOrgPlaylist, wrap(GetPlaylistItems))
-			playlistRoute.Get("/:id/dashboards", ValidateOrgPlaylist, wrap(GetPlaylistDashboards))
-			playlistRoute.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, wrap(DeletePlaylist))
-			playlistRoute.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistCommand{}), ValidateOrgPlaylist, wrap(UpdatePlaylist))
-			playlistRoute.Post("/", reqEditorRole, bind(m.CreatePlaylistCommand{}), wrap(CreatePlaylist))
+			playlistRoute.Get("/", Wrap(SearchPlaylists))
+			playlistRoute.Get("/:id", ValidateOrgPlaylist, Wrap(GetPlaylist))
+			playlistRoute.Get("/:id/items", ValidateOrgPlaylist, Wrap(GetPlaylistItems))
+			playlistRoute.Get("/:id/dashboards", ValidateOrgPlaylist, Wrap(GetPlaylistDashboards))
+			playlistRoute.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, Wrap(DeletePlaylist))
+			playlistRoute.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist))
+			playlistRoute.Post("/", reqEditorRole, bind(m.CreatePlaylistCommand{}), Wrap(CreatePlaylist))
 		})
 
 		// Search
 		apiRoute.Get("/search/", Search)
 
 		// metrics
-		apiRoute.Post("/tsdb/query", bind(dtos.MetricRequest{}), wrap(QueryMetrics))
-		apiRoute.Get("/tsdb/testdata/scenarios", wrap(GetTestDataScenarios))
-		apiRoute.Get("/tsdb/testdata/gensql", reqGrafanaAdmin, wrap(GenerateSQLTestData))
-		apiRoute.Get("/tsdb/testdata/random-walk", wrap(GetTestDataRandomWalk))
+		apiRoute.Post("/tsdb/query", bind(dtos.MetricRequest{}), Wrap(QueryMetrics))
+		apiRoute.Get("/tsdb/testdata/scenarios", Wrap(GetTestDataScenarios))
+		apiRoute.Get("/tsdb/testdata/gensql", reqGrafanaAdmin, Wrap(GenerateSQLTestData))
+		apiRoute.Get("/tsdb/testdata/random-walk", Wrap(GetTestDataRandomWalk))
 
 		apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) {
-			alertsRoute.Post("/test", bind(dtos.AlertTestCommand{}), wrap(AlertTest))
-			alertsRoute.Post("/:alertId/pause", reqEditorRole, bind(dtos.PauseAlertCommand{}), wrap(PauseAlert))
-			alertsRoute.Get("/:alertId", ValidateOrgAlert, wrap(GetAlert))
-			alertsRoute.Get("/", wrap(GetAlerts))
-			alertsRoute.Get("/states-for-dashboard", wrap(GetAlertStatesForDashboard))
+			alertsRoute.Post("/test", bind(dtos.AlertTestCommand{}), Wrap(AlertTest))
+			alertsRoute.Post("/:alertId/pause", reqEditorRole, bind(dtos.PauseAlertCommand{}), Wrap(PauseAlert))
+			alertsRoute.Get("/:alertId", ValidateOrgAlert, Wrap(GetAlert))
+			alertsRoute.Get("/", Wrap(GetAlerts))
+			alertsRoute.Get("/states-for-dashboard", Wrap(GetAlertStatesForDashboard))
 		})
 
-		apiRoute.Get("/alert-notifications", wrap(GetAlertNotifications))
-		apiRoute.Get("/alert-notifiers", wrap(GetAlertNotifiers))
+		apiRoute.Get("/alert-notifications", Wrap(GetAlertNotifications))
+		apiRoute.Get("/alert-notifiers", Wrap(GetAlertNotifiers))
 
 		apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) {
-			alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), wrap(NotificationTest))
-			alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), wrap(CreateAlertNotification))
-			alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), wrap(UpdateAlertNotification))
-			alertNotifications.Get("/:notificationId", wrap(GetAlertNotificationByID))
-			alertNotifications.Delete("/:notificationId", wrap(DeleteAlertNotification))
+			alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), Wrap(NotificationTest))
+			alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification))
+			alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification))
+			alertNotifications.Get("/:notificationId", Wrap(GetAlertNotificationByID))
+			alertNotifications.Delete("/:notificationId", Wrap(DeleteAlertNotification))
 		}, reqEditorRole)
 
-		apiRoute.Get("/annotations", wrap(GetAnnotations))
-		apiRoute.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), wrap(DeleteAnnotations))
+		apiRoute.Get("/annotations", Wrap(GetAnnotations))
+		apiRoute.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), Wrap(DeleteAnnotations))
 
 		apiRoute.Group("/annotations", func(annotationsRoute routing.RouteRegister) {
-			annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation))
-			annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationByID))
-			annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation))
-			annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion))
-			annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation))
+			annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation))
+			annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID))
+			annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation))
+			annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion))
+			annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation))
 		})
 
 		// error test
-		r.Get("/metrics/error", wrap(GenerateError))
+		r.Get("/metrics/error", Wrap(GenerateError))
 
 	}, reqSignedIn)
 
@@ -372,10 +367,10 @@ func (hs *HTTPServer) registerRoutes() {
 		adminRoute.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword)
 		adminRoute.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions)
 		adminRoute.Delete("/users/:id", AdminDeleteUser)
-		adminRoute.Get("/users/:id/quotas", wrap(GetUserQuotas))
-		adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota))
+		adminRoute.Get("/users/:id/quotas", Wrap(GetUserQuotas))
+		adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
 		adminRoute.Get("/stats", AdminGetStats)
-		adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), wrap(PauseAllAlerts))
+		adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts))
 	}, reqGrafanaAdmin)
 
 	// rendering
@@ -393,10 +388,4 @@ func (hs *HTTPServer) registerRoutes() {
 
 	// streams
 	//r.Post("/api/streams/push", reqSignedIn, bind(dtos.StreamMessage{}), liveConn.PushToStream)
-
-	r.Register(macaronR)
-
-	InitAppPluginRoutes(macaronR)
-
-	macaronR.NotFound(NotFoundHandler)
 }

+ 1 - 1
pkg/api/app_routes.go

@@ -18,7 +18,7 @@ import (
 
 var pluginProxyTransport *http.Transport
 
-func InitAppPluginRoutes(r *macaron.Macaron) {
+func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) {
 	pluginProxyTransport = &http.Transport{
 		TLSClientConfig: &tls.Config{
 			InsecureSkipVerify: setting.PluginAppsSkipVerifyTLS,

+ 1 - 1
pkg/api/common.go

@@ -30,7 +30,7 @@ type NormalResponse struct {
 	err        error
 }
 
-func wrap(action interface{}) macaron.Handler {
+func Wrap(action interface{}) macaron.Handler {
 
 	return func(c *m.ReqContext) {
 		var res Response

+ 2 - 2
pkg/api/common_test.go

@@ -23,7 +23,7 @@ func loggedInUserScenarioWithRole(desc string, method string, url string, routeP
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.OrgId = TestOrgID
@@ -51,7 +51,7 @@ func anonymousUserScenario(desc string, method string, url string, routePattern
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			if sc.handlerFunc != nil {
 				return sc.handlerFunc(sc.context)

+ 1 - 1
pkg/api/dashboard_permission_test.go

@@ -194,7 +194,7 @@ func updateDashboardPermissionScenario(desc string, url string, routePattern str
 
 		sc := setupScenarioContext(url)
 
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.OrgId = TestOrgID
 			sc.context.UserId = TestUserID

+ 2 - 2
pkg/api/dashboard_test.go

@@ -882,7 +882,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.SignedInUser = &m.SignedInUser{OrgId: cmd.OrgId, UserId: cmd.UserId}
 
@@ -907,7 +907,7 @@ func postDiffScenario(desc string, url string, routePattern string, cmd dtos.Cal
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.SignedInUser = &m.SignedInUser{
 				OrgId:  TestOrgID,

+ 1 - 0
pkg/api/dtos/index.go

@@ -13,6 +13,7 @@ type IndexViewData struct {
 	Theme                   string
 	NewGrafanaVersionExists bool
 	NewGrafanaVersion       string
+	AppName                 string
 }
 
 type PluginCss struct {

+ 1 - 1
pkg/api/folder_permission_test.go

@@ -226,7 +226,7 @@ func updateFolderPermissionScenario(desc string, url string, routePattern string
 
 		sc := setupScenarioContext(url)
 
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.OrgId = TestOrgID
 			sc.context.UserId = TestUserID

+ 2 - 2
pkg/api/folder_test.go

@@ -152,7 +152,7 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
 
@@ -181,7 +181,7 @@ func updateFolderScenario(desc string, url string, routePattern string, mock *fa
 		defer bus.ClearBusHandlers()
 
 		sc := setupScenarioContext(url)
-		sc.defaultHandler = wrap(func(c *m.ReqContext) Response {
+		sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
 			sc.context = c
 			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
 

+ 1 - 0
pkg/api/frontendsettings.go

@@ -153,6 +153,7 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
 			"latestVersion": plugins.GrafanaLatestVersion,
 			"hasUpdate":     plugins.GrafanaHasUpdate,
 			"env":           setting.Env,
+			"isEnterprise":  setting.IsEnterprise,
 		},
 	}
 

+ 31 - 7
pkg/api/http_server.go

@@ -33,7 +33,11 @@ import (
 )
 
 func init() {
-	registry.RegisterService(&HTTPServer{})
+	registry.Register(&registry.Descriptor{
+		Name:         "HTTPServer",
+		Instance:     &HTTPServer{},
+		InitPriority: registry.High,
+	})
 }
 
 type HTTPServer struct {
@@ -54,6 +58,10 @@ func (hs *HTTPServer) Init() error {
 	hs.log = log.New("http.server")
 	hs.cache = gocache.New(5*time.Minute, 10*time.Minute)
 
+	hs.streamManager = live.NewStreamManager()
+	hs.macaron = hs.newMacaron()
+	hs.registerRoutes()
+
 	return nil
 }
 
@@ -61,10 +69,8 @@ func (hs *HTTPServer) Run(ctx context.Context) error {
 	var err error
 
 	hs.context = ctx
-	hs.streamManager = live.NewStreamManager()
-	hs.macaron = hs.newMacaron()
-	hs.registerRoutes()
 
+	hs.applyRoutes()
 	hs.streamManager.Run(ctx)
 
 	listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort)
@@ -164,6 +170,26 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron {
 	macaron.Env = setting.Env
 	m := macaron.New()
 
+	// automatically set HEAD for every GET
+	m.SetAutoHead(true)
+
+	return m
+}
+
+func (hs *HTTPServer) applyRoutes() {
+	// start with middlewares & static routes
+	hs.addMiddlewaresAndStaticRoutes()
+	// then add view routes & api routes
+	hs.RouteRegister.Register(hs.macaron)
+	// then custom app proxy routes
+	hs.initAppPluginRoutes(hs.macaron)
+	// lastly not found route
+	hs.macaron.NotFound(NotFoundHandler)
+}
+
+func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
+	m := hs.macaron
+
 	m.Use(middleware.Logger())
 
 	if setting.EnableGzip {
@@ -175,7 +201,7 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron {
 	for _, route := range plugins.StaticRoutes {
 		pluginRoute := path.Join("/public/plugins/", route.PluginId)
 		hs.log.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory)
-		hs.mapStatic(m, route.Directory, "", pluginRoute)
+		hs.mapStatic(hs.macaron, route.Directory, "", pluginRoute)
 	}
 
 	hs.mapStatic(m, setting.StaticRootPath, "build", "public/build")
@@ -204,8 +230,6 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron {
 	}
 
 	m.Use(middleware.AddDefaultResponseHeaders())
-
-	return m
 }
 
 func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) {

+ 1 - 0
pkg/api/index.go

@@ -76,6 +76,7 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 		BuildCommit:             setting.BuildCommit,
 		NewGrafanaVersion:       plugins.GrafanaLatestVersion,
 		NewGrafanaVersionExists: plugins.GrafanaHasUpdate,
+		AppName:                 setting.ApplicationName,
 	}
 
 	if setting.DisableGravatar {

+ 4 - 4
pkg/cmd/grafana-server/main.go

@@ -18,7 +18,7 @@ import (
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/setting"
 
-	_ "github.com/grafana/grafana/pkg/extensions"
+	extensions "github.com/grafana/grafana/pkg/extensions"
 	_ "github.com/grafana/grafana/pkg/services/alerting/conditions"
 	_ "github.com/grafana/grafana/pkg/services/alerting/notifiers"
 	_ "github.com/grafana/grafana/pkg/tsdb/cloudwatch"
@@ -35,7 +35,6 @@ import (
 var version = "5.0.0"
 var commit = "NA"
 var buildstamp string
-var enterprise string
 
 var configFile = flag.String("config", "", "path to config file")
 var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory")
@@ -78,7 +77,7 @@ func main() {
 	setting.BuildVersion = version
 	setting.BuildCommit = commit
 	setting.BuildStamp = buildstampInt64
-	setting.Enterprise, _ = strconv.ParseBool(enterprise)
+	setting.IsEnterprise = extensions.IsEnterprise
 
 	metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
 
@@ -88,10 +87,11 @@ func main() {
 
 	err := server.Run()
 
+	code := server.Exit(err)
 	trace.Stop()
 	log.Close()
 
-	server.Exit(err)
+	os.Exit(code)
 }
 
 func listenToSystemSignals(server *GrafanaServerImpl) {

+ 2 - 2
pkg/cmd/grafana-server/server.go

@@ -175,7 +175,7 @@ func (g *GrafanaServerImpl) Shutdown(reason string) {
 	g.childRoutines.Wait()
 }
 
-func (g *GrafanaServerImpl) Exit(reason error) {
+func (g *GrafanaServerImpl) Exit(reason error) int {
 	// default exit code is 1
 	code := 1
 
@@ -185,7 +185,7 @@ func (g *GrafanaServerImpl) Exit(reason error) {
 	}
 
 	g.log.Error("Server shutdown", "reason", reason)
-	os.Exit(code)
+	return code
 }
 
 func (g *GrafanaServerImpl) writePIDFile() {

+ 1 - 1
pkg/extensions/main.go

@@ -1,3 +1,3 @@
 package extensions
 
-import _ "github.com/pkg/errors"
+var IsEnterprise bool = false

+ 17 - 1
pkg/login/ext_user.go

@@ -21,6 +21,7 @@ func UpsertUser(cmd *m.UpsertUserCommand) error {
 		Email:      extUser.Email,
 		Login:      extUser.Login,
 	}
+
 	err := bus.Dispatch(userQuery)
 	if err != m.ErrUserNotFound && err != nil {
 		return err
@@ -66,7 +67,21 @@ func UpsertUser(cmd *m.UpsertUserCommand) error {
 		}
 	}
 
-	return syncOrgRoles(cmd.Result, extUser)
+	err = syncOrgRoles(cmd.Result, extUser)
+	if err != nil {
+		return err
+	}
+
+	err = bus.Dispatch(&m.SyncTeamsCommand{
+		User:         cmd.Result,
+		ExternalUser: extUser,
+	})
+
+	if err == bus.ErrHandlerNotFound {
+		return nil
+	}
+
+	return err
 }
 
 func createUser(extUser *m.ExternalUserInfo) (*m.User, error) {
@@ -76,6 +91,7 @@ func createUser(extUser *m.ExternalUserInfo) (*m.User, error) {
 		Name:         extUser.Name,
 		SkipOrgSetup: len(extUser.OrgRoles) > 0,
 	}
+
 	if err := bus.Dispatch(cmd); err != nil {
 		return nil, err
 	}

+ 2 - 0
pkg/login/ldap.go

@@ -163,6 +163,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
 		Name:       fmt.Sprintf("%s %s", ldapUser.FirstName, ldapUser.LastName),
 		Login:      ldapUser.Username,
 		Email:      ldapUser.Email,
+		Groups:     ldapUser.MemberOf,
 		OrgRoles:   map[int64]m.RoleType{},
 	}
 
@@ -194,6 +195,7 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
 		ExternalUser:  extUser,
 		SignupAllowed: setting.LdapAllowSignup,
 	}
+
 	err := bus.Dispatch(userQuery)
 	if err != nil {
 		return nil, err

+ 13 - 3
pkg/login/ldap_test.go

@@ -1,6 +1,7 @@
 package login
 
 import (
+	"context"
 	"crypto/tls"
 	"testing"
 
@@ -14,6 +15,14 @@ func TestLdapAuther(t *testing.T) {
 
 	Convey("When translating ldap user to grafana user", t, func() {
 
+		var user1 = &m.User{}
+
+		bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.UpsertUserCommand) error {
+			cmd.Result = user1
+			cmd.Result.Login = "torkelo"
+			return nil
+		})
+
 		Convey("Given no ldap group map match", func() {
 			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
 				LdapGroups: []*LdapGroupToOrgRole{{}},
@@ -23,8 +32,6 @@ func TestLdapAuther(t *testing.T) {
 			So(err, ShouldEqual, ErrInvalidCredentials)
 		})
 
-		var user1 = &m.User{}
-
 		ldapAutherScenario("Given wildcard group match", func(sc *scenarioContext) {
 			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
 				LdapGroups: []*LdapGroupToOrgRole{
@@ -96,7 +103,6 @@ func TestLdapAuther(t *testing.T) {
 	})
 
 	Convey("When syncing ldap groups to grafana org roles", t, func() {
-
 		ldapAutherScenario("given no current user orgs", func(sc *scenarioContext) {
 			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
 				LdapGroups: []*LdapGroupToOrgRole{
@@ -322,6 +328,10 @@ func ldapAutherScenario(desc string, fn scenarioFunc) {
 
 		bus.AddHandler("test", UpsertUser)
 
+		bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.SyncTeamsCommand) error {
+			return nil
+		})
+
 		bus.AddHandler("test", func(cmd *m.GetUserByAuthInfoQuery) error {
 			sc.getUserByAuthInfoQuery = cmd
 			sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login}

+ 9 - 0
pkg/metrics/metrics.go

@@ -334,6 +334,14 @@ func updateTotalStats() {
 
 var usageStatsURL = "https://stats.grafana.org/grafana-usage-report"
 
+func getEdition() string {
+	if setting.IsEnterprise {
+		return "enterprise"
+	} else {
+		return "oss"
+	}
+}
+
 func sendUsageStats() {
 	if !setting.ReportingEnabled {
 		return
@@ -349,6 +357,7 @@ func sendUsageStats() {
 		"metrics": metrics,
 		"os":      runtime.GOOS,
 		"arch":    runtime.GOARCH,
+		"edition": getEdition(),
 	}
 
 	statsQuery := models.GetSystemStatsQuery{}

+ 6 - 0
pkg/middleware/auth.go

@@ -9,6 +9,7 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
 )
 
 type AuthOptions struct {
@@ -34,6 +35,11 @@ func getApiKey(c *m.ReqContext) string {
 		return key
 	}
 
+	username, password, err := util.DecodeBasicAuthHeader(header)
+	if err == nil && username == "api_key" {
+		return password
+	}
+
 	return ""
 }
 

+ 8 - 12
pkg/middleware/auth_proxy.go

@@ -2,6 +2,7 @@ package middleware
 
 import (
 	"fmt"
+	"net"
 	"net/mail"
 	"reflect"
 	"strings"
@@ -28,7 +29,7 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 	}
 
 	// if auth proxy ip(s) defined, check if request comes from one of those
-	if err := checkAuthenticationProxy(ctx.RemoteAddr(), proxyHeaderValue); err != nil {
+	if err := checkAuthenticationProxy(ctx.Req.RemoteAddr, proxyHeaderValue); err != nil {
 		ctx.Handle(407, "Proxy authentication required", err)
 		return true
 	}
@@ -196,23 +197,18 @@ func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error
 		return nil
 	}
 
-	// Multiple ip addresses? Right-most IP address is the IP address of the most recent proxy
-	if strings.Contains(remoteAddr, ",") {
-		sourceIPs := strings.Split(remoteAddr, ",")
-		remoteAddr = strings.TrimSpace(sourceIPs[len(sourceIPs)-1])
-	}
-
-	remoteAddr = strings.TrimPrefix(remoteAddr, "[")
-	remoteAddr = strings.TrimSuffix(remoteAddr, "]")
-
 	proxies := strings.Split(setting.AuthProxyWhitelist, ",")
+	sourceIP, _, err := net.SplitHostPort(remoteAddr)
+	if err != nil {
+		return err
+	}
 
 	// Compare allowed IP addresses to actual address
 	for _, proxyIP := range proxies {
-		if remoteAddr == strings.TrimSpace(proxyIP) {
+		if sourceIP == strings.TrimSpace(proxyIP) {
 			return nil
 		}
 	}
 
-	return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, remoteAddr)
+	return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP)
 }

+ 24 - 57
pkg/middleware/middleware_test.go

@@ -82,7 +82,7 @@ func TestMiddlewareContext(t *testing.T) {
 
 			setting.BasicAuthEnabled = true
 			authHeader := util.GetBasicAuthHeader("myUser", "myPass")
-			sc.fakeReq("GET", "/").withAuthoriziationHeader(authHeader).exec()
+			sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
 
 			Convey("Should init middleware context with user", func() {
 				So(sc.context.IsSignedIn, ShouldEqual, true)
@@ -128,6 +128,28 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 		})
 
+		middlewareScenario("Valid api key via Basic auth", func(sc *scenarioContext) {
+			keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd")
+
+			bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error {
+				query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash}
+				return nil
+			})
+
+			authHeader := util.GetBasicAuthHeader("api_key", "eyJrIjoidjVuQXdwTWFmRlA2em5hUzR1cmhkV0RMUzU1MTFNNDIiLCJuIjoiYXNkIiwiaWQiOjF9")
+			sc.fakeReq("GET", "/").withAuthorizationHeader(authHeader).exec()
+
+			Convey("Should return 200", func() {
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			Convey("Should init middleware context", func() {
+				So(sc.context.IsSignedIn, ShouldEqual, true)
+				So(sc.context.OrgId, ShouldEqual, 12)
+				So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR)
+			})
+		})
+
 		middlewareScenario("UserId in session", func(sc *scenarioContext) {
 
 			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
@@ -293,61 +315,6 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 		})
 
-		middlewareScenario("When auth_proxy is enabled and request has X-Forwarded-For that is not trusted", func(sc *scenarioContext) {
-			setting.AuthProxyEnabled = true
-			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
-			setting.AuthProxyHeaderProperty = "username"
-			setting.AuthProxyWhitelist = "192.168.1.1, 2001::23"
-
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
-				return nil
-			})
-
-			bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
-				cmd.Result = &m.User{Id: 33}
-				return nil
-			})
-
-			sc.fakeReq("GET", "/")
-			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
-			sc.req.Header.Add("X-Forwarded-For", "client-ip, 192.168.1.1, 192.168.1.2")
-			sc.exec()
-
-			Convey("should return 407 status code", func() {
-				So(sc.resp.Code, ShouldEqual, 407)
-				So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 192.168.1.2 is not from the authentication proxy")
-			})
-		})
-
-		middlewareScenario("When auth_proxy is enabled and request has X-Forwarded-For that is trusted", func(sc *scenarioContext) {
-			setting.AuthProxyEnabled = true
-			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
-			setting.AuthProxyHeaderProperty = "username"
-			setting.AuthProxyWhitelist = "192.168.1.1, 2001::23"
-
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
-				return nil
-			})
-
-			bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
-				cmd.Result = &m.User{Id: 33}
-				return nil
-			})
-
-			sc.fakeReq("GET", "/")
-			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
-			sc.req.Header.Add("X-Forwarded-For", "client-ip, 192.168.1.2, 192.168.1.1")
-			sc.exec()
-
-			Convey("Should init context with user info", func() {
-				So(sc.context.IsSignedIn, ShouldBeTrue)
-				So(sc.context.UserId, ShouldEqual, 33)
-				So(sc.context.OrgId, ShouldEqual, 4)
-			})
-		})
-
 		middlewareScenario("When session exists for previous user, create a new session", func(sc *scenarioContext) {
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
@@ -473,7 +440,7 @@ func (sc *scenarioContext) withInvalidApiKey() *scenarioContext {
 	return sc
 }
 
-func (sc *scenarioContext) withAuthoriziationHeader(authHeader string) *scenarioContext {
+func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
 	sc.authHeader = authHeader
 	return sc
 }

+ 1 - 0
pkg/models/team_member.go

@@ -42,6 +42,7 @@ type RemoveTeamMemberCommand struct {
 type GetTeamMembersQuery struct {
 	OrgId  int64
 	TeamId int64
+	UserId int64
 	Result []*TeamMemberDTO
 }
 

+ 6 - 0
pkg/models/user_auth.go

@@ -19,6 +19,7 @@ type ExternalUserInfo struct {
 	Email      string
 	Login      string
 	Name       string
+	Groups     []string
 	OrgRoles   map[int64]RoleType
 }
 
@@ -70,3 +71,8 @@ type GetAuthInfoQuery struct {
 
 	Result *UserAuth
 }
+
+type SyncTeamsCommand struct {
+	ExternalUser *ExternalUserInfo
+	User         *User
+}

+ 27 - 3
pkg/registry/registry.go

@@ -4,6 +4,8 @@ import (
 	"context"
 	"reflect"
 	"sort"
+
+	"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 )
 
 type Descriptor struct {
@@ -34,23 +36,45 @@ func GetServices() []*Descriptor {
 	return services
 }
 
+// Service interface is the lowest common shape that services
+// are expected to forfill to be started within Grafana.
 type Service interface {
+
+	// Init is called by Grafana main process which gives the service
+	// the possibility do some initial work before its started. Things
+	// like adding routes, bus handlers should be done in the Init function
 	Init() error
 }
 
-// Useful for alerting service
+// CanBeDisabled allows the services to decide if it should
+// be started or not by itself. This is useful for services
+// that might not always be started, ex alerting.
+// This will be called after `Init()`.
 type CanBeDisabled interface {
+
+	// IsDisabled should return a bool saying if it can be started or not.
 	IsDisabled() bool
 }
 
+// BackgroundService should be implemented for services that have
+// long running tasks in the background.
 type BackgroundService interface {
+	// Run starts the background process of the service after `Init` have been called
+	// on all services. The `context.Context` passed into the function should be used
+	// to subscribe to ctx.Done() so the service can be notified when Grafana shuts down.
 	Run(ctx context.Context) error
 }
 
-type HasInitPriority interface {
-	GetInitPriority() Priority
+// DatabaseMigrator allows the caller to add migrations to
+// the migrator passed as argument
+type DatabaseMigrator interface {
+
+	// AddMigrations allows the service to add migrations to
+	// the database migrator.
+	AddMigration(mg *migrator.Migrator)
 }
 
+// IsDisabled takes an service and return true if its disabled
 func IsDisabled(srv Service) bool {
 	canBeDisabled, ok := srv.(CanBeDisabled)
 	return ok && canBeDisabled.IsDisabled()

+ 4 - 4
pkg/services/alerting/conditions/reducer.go

@@ -108,9 +108,9 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float {
 				break
 			}
 		}
-		// get other points
+		// get the oldest point
 		points = points[0:i]
-		for i := len(points) - 1; i >= 0; i-- {
+		for i := 0; i < len(points); i++ {
 			if points[i][0].Valid {
 				allNull = false
 				value = first - points[i][0].Float64
@@ -131,9 +131,9 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float {
 				break
 			}
 		}
-		// get other points
+		// get the oldest point
 		points = points[0:i]
-		for i := len(points) - 1; i >= 0; i-- {
+		for i := 0; i < len(points); i++ {
 			if points[i][0].Valid {
 				allNull = false
 				val := (first - points[i][0].Float64) / points[i][0].Float64 * 100

+ 21 - 2
pkg/services/alerting/conditions/reducer_test.go

@@ -110,16 +110,35 @@ func TestSimpleReducer(t *testing.T) {
 			So(reducer.Reduce(series).Float64, ShouldEqual, float64(3))
 		})
 
-		Convey("diff", func() {
+		Convey("diff one point", func() {
+			result := testReducer("diff", 30)
+			So(result, ShouldEqual, float64(0))
+		})
+
+		Convey("diff two points", func() {
 			result := testReducer("diff", 30, 40)
 			So(result, ShouldEqual, float64(10))
 		})
 
-		Convey("percent_diff", func() {
+		Convey("diff three points", func() {
+			result := testReducer("diff", 30, 40, 40)
+			So(result, ShouldEqual, float64(10))
+		})
+
+		Convey("percent_diff one point", func() {
+			result := testReducer("percent_diff", 40)
+			So(result, ShouldEqual, float64(0))
+		})
+
+		Convey("percent_diff two points", func() {
 			result := testReducer("percent_diff", 30, 40)
 			So(result, ShouldEqual, float64(33.33333333333333))
 		})
 
+		Convey("percent_diff three points", func() {
+			result := testReducer("percent_diff", 30, 40, 40)
+			So(result, ShouldEqual, float64(33.33333333333333))
+		})
 	})
 }
 

+ 4 - 4
pkg/services/alerting/extractor_test.go

@@ -50,7 +50,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 		So(err, ShouldBeNil)
 
 		Convey("Extractor should not modify the original json", func() {
-			dashJson, err := simplejson.NewJson([]byte(json))
+			dashJson, err := simplejson.NewJson(json)
 			So(err, ShouldBeNil)
 
 			dash := m.NewDashboardFromJson(dashJson)
@@ -79,7 +79,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 
 		Convey("Parsing and validating dashboard containing graphite alerts", func() {
 
-			dashJson, err := simplejson.NewJson([]byte(json))
+			dashJson, err := simplejson.NewJson(json)
 			So(err, ShouldBeNil)
 
 			dash := m.NewDashboardFromJson(dashJson)
@@ -143,7 +143,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			panelWithoutId, err := ioutil.ReadFile("./test-data/panels-missing-id.json")
 			So(err, ShouldBeNil)
 
-			dashJson, err := simplejson.NewJson([]byte(panelWithoutId))
+			dashJson, err := simplejson.NewJson(panelWithoutId)
 			So(err, ShouldBeNil)
 			dash := m.NewDashboardFromJson(dashJson)
 			extractor := NewDashAlertExtractor(dash, 1)
@@ -159,7 +159,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 			panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json")
 			So(err, ShouldBeNil)
 
-			dashJson, err := simplejson.NewJson([]byte(panelWithIdZero))
+			dashJson, err := simplejson.NewJson(panelWithIdZero)
 			So(err, ShouldBeNil)
 			dash := m.NewDashboardFromJson(dashJson)
 			extractor := NewDashAlertExtractor(dash, 1)

+ 4 - 1
pkg/services/alerting/notifier.go

@@ -104,7 +104,10 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
 		return err
 	}
 
-	n.log.Info("uploaded", "url", context.ImagePublicUrl)
+	if context.ImagePublicUrl != "" {
+		n.log.Info("uploaded screenshot of alert to external image store", "url", context.ImagePublicUrl)
+	}
+
 	return nil
 }
 

+ 1 - 0
pkg/services/sqlstore/migrations/team_mig.go

@@ -50,4 +50,5 @@ func addTeamMigrations(mg *Migrator) {
 	mg.AddMigration("Add column email to team table", NewAddColumnMigration(teamV1, &Column{
 		Name: "email", Type: DB_NVarchar, Nullable: true, Length: 190,
 	}))
+
 }

+ 7 - 0
pkg/services/sqlstore/sqlstore.go

@@ -132,6 +132,13 @@ func (ss *SqlStore) Init() error {
 	migrator := migrator.NewMigrator(x)
 	migrations.AddMigrations(migrator)
 
+	for _, descriptor := range registry.GetServices() {
+		sc, ok := descriptor.Instance.(registry.DatabaseMigrator)
+		if ok {
+			sc.AddMigration(migrator)
+		}
+	}
+
 	if err := migrator.Start(); err != nil {
 		return fmt.Errorf("Migration failed err: %v", err)
 	}

+ 9 - 1
pkg/services/sqlstore/team.go

@@ -268,7 +268,15 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	query.Result = make([]*m.TeamMemberDTO, 0)
 	sess := x.Table("team_member")
 	sess.Join("INNER", "user", fmt.Sprintf("team_member.user_id=%s.id", x.Dialect().Quote("user")))
-	sess.Where("team_member.org_id=? and team_member.team_id=?", query.OrgId, query.TeamId)
+	if query.OrgId != 0 {
+		sess.Where("team_member.org_id=?", query.OrgId)
+	}
+	if query.TeamId != 0 {
+		sess.Where("team_member.team_id=?", query.TeamId)
+	}
+	if query.UserId != 0 {
+		sess.Where("team_member.user_id=?", query.UserId)
+	}
 	sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login")
 	sess.Asc("user.login", "user.email")
 

+ 4 - 3
pkg/setting/setting.go

@@ -18,9 +18,10 @@ import (
 
 	"github.com/go-macaron/session"
 
+	"time"
+
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/util"
-	"time"
 )
 
 type Scheme string
@@ -49,7 +50,7 @@ var (
 	BuildVersion    string
 	BuildCommit     string
 	BuildStamp      int64
-	Enterprise      bool
+	IsEnterprise    bool
 	ApplicationName string
 
 	// Paths
@@ -517,7 +518,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	Raw = cfg.Raw
 
 	ApplicationName = "Grafana"
-	if Enterprise {
+	if IsEnterprise {
 		ApplicationName += " Enterprise"
 	}
 

+ 1 - 0
pkg/social/github_oauth.go

@@ -213,6 +213,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
 	userInfo := &BasicUserInfo{
 		Name:  data.Login,
 		Login: data.Login,
+		Id:    fmt.Sprintf("%d", data.Id),
 		Email: data.Email,
 	}
 

+ 9 - 9
pkg/tsdb/elasticsearch/client/search_request_test.go

@@ -32,7 +32,7 @@ func TestSearchRequest(t *testing.T) {
 				Convey("When marshal to JSON should generate correct json", func() {
 					body, err := json.Marshal(sr)
 					So(err, ShouldBeNil)
-					json, err := simplejson.NewJson([]byte(body))
+					json, err := simplejson.NewJson(body)
 					So(err, ShouldBeNil)
 					So(json.Get("size").MustInt(500), ShouldEqual, 0)
 					So(json.Get("sort").Interface(), ShouldBeNil)
@@ -81,7 +81,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 						So(json.Get("size").MustInt(0), ShouldEqual, 200)
 
@@ -124,7 +124,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 
 						scriptFields, err := json.Get("script_fields").Map()
@@ -163,7 +163,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 
 						So(json.Get("aggs").MustMap(), ShouldHaveLength, 2)
@@ -200,7 +200,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 
 						So(json.Get("aggs").MustMap(), ShouldHaveLength, 1)
@@ -251,7 +251,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 
 						topAggOne := json.GetPath("aggs", "1")
@@ -300,7 +300,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 
 						topAgg := json.GetPath("aggs", "1")
@@ -364,7 +364,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 
 						termsAgg := json.GetPath("aggs", "1")
@@ -419,7 +419,7 @@ func TestSearchRequest(t *testing.T) {
 					Convey("When marshal to JSON should generate correct json", func() {
 						body, err := json.Marshal(sr)
 						So(err, ShouldBeNil)
-						json, err := simplejson.NewJson([]byte(body))
+						json, err := simplejson.NewJson(body)
 						So(err, ShouldBeNil)
 
 						scriptFields, err := json.Get("script_fields").Map()

+ 5 - 4
pkg/tsdb/mssql/macros.go

@@ -82,11 +82,12 @@ func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 		if len(args) == 0 {
 			return "", fmt.Errorf("missing time column argument for macro %v", name)
 		}
-		return fmt.Sprintf("%s >= DATEADD(s, %d, '1970-01-01') AND %s <= DATEADD(s, %d, '1970-01-01')", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil
+
+		return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeFrom":
-		return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", m.TimeRange.GetFromAsSecondsEpoch()), nil
+		return fmt.Sprintf("'%s'", m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeTo":
-		return fmt.Sprintf("DATEADD(second, %d, '1970-01-01')", m.TimeRange.GetToAsSecondsEpoch()), nil
+		return fmt.Sprintf("'%s'", m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval", name)
@@ -108,7 +109,7 @@ func (m *MsSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 				m.Query.Model.Set("fillValue", floatVal)
 			}
 		}
-		return fmt.Sprintf("CAST(ROUND(DATEDIFF(second, '1970-01-01', %s)/%.1f, 0) as bigint)*%.0f", args[0], interval.Seconds(), interval.Seconds()), nil
+		return fmt.Sprintf("FLOOR(DATEDIFF(second, '1970-01-01', %s)/%.0f)*%.0f", args[0], interval.Seconds(), interval.Seconds()), nil
 	case "__unixEpochFilter":
 		if len(args) == 0 {
 			return "", fmt.Errorf("missing time column argument for macro %v", name)

+ 11 - 11
pkg/tsdb/mssql/macros_test.go

@@ -49,21 +49,21 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeGroup function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, "GROUP BY CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)*300")
+				So(sql, ShouldEqual, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300")
 			})
 
 			Convey("interpolate __timeGroup function with spaces around arguments", func() {
 				sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, "GROUP BY CAST(ROUND(DATEDIFF(second, '1970-01-01', time_column)/300.0, 0) as bigint)*300")
+				So(sql, ShouldEqual, "GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time_column)/300)*300")
 			})
 
 			Convey("interpolate __timeGroup function with fill (value = NULL)", func() {
@@ -96,14 +96,14 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeTo function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __unixEpochFilter function", func() {
@@ -137,21 +137,21 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeFrom function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeTo function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __unixEpochFilter function", func() {
@@ -185,21 +185,21 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= DATEADD(s, %d, '1970-01-01') AND time_column <= DATEADD(s, %d, '1970-01-01')", from.Unix(), to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeFrom function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", from.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeTo function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select DATEADD(second, %d, '1970-01-01')", to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __unixEpochFilter function", func() {

+ 19 - 13
pkg/tsdb/mssql/mssql_test.go

@@ -210,11 +210,12 @@ func TestMSSQL(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				points := queryResult.Series[0].Points
-				So(len(points), ShouldEqual, 6)
+				// without fill this should result in 4 buckets
+				So(len(points), ShouldEqual, 4)
 
 				dt := fromStart
 
-				for i := 0; i < 3; i++ {
+				for i := 0; i < 2; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 15)
@@ -222,9 +223,9 @@ func TestMSSQL(t *testing.T) {
 					dt = dt.Add(5 * time.Minute)
 				}
 
-				// adjust for 5 minute gap
-				dt = dt.Add(5 * time.Minute)
-				for i := 3; i < 6; i++ {
+				// adjust for 10 minute gap between first and second set of points
+				dt = dt.Add(10 * time.Minute)
+				for i := 2; i < 4; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 20)
@@ -260,7 +261,7 @@ func TestMSSQL(t *testing.T) {
 
 				dt := fromStart
 
-				for i := 0; i < 3; i++ {
+				for i := 0; i < 2; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 15)
@@ -268,17 +269,22 @@ func TestMSSQL(t *testing.T) {
 					dt = dt.Add(5 * time.Minute)
 				}
 
+				// check for NULL values inserted by fill
+				So(points[2][0].Valid, ShouldBeFalse)
 				So(points[3][0].Valid, ShouldBeFalse)
 
-				// adjust for 5 minute gap
-				dt = dt.Add(5 * time.Minute)
-				for i := 4; i < 7; i++ {
+				// adjust for 10 minute gap between first and second set of points
+				dt = dt.Add(10 * time.Minute)
+				for i := 4; i < 6; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 20)
 					So(aTime, ShouldEqual, dt)
 					dt = dt.Add(5 * time.Minute)
 				}
+
+				So(points[6][0].Valid, ShouldBeFalse)
+
 			})
 
 			Convey("When doing a metric query using timeGroup with float fill enabled", func() {
@@ -525,7 +531,7 @@ func TestMSSQL(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				So(len(queryResult.Series), ShouldEqual, 1)
-				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3)
+				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3)
 			})
 
 			Convey("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable) should return metric with time in milliseconds", func() {
@@ -547,7 +553,7 @@ func TestMSSQL(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				So(len(queryResult.Series), ShouldEqual, 1)
-				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3)
+				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3)
 			})
 
 			Convey("When doing a metric query grouping by time and select metric column should return correct series", func() {
@@ -924,7 +930,7 @@ func TestMSSQL(t *testing.T) {
 				columns := queryResult.Tables[0].Rows[0]
 
 				//Should be in milliseconds
-				So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000))
+				So(columns[0].(int64), ShouldEqual, dt.Unix()*1000)
 			})
 
 			Convey("When doing an annotation query with a time column in epoch second format (int) should return ms", func() {
@@ -954,7 +960,7 @@ func TestMSSQL(t *testing.T) {
 				columns := queryResult.Tables[0].Rows[0]
 
 				//Should be in milliseconds
-				So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000))
+				So(columns[0].(int64), ShouldEqual, dt.Unix()*1000)
 			})
 
 			Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() {

+ 5 - 4
pkg/tsdb/mysql/macros.go

@@ -77,11 +77,12 @@ func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 		if len(args) == 0 {
 			return "", fmt.Errorf("missing time column argument for macro %v", name)
 		}
-		return fmt.Sprintf("%s >= FROM_UNIXTIME(%d) AND %s <= FROM_UNIXTIME(%d)", args[0], m.TimeRange.GetFromAsSecondsEpoch(), args[0], m.TimeRange.GetToAsSecondsEpoch()), nil
+
+		return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeFrom":
-		return fmt.Sprintf("FROM_UNIXTIME(%d)", m.TimeRange.GetFromAsSecondsEpoch()), nil
+		return fmt.Sprintf("'%s'", m.TimeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeTo":
-		return fmt.Sprintf("FROM_UNIXTIME(%d)", m.TimeRange.GetToAsSecondsEpoch()), nil
+		return fmt.Sprintf("'%s'", m.TimeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
 	case "__timeGroup":
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval", name)
@@ -103,7 +104,7 @@ func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
 				m.Query.Model.Set("fillValue", floatVal)
 			}
 		}
-		return fmt.Sprintf("cast(cast(UNIX_TIMESTAMP(%s)/(%.0f) as signed)*%.0f as signed)", args[0], interval.Seconds(), interval.Seconds()), nil
+		return fmt.Sprintf("UNIX_TIMESTAMP(%s) DIV %.0f * %.0f", args[0], interval.Seconds(), interval.Seconds()), nil
 	case "__unixEpochFilter":
 		if len(args) == 0 {
 			return "", fmt.Errorf("missing time column argument for macro %v", name)

+ 11 - 11
pkg/tsdb/mysql/macros_test.go

@@ -39,7 +39,7 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, "GROUP BY cast(cast(UNIX_TIMESTAMP(time_column)/(300) as signed)*300 as signed)")
+				So(sql, ShouldEqual, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300")
 			})
 
 			Convey("interpolate __timeGroup function with spaces around arguments", func() {
@@ -47,28 +47,28 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, "GROUP BY cast(cast(UNIX_TIMESTAMP(time_column)/(300) as signed)*300 as signed)")
+				So(sql, ShouldEqual, "GROUP BY UNIX_TIMESTAMP(time_column) DIV 300 * 300")
 			})
 
 			Convey("interpolate __timeFilter function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeFrom function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeTo function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __unixEpochFilter function", func() {
@@ -102,21 +102,21 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeFrom function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeTo function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __unixEpochFilter function", func() {
@@ -150,21 +150,21 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column >= FROM_UNIXTIME(%d) AND time_column <= FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeFrom function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", from.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __timeTo function", func() {
 				sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, fmt.Sprintf("select FROM_UNIXTIME(%d)", to.Unix()))
+				So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
 			})
 
 			Convey("interpolate __unixEpochFilter function", func() {

+ 21 - 14
pkg/tsdb/mysql/mysql_test.go

@@ -132,8 +132,8 @@ func TestMySQL(t *testing.T) {
 				So(column[7].(float64), ShouldEqual, 1.11)
 				So(column[8].(float64), ShouldEqual, 2.22)
 				So(*column[9].(*float32), ShouldEqual, 3.33)
-				So(column[10].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now())
-				So(column[11].(time.Time), ShouldHappenWithin, time.Duration(10*time.Second), time.Now())
+				So(column[10].(time.Time), ShouldHappenWithin, 10*time.Second, time.Now())
+				So(column[11].(time.Time), ShouldHappenWithin, 10*time.Second, time.Now())
 				So(column[12].(string), ShouldEqual, "11:11:11")
 				So(column[13].(int64), ShouldEqual, 2018)
 				So(*column[14].(*[]byte), ShouldHaveSameTypeAs, []byte{1})
@@ -209,11 +209,12 @@ func TestMySQL(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				points := queryResult.Series[0].Points
-				So(len(points), ShouldEqual, 6)
+				// without fill this should result in 4 buckets
+				So(len(points), ShouldEqual, 4)
 
 				dt := fromStart
 
-				for i := 0; i < 3; i++ {
+				for i := 0; i < 2; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 15)
@@ -221,9 +222,9 @@ func TestMySQL(t *testing.T) {
 					dt = dt.Add(5 * time.Minute)
 				}
 
-				// adjust for 5 minute gap
-				dt = dt.Add(5 * time.Minute)
-				for i := 3; i < 6; i++ {
+				// adjust for 10 minute gap between first and second set of points
+				dt = dt.Add(10 * time.Minute)
+				for i := 2; i < 4; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 20)
@@ -259,7 +260,7 @@ func TestMySQL(t *testing.T) {
 
 				dt := fromStart
 
-				for i := 0; i < 3; i++ {
+				for i := 0; i < 2; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 15)
@@ -267,17 +268,23 @@ func TestMySQL(t *testing.T) {
 					dt = dt.Add(5 * time.Minute)
 				}
 
+				// check for NULL values inserted by fill
+				So(points[2][0].Valid, ShouldBeFalse)
 				So(points[3][0].Valid, ShouldBeFalse)
 
-				// adjust for 5 minute gap
-				dt = dt.Add(5 * time.Minute)
-				for i := 4; i < 7; i++ {
+				// adjust for 10 minute gap between first and second set of points
+				dt = dt.Add(10 * time.Minute)
+				for i := 4; i < 6; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 20)
 					So(aTime, ShouldEqual, dt)
 					dt = dt.Add(5 * time.Minute)
 				}
+
+				// check for NULL values inserted by fill
+				So(points[6][0].Valid, ShouldBeFalse)
+
 			})
 
 			Convey("When doing a metric query using timeGroup with float fill enabled", func() {
@@ -571,7 +578,7 @@ func TestMySQL(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				So(len(queryResult.Series), ShouldEqual, 1)
-				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3)
+				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3)
 			})
 
 			Convey("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable) should return metric with time in milliseconds", func() {
@@ -593,7 +600,7 @@ func TestMySQL(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				So(len(queryResult.Series), ShouldEqual, 1)
-				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3)
+				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3)
 			})
 
 			Convey("When doing a metric query grouping by time and select metric column should return correct series", func() {
@@ -810,7 +817,7 @@ func TestMySQL(t *testing.T) {
 				columns := queryResult.Tables[0].Rows[0]
 
 				//Should be in milliseconds
-				So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000))
+				So(columns[0].(int64), ShouldEqual, dt.Unix()*1000)
 			})
 
 			Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() {

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

@@ -109,7 +109,7 @@ func (m *PostgresMacroEngine) evaluateMacro(name string, args []string) (string,
 				m.Query.Model.Set("fillValue", floatVal)
 			}
 		}
-		return fmt.Sprintf("(extract(epoch from %s)/%v)::bigint*%v AS time", args[0], interval.Seconds(), interval.Seconds()), nil
+		return fmt.Sprintf("floor(extract(epoch from %s)/%v)*%v AS time", args[0], interval.Seconds(), interval.Seconds()), nil
 	case "__unixEpochFilter":
 		if len(args) == 0 {
 			return "", fmt.Errorf("missing time column argument for macro %v", name)

+ 2 - 2
pkg/tsdb/postgres/macros_test.go

@@ -53,7 +53,7 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column,'5m')")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, "GROUP BY (extract(epoch from time_column)/300)::bigint*300 AS time")
+				So(sql, ShouldEqual, "GROUP BY floor(extract(epoch from time_column)/300)*300 AS time")
 			})
 
 			Convey("interpolate __timeGroup function with spaces between args", func() {
@@ -61,7 +61,7 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "GROUP BY $__timeGroup(time_column , '5m')")
 				So(err, ShouldBeNil)
 
-				So(sql, ShouldEqual, "GROUP BY (extract(epoch from time_column)/300)::bigint*300 AS time")
+				So(sql, ShouldEqual, "GROUP BY floor(extract(epoch from time_column)/300)*300 AS time")
 			})
 
 			Convey("interpolate __timeTo function", func() {

+ 21 - 13
pkg/tsdb/postgres/postgres_test.go

@@ -189,21 +189,23 @@ func TestPostgres(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				points := queryResult.Series[0].Points
-				So(len(points), ShouldEqual, 6)
+				// without fill this should result in 4 buckets
+				So(len(points), ShouldEqual, 4)
 
 				dt := fromStart
 
-				for i := 0; i < 3; i++ {
+				for i := 0; i < 2; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 15)
 					So(aTime, ShouldEqual, dt)
+					So(aTime.Unix()%300, ShouldEqual, 0)
 					dt = dt.Add(5 * time.Minute)
 				}
 
-				// adjust for 5 minute gap
-				dt = dt.Add(5 * time.Minute)
-				for i := 3; i < 6; i++ {
+				// adjust for 10 minute gap between first and second set of points
+				dt = dt.Add(10 * time.Minute)
+				for i := 2; i < 4; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 20)
@@ -239,7 +241,7 @@ func TestPostgres(t *testing.T) {
 
 				dt := fromStart
 
-				for i := 0; i < 3; i++ {
+				for i := 0; i < 2; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 15)
@@ -247,17 +249,23 @@ func TestPostgres(t *testing.T) {
 					dt = dt.Add(5 * time.Minute)
 				}
 
+				// check for NULL values inserted by fill
+				So(points[2][0].Valid, ShouldBeFalse)
 				So(points[3][0].Valid, ShouldBeFalse)
 
-				// adjust for 5 minute gap
-				dt = dt.Add(5 * time.Minute)
-				for i := 4; i < 7; i++ {
+				// adjust for 10 minute gap between first and second set of points
+				dt = dt.Add(10 * time.Minute)
+				for i := 4; i < 6; i++ {
 					aValue := points[i][0].Float64
 					aTime := time.Unix(int64(points[i][1].Float64)/1000, 0)
 					So(aValue, ShouldEqual, 20)
 					So(aTime, ShouldEqual, dt)
 					dt = dt.Add(5 * time.Minute)
 				}
+
+				// check for NULL values inserted by fill
+				So(points[6][0].Valid, ShouldBeFalse)
+
 			})
 
 			Convey("When doing a metric query using timeGroup with float fill enabled", func() {
@@ -504,7 +512,7 @@ func TestPostgres(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				So(len(queryResult.Series), ShouldEqual, 1)
-				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3)
+				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3)
 			})
 
 			Convey("When doing a metric query using epoch (float32 nullable) as time column and value column (float32 nullable) should return metric with time in milliseconds", func() {
@@ -526,7 +534,7 @@ func TestPostgres(t *testing.T) {
 				So(queryResult.Error, ShouldBeNil)
 
 				So(len(queryResult.Series), ShouldEqual, 1)
-				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float64(float32(tInitial.Unix())))*1e3)
+				So(queryResult.Series[0].Points[0][1].Float64, ShouldEqual, float64(float32(tInitial.Unix()))*1e3)
 			})
 
 			Convey("When doing a metric query grouping by time and select metric column should return correct series", func() {
@@ -713,7 +721,7 @@ func TestPostgres(t *testing.T) {
 				columns := queryResult.Tables[0].Rows[0]
 
 				//Should be in milliseconds
-				So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000))
+				So(columns[0].(int64), ShouldEqual, dt.Unix()*1000)
 			})
 
 			Convey("When doing an annotation query with a time column in epoch second format (int) should return ms", func() {
@@ -743,7 +751,7 @@ func TestPostgres(t *testing.T) {
 				columns := queryResult.Tables[0].Rows[0]
 
 				//Should be in milliseconds
-				So(columns[0].(int64), ShouldEqual, int64(dt.Unix()*1000))
+				So(columns[0].(int64), ShouldEqual, dt.Unix()*1000)
 			})
 
 			Convey("When doing an annotation query with a time column in epoch millisecond format should return ms", func() {

+ 64 - 34
public/app/containers/Explore/QueryField.tsx

@@ -9,7 +9,7 @@ import { getNextCharacter, getPreviousCousin } from './utils/dom';
 import BracesPlugin from './slate-plugins/braces';
 import ClearPlugin from './slate-plugins/clear';
 import NewlinePlugin from './slate-plugins/newline';
-import PluginPrism, { configurePrismMetricsTokens } from './slate-plugins/prism/index';
+import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
 import RunnerPlugin from './slate-plugins/runner';
 import debounce from './utils/debounce';
 import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
@@ -17,13 +17,13 @@ import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
 import Typeahead from './Typeahead';
 
 const EMPTY_METRIC = '';
-const TYPEAHEAD_DEBOUNCE = 300;
+export const TYPEAHEAD_DEBOUNCE = 300;
 
 function flattenSuggestions(s) {
   return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
 }
 
-const getInitialValue = query =>
+export const getInitialValue = query =>
   Value.fromJSON({
     document: {
       nodes: [
@@ -45,12 +45,14 @@ const getInitialValue = query =>
     },
   });
 
-class Portal extends React.Component {
+class Portal extends React.Component<any, any> {
   node: any;
+
   constructor(props) {
     super(props);
+    const { index = 0, prefix = 'query' } = props;
     this.node = document.createElement('div');
-    this.node.classList.add('explore-typeahead', `explore-typeahead-${props.index}`);
+    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
     document.body.appendChild(this.node);
   }
 
@@ -71,12 +73,14 @@ class QueryField extends React.Component<any, any> {
   constructor(props, context) {
     super(props, context);
 
+    const { prismDefinition = {}, prismLanguage = 'promql' } = props;
+
     this.plugins = [
       BracesPlugin(),
       ClearPlugin(),
       RunnerPlugin({ handler: props.onPressEnter }),
       NewlinePlugin(),
-      PluginPrism(),
+      PluginPrism({ definition: prismDefinition, language: prismLanguage }),
     ];
 
     this.state = {
@@ -131,7 +135,8 @@ class QueryField extends React.Component<any, any> {
     if (!this.state.metrics) {
       return;
     }
-    configurePrismMetricsTokens(this.state.metrics);
+    setPrismTokens(this.props.prismLanguage, 'metrics', this.state.metrics);
+
     // Trigger re-render
     window.requestAnimationFrame(() => {
       // Bogus edit to trigger highlighting
@@ -162,7 +167,7 @@ class QueryField extends React.Component<any, any> {
     const selection = window.getSelection();
     if (selection.anchorNode) {
       const wrapperNode = selection.anchorNode.parentElement;
-      const editorNode = wrapperNode.closest('.query-field');
+      const editorNode = wrapperNode.closest('.slate-query-field');
       if (!editorNode || this.state.value.isBlurred) {
         // Not inside this editor
         return;
@@ -330,20 +335,30 @@ class QueryField extends React.Component<any, any> {
   }
 
   onKeyDown = (event, change) => {
-    if (this.menuEl) {
-      const { typeaheadIndex, suggestions } = this.state;
-
-      switch (event.key) {
-        case 'Escape': {
-          if (this.menuEl) {
-            event.preventDefault();
-            this.resetTypeahead();
-            return true;
-          }
-          break;
+    const { typeaheadIndex, suggestions } = this.state;
+
+    switch (event.key) {
+      case 'Escape': {
+        if (this.menuEl) {
+          event.preventDefault();
+          event.stopPropagation();
+          this.resetTypeahead();
+          return true;
         }
+        break;
+      }
 
-        case 'Tab': {
+      case ' ': {
+        if (event.ctrlKey) {
+          event.preventDefault();
+          this.handleTypeahead();
+          return true;
+        }
+        break;
+      }
+
+      case 'Tab': {
+        if (this.menuEl) {
           // Dont blur input
           event.preventDefault();
           if (!suggestions || suggestions.length === 0) {
@@ -359,25 +374,30 @@ class QueryField extends React.Component<any, any> {
           this.applyTypeahead(change, suggestion);
           return true;
         }
+        break;
+      }
 
-        case 'ArrowDown': {
+      case 'ArrowDown': {
+        if (this.menuEl) {
           // Select next suggestion
           event.preventDefault();
           this.setState({ typeaheadIndex: typeaheadIndex + 1 });
-          break;
         }
+        break;
+      }
 
-        case 'ArrowUp': {
+      case 'ArrowUp': {
+        if (this.menuEl) {
           // Select previous suggestion
           event.preventDefault();
           this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
-          break;
         }
+        break;
+      }
 
-        default: {
-          // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
-          break;
-        }
+      default: {
+        // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
+        break;
       }
     }
     return undefined;
@@ -502,10 +522,17 @@ class QueryField extends React.Component<any, any> {
 
     // Align menu overlay to editor node
     if (node) {
+      // Read from DOM
       const rect = node.parentElement.getBoundingClientRect();
-      menu.style.opacity = 1;
-      menu.style.top = `${rect.top + window.scrollY + rect.height + 4}px`;
-      menu.style.left = `${rect.left + window.scrollX - 2}px`;
+      const scrollX = window.scrollX;
+      const scrollY = window.scrollY;
+
+      // Write DOM
+      requestAnimationFrame(() => {
+        menu.style.opacity = 1;
+        menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
+        menu.style.left = `${rect.left + scrollX - 2}px`;
+      });
     }
   };
 
@@ -514,6 +541,7 @@ class QueryField extends React.Component<any, any> {
   };
 
   renderMenu = () => {
+    const { portalPrefix } = this.props;
     const { suggestions } = this.state;
     const hasSuggesstions = suggestions && suggestions.length > 0;
     if (!hasSuggesstions) {
@@ -524,11 +552,13 @@ class QueryField extends React.Component<any, any> {
     let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
     const flattenedSuggestions = flattenSuggestions(suggestions);
     selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
-    const selectedKeys = flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : [];
+    const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
+      i => (typeof i === 'object' ? i.text : i)
+    );
 
     // Create typeahead in DOM root so we can later position it absolutely
     return (
-      <Portal>
+      <Portal prefix={portalPrefix}>
         <Typeahead
           menuRef={this.menuRef}
           selectedItems={selectedKeys}
@@ -541,7 +571,7 @@ class QueryField extends React.Component<any, any> {
 
   render() {
     return (
-      <div className="query-field">
+      <div className="slate-query-field">
         {this.renderMenu()}
         <Editor
           autoCorrect={false}

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

@@ -1,5 +1,6 @@
 import React, { PureComponent } from 'react';
 
+import promql from './slate-plugins/prism/promql';
 import QueryField from './QueryField';
 
 class QueryRow extends PureComponent<any, any> {
@@ -55,12 +56,15 @@ class QueryRow extends PureComponent<any, any> {
             <i className="fa fa-minus" />
           </button>
         </div>
-        <div className="query-field-wrapper">
+        <div className="slate-query-field-wrapper">
           <QueryField
             initialQuery={edited ? null : query}
+            portalPrefix="explore"
             onPressEnter={this.handlePressEnter}
             onQueryChange={this.handleChangeQuery}
             placeholder="Enter a PromQL query"
+            prismLanguage="promql"
+            prismDefinition={promql}
             request={request}
           />
         </div>

+ 15 - 4
public/app/containers/Explore/Typeahead.tsx

@@ -23,12 +23,13 @@ class TypeaheadItem extends React.PureComponent<any, any> {
   };
 
   render() {
-    const { isSelected, label, onClickItem } = this.props;
+    const { hint, isSelected, label, onClickItem } = this.props;
     const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
     const onClick = () => onClickItem(label);
     return (
       <li ref={this.getRef} className={className} onClick={onClick}>
         {label}
+        {hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null}
       </li>
     );
   }
@@ -41,9 +42,19 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
       <li className="typeahead-group">
         <div className="typeahead-group__title">{label}</div>
         <ul className="typeahead-group__list">
-          {items.map(item => (
-            <TypeaheadItem key={item} onClickItem={onClickItem} isSelected={selected.indexOf(item) > -1} label={item} />
-          ))}
+          {items.map(item => {
+            const text = typeof item === 'object' ? item.text : item;
+            const label = typeof item === 'object' ? item.display || item.text : item;
+            return (
+              <TypeaheadItem
+                key={text}
+                onClickItem={onClickItem}
+                isSelected={selected.indexOf(text) > -1}
+                hint={item.hint}
+                label={label}
+              />
+            );
+          })}
         </ul>
       </li>
     );

+ 11 - 10
public/app/containers/Explore/slate-plugins/prism/index.tsx

@@ -1,16 +1,12 @@
 import React from 'react';
 import Prism from 'prismjs';
 
-import Promql from './promql';
-
-Prism.languages.promql = Promql;
-
 const TOKEN_MARK = 'prism-token';
 
-export function configurePrismMetricsTokens(metrics) {
-  Prism.languages.promql.metric = {
-    alias: 'variable',
-    pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
+export function setPrismTokens(language, field, values, alias = 'variable') {
+  Prism.languages[language][field] = {
+    alias,
+    pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
   };
 }
 
@@ -21,7 +17,12 @@ export function configurePrismMetricsTokens(metrics) {
  * (Adapted to handle nested grammar definitions.)
  */
 
-export default function PrismPlugin() {
+export default function PrismPlugin({ definition, language }) {
+  if (definition) {
+    // Don't override exising modified definitions
+    Prism.languages[language] = Prism.languages[language] || definition;
+  }
+
   return {
     /**
      * Render a Slate mark with appropiate CSS class names
@@ -54,7 +55,7 @@ export default function PrismPlugin() {
 
       const texts = node.getTexts().toArray();
       const tstring = texts.map(t => t.text).join('\n');
-      const grammar = Prism.languages.promql;
+      const grammar = Prism.languages[language];
       const tokens = Prism.tokenize(tstring, grammar);
       const decorations = [];
       let startText = texts.shift();

+ 16 - 2
public/app/core/config.ts

@@ -1,11 +1,18 @@
 import _ from 'lodash';
 
-class Settings {
+export interface BuildInfo {
+  version: string;
+  commit: string;
+  isEnterprise: boolean;
+  env: string;
+}
+
+export class Settings {
   datasources: any;
   panels: any;
   appSubUrl: string;
   window_title_prefix: string;
-  buildInfo: any;
+  buildInfo: BuildInfo;
   new_panel_title: string;
   bootData: any;
   externalUserMngLinkUrl: string;
@@ -32,7 +39,14 @@ class Settings {
       playlist_timespan: '1m',
       unsaved_changes_warning: true,
       appSubUrl: '',
+      buildInfo: {
+        version: 'v1.0',
+        commit: '1',
+        env: 'production',
+        isEnterprise: false,
+      },
     };
+
     _.extend(this, defaults, options);
   }
 }

+ 2 - 2
public/app/core/directives/value_select_dropdown.ts

@@ -93,7 +93,7 @@ export class ValueSelectDropdownCtrl {
       tagValuesPromise = this.$q.when(tag.values);
     }
 
-    tagValuesPromise.then(values => {
+    return tagValuesPromise.then(values => {
       tag.values = values;
       tag.valuesText = values.join(' + ');
       _.each(this.options, option => {
@@ -132,7 +132,7 @@ export class ValueSelectDropdownCtrl {
     this.highlightIndex = (this.highlightIndex + direction) % this.search.options.length;
   }
 
-  selectValue(option, event, commitChange, excludeOthers) {
+  selectValue(option, event, commitChange?, excludeOthers?) {
     if (!option) {
       return;
     }

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

@@ -34,14 +34,10 @@ export class ContextSrv {
   constructor() {
     this.sidemenu = store.getBool('grafana.sidemenu', true);
 
-    if (!config.buildInfo) {
-      config.buildInfo = {};
-    }
     if (!config.bootData) {
       config.bootData = { user: {}, settings: {} };
     }
 
-    this.version = config.buildInfo.version;
     this.user = new User();
     this.isSignedIn = this.user.isSignedIn;
     this.isGrafanaAdmin = this.user.isGrafanaAdmin;

+ 35 - 0
public/app/core/specs/table_model.jest.ts

@@ -44,3 +44,38 @@ describe('when sorting table asc', () => {
     expect(table.rows[2][1]).toBe(15);
   });
 });
+
+describe('when sorting with nulls', () => {
+  var table;
+  var values;
+
+  beforeEach(() => {
+    table = new TableModel();
+    table.columns = [{}, {}];
+    table.rows = [[42, ''], [19, 'a'], [null, 'b'], [0, 'd'], [null, null], [2, 'c'], [0, null], [-8, '']];
+  });
+
+  it('numbers with nulls at end with asc sort', () => {
+    table.sort({ col: 0, desc: false });
+    values = table.rows.map(row => row[0]);
+    expect(values).toEqual([-8, 0, 0, 2, 19, 42, null, null]);
+  });
+
+  it('numbers with nulls at start with desc sort', () => {
+    table.sort({ col: 0, desc: true });
+    values = table.rows.map(row => row[0]);
+    expect(values).toEqual([null, null, 42, 19, 2, 0, 0, -8]);
+  });
+
+  it('strings with nulls at end with asc sort', () => {
+    table.sort({ col: 1, desc: false });
+    values = table.rows.map(row => row[1]);
+    expect(values).toEqual(['', '', 'a', 'b', 'c', 'd', null, null]);
+  });
+
+  it('strings with nulls at start with desc sort', () => {
+    table.sort({ col: 1, desc: true });
+    values = table.rows.map(row => row[1]);
+    expect(values).toEqual([null, null, 'd', 'c', 'b', 'a', '', '']);
+  });
+});

+ 14 - 0
public/app/core/specs/time_series.jest.ts

@@ -119,6 +119,20 @@ describe('TimeSeries', function() {
       series.getFlotPairs('null');
       expect(series.stats.avg).toBe(null);
     });
+
+    it('calculates timeStep', function() {
+      series = new TimeSeries({
+        datapoints: [[null, 1], [null, 2], [null, 3]],
+      });
+      series.getFlotPairs('null');
+      expect(series.stats.timeStep).toBe(1);
+
+      series = new TimeSeries({
+        datapoints: [[0, 1530529290], [0, 1530529305], [0, 1530529320]],
+      });
+      series.getFlotPairs('null');
+      expect(series.stats.timeStep).toBe(15);
+    });
   });
 
   describe('When checking if ms resolution is needed', function() {

+ 159 - 0
public/app/core/specs/value_select_dropdown.jest.ts

@@ -0,0 +1,159 @@
+import 'app/core/directives/value_select_dropdown';
+import { ValueSelectDropdownCtrl } from '../directives/value_select_dropdown';
+import q from 'q';
+
+describe('SelectDropdownCtrl', () => {
+  let tagValuesMap: any = {};
+
+  ValueSelectDropdownCtrl.prototype.onUpdated = jest.fn();
+  let ctrl;
+
+  describe('Given simple variable', () => {
+    beforeEach(() => {
+      ctrl = new ValueSelectDropdownCtrl(q);
+      ctrl.variable = {
+        current: { text: 'hej', value: 'hej' },
+        getValuesForTag: key => {
+          return Promise.resolve(tagValuesMap[key]);
+        },
+      };
+      ctrl.init();
+    });
+
+    it('Should init labelText and linkText', () => {
+      expect(ctrl.linkText).toBe('hej');
+    });
+  });
+
+  describe('Given variable with tags and dropdown is opened', () => {
+    beforeEach(() => {
+      ctrl = new ValueSelectDropdownCtrl(q);
+      ctrl.variable = {
+        current: { text: 'server-1', value: 'server-1' },
+        options: [
+          { text: 'server-1', value: 'server-1', selected: true },
+          { text: 'server-2', value: 'server-2' },
+          { text: 'server-3', value: 'server-3' },
+        ],
+        tags: ['key1', 'key2', 'key3'],
+        getValuesForTag: key => {
+          return Promise.resolve(tagValuesMap[key]);
+        },
+        multi: true,
+      };
+      tagValuesMap.key1 = ['server-1', 'server-3'];
+      tagValuesMap.key2 = ['server-2', 'server-3'];
+      tagValuesMap.key3 = ['server-1', 'server-2', 'server-3'];
+      ctrl.init();
+      ctrl.show();
+    });
+
+    it('should init tags model', () => {
+      expect(ctrl.tags.length).toBe(3);
+      expect(ctrl.tags[0].text).toBe('key1');
+    });
+
+    it('should init options model', () => {
+      expect(ctrl.options.length).toBe(3);
+    });
+
+    it('should init selected values array', () => {
+      expect(ctrl.selectedValues.length).toBe(1);
+    });
+
+    it('should set linkText', () => {
+      expect(ctrl.linkText).toBe('server-1');
+    });
+
+    describe('after adititional value is selected', () => {
+      beforeEach(() => {
+        ctrl.selectValue(ctrl.options[2], {});
+        ctrl.commitChanges();
+      });
+
+      it('should update link text', () => {
+        expect(ctrl.linkText).toBe('server-1 + server-3');
+      });
+    });
+
+    describe('When tag is selected', () => {
+      beforeEach(async () => {
+        await ctrl.selectTag(ctrl.tags[0]);
+        ctrl.commitChanges();
+      });
+
+      it('should select tag', () => {
+        expect(ctrl.selectedTags.length).toBe(1);
+      });
+
+      it('should select values', () => {
+        expect(ctrl.options[0].selected).toBe(true);
+        expect(ctrl.options[2].selected).toBe(true);
+      });
+
+      it('link text should not include tag values', () => {
+        expect(ctrl.linkText).toBe('');
+      });
+
+      describe('and then dropdown is opened and closed without changes', () => {
+        beforeEach(() => {
+          ctrl.show();
+          ctrl.commitChanges();
+        });
+
+        it('should still have selected tag', () => {
+          expect(ctrl.selectedTags.length).toBe(1);
+        });
+      });
+
+      describe('and then unselected', () => {
+        beforeEach(async () => {
+          await ctrl.selectTag(ctrl.tags[0]);
+        });
+
+        it('should deselect tag', () => {
+          expect(ctrl.selectedTags.length).toBe(0);
+        });
+      });
+
+      describe('and then value is unselected', () => {
+        beforeEach(() => {
+          ctrl.selectValue(ctrl.options[0], {});
+        });
+
+        it('should deselect tag', () => {
+          expect(ctrl.selectedTags.length).toBe(0);
+        });
+      });
+    });
+  });
+
+  describe('Given variable with selected tags', () => {
+    beforeEach(() => {
+      ctrl = new ValueSelectDropdownCtrl(q);
+      ctrl.variable = {
+        current: {
+          text: 'server-1',
+          value: 'server-1',
+          tags: [{ text: 'key1', selected: true }],
+        },
+        options: [
+          { text: 'server-1', value: 'server-1' },
+          { text: 'server-2', value: 'server-2' },
+          { text: 'server-3', value: 'server-3' },
+        ],
+        tags: ['key1', 'key2', 'key3'],
+        getValuesForTag: key => {
+          return Promise.resolve(tagValuesMap[key]);
+        },
+        multi: true,
+      };
+      ctrl.init();
+      ctrl.show();
+    });
+
+    it('should set tag as selected', () => {
+      expect(ctrl.tags[0].selected).toBe(true);
+    });
+  });
+});

+ 0 - 171
public/app/core/specs/value_select_dropdown_specs.ts

@@ -1,171 +0,0 @@
-import { describe, beforeEach, it, expect, angularMocks, sinon } from 'test/lib/common';
-import 'app/core/directives/value_select_dropdown';
-
-describe('SelectDropdownCtrl', function() {
-  var scope;
-  var ctrl;
-  var tagValuesMap: any = {};
-  var rootScope;
-  var q;
-
-  beforeEach(angularMocks.module('grafana.core'));
-  beforeEach(
-    angularMocks.inject(function($controller, $rootScope, $q, $httpBackend) {
-      rootScope = $rootScope;
-      q = $q;
-      scope = $rootScope.$new();
-      ctrl = $controller('ValueSelectDropdownCtrl', { $scope: scope });
-      ctrl.onUpdated = sinon.spy();
-      $httpBackend.when('GET', /\.html$/).respond('');
-    })
-  );
-
-  describe('Given simple variable', function() {
-    beforeEach(function() {
-      ctrl.variable = {
-        current: { text: 'hej', value: 'hej' },
-        getValuesForTag: function(key) {
-          return q.when(tagValuesMap[key]);
-        },
-      };
-      ctrl.init();
-    });
-
-    it('Should init labelText and linkText', function() {
-      expect(ctrl.linkText).to.be('hej');
-    });
-  });
-
-  describe('Given variable with tags and dropdown is opened', function() {
-    beforeEach(function() {
-      ctrl.variable = {
-        current: { text: 'server-1', value: 'server-1' },
-        options: [
-          { text: 'server-1', value: 'server-1', selected: true },
-          { text: 'server-2', value: 'server-2' },
-          { text: 'server-3', value: 'server-3' },
-        ],
-        tags: ['key1', 'key2', 'key3'],
-        getValuesForTag: function(key) {
-          return q.when(tagValuesMap[key]);
-        },
-        multi: true,
-      };
-      tagValuesMap.key1 = ['server-1', 'server-3'];
-      tagValuesMap.key2 = ['server-2', 'server-3'];
-      tagValuesMap.key3 = ['server-1', 'server-2', 'server-3'];
-      ctrl.init();
-      ctrl.show();
-    });
-
-    it('should init tags model', function() {
-      expect(ctrl.tags.length).to.be(3);
-      expect(ctrl.tags[0].text).to.be('key1');
-    });
-
-    it('should init options model', function() {
-      expect(ctrl.options.length).to.be(3);
-    });
-
-    it('should init selected values array', function() {
-      expect(ctrl.selectedValues.length).to.be(1);
-    });
-
-    it('should set linkText', function() {
-      expect(ctrl.linkText).to.be('server-1');
-    });
-
-    describe('after adititional value is selected', function() {
-      beforeEach(function() {
-        ctrl.selectValue(ctrl.options[2], {});
-        ctrl.commitChanges();
-      });
-
-      it('should update link text', function() {
-        expect(ctrl.linkText).to.be('server-1 + server-3');
-      });
-    });
-
-    describe('When tag is selected', function() {
-      beforeEach(function() {
-        ctrl.selectTag(ctrl.tags[0]);
-        rootScope.$digest();
-        ctrl.commitChanges();
-      });
-
-      it('should select tag', function() {
-        expect(ctrl.selectedTags.length).to.be(1);
-      });
-
-      it('should select values', function() {
-        expect(ctrl.options[0].selected).to.be(true);
-        expect(ctrl.options[2].selected).to.be(true);
-      });
-
-      it('link text should not include tag values', function() {
-        expect(ctrl.linkText).to.be('');
-      });
-
-      describe('and then dropdown is opened and closed without changes', function() {
-        beforeEach(function() {
-          ctrl.show();
-          ctrl.commitChanges();
-          rootScope.$digest();
-        });
-
-        it('should still have selected tag', function() {
-          expect(ctrl.selectedTags.length).to.be(1);
-        });
-      });
-
-      describe('and then unselected', function() {
-        beforeEach(function() {
-          ctrl.selectTag(ctrl.tags[0]);
-          rootScope.$digest();
-        });
-
-        it('should deselect tag', function() {
-          expect(ctrl.selectedTags.length).to.be(0);
-        });
-      });
-
-      describe('and then value is unselected', function() {
-        beforeEach(function() {
-          ctrl.selectValue(ctrl.options[0], {});
-        });
-
-        it('should deselect tag', function() {
-          expect(ctrl.selectedTags.length).to.be(0);
-        });
-      });
-    });
-  });
-
-  describe('Given variable with selected tags', function() {
-    beforeEach(function() {
-      ctrl.variable = {
-        current: {
-          text: 'server-1',
-          value: 'server-1',
-          tags: [{ text: 'key1', selected: true }],
-        },
-        options: [
-          { text: 'server-1', value: 'server-1' },
-          { text: 'server-2', value: 'server-2' },
-          { text: 'server-3', value: 'server-3' },
-        ],
-        tags: ['key1', 'key2', 'key3'],
-        getValuesForTag: function(key) {
-          return q.when(tagValuesMap[key]);
-        },
-        multi: true,
-      };
-      ctrl.init();
-      ctrl.show();
-    });
-
-    it('should set tag as selected', function() {
-      expect(ctrl.tags[0].selected).to.be(true);
-    });
-  });
-});

+ 5 - 12
public/app/core/table_model.ts

@@ -19,23 +19,16 @@ export default class TableModel {
     this.rows.sort(function(a, b) {
       a = a[options.col];
       b = b[options.col];
-      if (a < b) {
-        return -1;
-      }
-      if (a > b) {
-        return 1;
-      }
-      return 0;
+      // Sort null or undefined seperately from comparable values
+      return +(a == null) - +(b == null) || +(a > b) || -(a < b);
     });
 
-    this.columns[options.col].sort = true;
-
     if (options.desc) {
       this.rows.reverse();
-      this.columns[options.col].desc = true;
-    } else {
-      this.columns[options.col].desc = false;
     }
+
+    this.columns[options.col].sort = true;
+    this.columns[options.col].desc = options.desc;
   }
 
   addColumn(col) {

+ 11 - 11
public/app/features/annotations/specs/annotations_srv_specs.ts → public/app/features/annotations/specs/annotations_srv.jest.ts

@@ -1,17 +1,17 @@
-import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
 import '../annotations_srv';
-import helpers from 'test/specs/helpers';
 import 'app/features/dashboard/time_srv';
+import { AnnotationsSrv } from '../annotations_srv';
 
 describe('AnnotationsSrv', function() {
-  var ctx = new helpers.ServiceTestContext();
+  let $rootScope = {
+    onAppEvent: jest.fn(),
+  };
+  let $q;
+  let datasourceSrv;
+  let backendSrv;
+  let timeSrv;
 
-  beforeEach(angularMocks.module('grafana.core'));
-  beforeEach(angularMocks.module('grafana.services'));
-  beforeEach(ctx.createService('timeSrv'));
-  beforeEach(() => {
-    ctx.createService('annotationsSrv');
-  });
+  let annotationsSrv = new AnnotationsSrv($rootScope, $q, datasourceSrv, backendSrv, timeSrv);
 
   describe('When translating the query result', () => {
     const annotationSource = {
@@ -30,11 +30,11 @@ describe('AnnotationsSrv', function() {
     let translatedAnnotations;
 
     beforeEach(() => {
-      translatedAnnotations = ctx.service.translateQueryResult(annotationSource, annotations);
+      translatedAnnotations = annotationsSrv.translateQueryResult(annotationSource, annotations);
     });
 
     it('should set defaults', () => {
-      expect(translatedAnnotations[0].source).to.eql(annotationSource);
+      expect(translatedAnnotations[0].source).toEqual(annotationSource);
     });
   });
 });

+ 1 - 3
public/app/features/dashboard/specs/exporter.jest.ts

@@ -86,9 +86,7 @@ describe('given dashboard with repeated panels', () => {
       ],
     };
 
-    config.buildInfo = {
-      version: '3.0.2',
-    };
+    config.buildInfo.version = '3.0.2';
 
     //Stubs test function calls
     var datasourceSrvStub = { get: jest.fn(arg => getStub(arg)) };

+ 67 - 0
public/app/features/dashboard/specs/viewstate_srv.jest.ts

@@ -0,0 +1,67 @@
+//import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
+import 'app/features/dashboard/view_state_srv';
+import config from 'app/core/config';
+import { DashboardViewState } from '../view_state_srv';
+
+describe('when updating view state', () => {
+  let location = {
+    replace: jest.fn(),
+    search: jest.fn(),
+  };
+
+  let $scope = {
+    onAppEvent: jest.fn(() => {}),
+    dashboard: {
+      meta: {},
+      panels: [],
+    },
+  };
+
+  let $rootScope = {};
+  let viewState;
+
+  beforeEach(() => {
+    config.bootData = {
+      user: {
+        orgId: 1,
+      },
+    };
+  });
+
+  describe('to fullscreen true and edit true', () => {
+    beforeEach(() => {
+      location.search = jest.fn(() => {
+        return { fullscreen: true, edit: true, panelId: 1 };
+      });
+      viewState = new DashboardViewState($scope, location, {}, $rootScope);
+    });
+
+    it('should update querystring and view state', () => {
+      var updateState = { fullscreen: true, edit: true, panelId: 1 };
+
+      viewState.update(updateState);
+
+      expect(location.search).toHaveBeenCalledWith({
+        edit: true,
+        editview: null,
+        fullscreen: true,
+        orgId: 1,
+        panelId: 1,
+      });
+      expect(viewState.dashboard.meta.fullscreen).toBe(true);
+      expect(viewState.state.fullscreen).toBe(true);
+    });
+  });
+
+  describe('to fullscreen false', () => {
+    beforeEach(() => {
+      viewState = new DashboardViewState($scope, location, {}, $rootScope);
+    });
+    it('should remove params from query string', () => {
+      viewState.update({ fullscreen: true, panelId: 1, edit: true });
+      viewState.update({ fullscreen: false });
+      expect(viewState.dashboard.meta.fullscreen).toBe(false);
+      expect(viewState.state.fullscreen).toBe(null);
+    });
+  });
+});

+ 0 - 65
public/app/features/dashboard/specs/viewstate_srv_specs.ts

@@ -1,65 +0,0 @@
-import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
-import 'app/features/dashboard/view_state_srv';
-import config from 'app/core/config';
-
-describe('when updating view state', function() {
-  var viewState, location;
-  var timeSrv = {};
-  var templateSrv = {};
-  var contextSrv = {
-    user: {
-      orgId: 19,
-    },
-  };
-  beforeEach(function() {
-    config.bootData = {
-      user: {
-        orgId: 1,
-      },
-    };
-  });
-  beforeEach(angularMocks.module('grafana.services'));
-  beforeEach(
-    angularMocks.module(function($provide) {
-      $provide.value('timeSrv', timeSrv);
-      $provide.value('templateSrv', templateSrv);
-      $provide.value('contextSrv', contextSrv);
-    })
-  );
-
-  beforeEach(
-    angularMocks.inject(function(dashboardViewStateSrv, $location, $rootScope) {
-      $rootScope.onAppEvent = function() {};
-      $rootScope.dashboard = {
-        meta: {},
-        panels: [],
-      };
-      viewState = dashboardViewStateSrv.create($rootScope);
-      location = $location;
-    })
-  );
-
-  describe('to fullscreen true and edit true', function() {
-    it('should update querystring and view state', function() {
-      var updateState = { fullscreen: true, edit: true, panelId: 1 };
-      viewState.update(updateState);
-      expect(location.search()).to.eql({
-        fullscreen: true,
-        edit: true,
-        panelId: 1,
-        orgId: 1,
-      });
-      expect(viewState.dashboard.meta.fullscreen).to.be(true);
-      expect(viewState.state.fullscreen).to.be(true);
-    });
-  });
-
-  describe('to fullscreen false', function() {
-    it('should remove params from query string', function() {
-      viewState.update({ fullscreen: true, panelId: 1, edit: true });
-      viewState.update({ fullscreen: false });
-      expect(viewState.dashboard.meta.fullscreen).to.be(false);
-      expect(viewState.state.fullscreen).to.be(null);
-    });
-  });
-});

+ 9 - 8
public/app/features/dashlinks/module.ts

@@ -41,20 +41,20 @@ function dashLink($compile, $sanitize, linkSrv) {
       elem.html(template);
       $compile(elem.contents())(scope);
 
-      var anchor = elem.find('a');
-      var icon = elem.find('i');
-      var span = elem.find('span');
-
       function update() {
         var linkInfo = linkSrv.getAnchorInfo(link);
+
+        const anchor = elem.find('a');
+        const span = elem.find('span');
         span.text(linkInfo.title);
+
         if (!link.asDropdown) {
           anchor.attr('href', linkInfo.href);
           sanitizeAnchor();
         }
-        elem.find('a').attr('data-placement', 'bottom');
+        anchor.attr('data-placement', 'bottom');
         // tooltip
-        elem.find('a').tooltip({
+        anchor.tooltip({
           title: $sanitize(scope.link.tooltip),
           html: true,
           container: 'body',
@@ -62,12 +62,13 @@ function dashLink($compile, $sanitize, linkSrv) {
       }
 
       function sanitizeAnchor() {
+        const anchor = elem.find('a');
         const anchorSanitized = $sanitize(anchor.parent().html());
         anchor.parent().html(anchorSanitized);
       }
 
-      icon.attr('class', 'fa fa-fw ' + scope.link.icon);
-      anchor.attr('target', scope.link.target);
+      elem.find('i').attr('class', 'fa fa-fw ' + scope.link.icon);
+      elem.find('a').attr('target', scope.link.target);
 
       // fix for menus on the far right
       if (link.asDropdown && scope.$last) {

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