Przeglądaj źródła

Merge branch 'master' into fix/explore-datasource-change

Torkel Ödegaard 7 lat temu
rodzic
commit
2c9a867bde
100 zmienionych plików z 2087 dodań i 686 usunięć
  1. 8 8
      .circleci/config.yml
  2. 18 10
      CHANGELOG.md
  3. 4 2
      Dockerfile
  4. 1 1
      appveyor.yml
  5. 19 5
      conf/defaults.ini
  6. 19 5
      conf/sample.ini
  7. 2 1
      devenv/docker/ha_test/docker-compose.yaml
  8. 69 0
      devenv/docker/loadtest/README.md
  9. 71 0
      devenv/docker/loadtest/auth_token_test.js
  10. 187 0
      devenv/docker/loadtest/modules/client.js
  11. 35 0
      devenv/docker/loadtest/modules/util.js
  12. 24 0
      devenv/docker/loadtest/run.sh
  13. 26 1
      docs/sources/http_api/other.md
  14. 23 9
      packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
  15. 2 2
      packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
  16. 1 1
      packages/grafana-ui/src/components/Select/Select.tsx
  17. 6 6
      pkg/api/api.go
  18. 32 10
      pkg/api/common_test.go
  19. 1 1
      pkg/api/dashboard.go
  20. 17 16
      pkg/api/http_server.go
  21. 59 67
      pkg/api/login.go
  22. 49 16
      pkg/api/login_oauth.go
  23. 2 2
      pkg/api/org_invite.go
  24. 2 2
      pkg/api/signup.go
  25. 0 11
      pkg/middleware/auth.go
  26. 19 1
      pkg/middleware/auth_proxy.go
  27. 5 28
      pkg/middleware/middleware.go
  28. 48 30
      pkg/middleware/middleware_test.go
  29. 0 1
      pkg/middleware/org_redirect.go
  30. 11 13
      pkg/middleware/org_redirect_test.go
  31. 5 8
      pkg/middleware/quota_test.go
  32. 3 4
      pkg/middleware/recovery_test.go
  33. 0 21
      pkg/middleware/session.go
  34. 3 3
      pkg/models/context.go
  35. 13 3
      pkg/services/alerting/engine.go
  36. 148 0
      pkg/services/alerting/engine_integration_test.go
  37. 266 0
      pkg/services/auth/auth_token.go
  38. 339 0
      pkg/services/auth/auth_token_test.go
  39. 25 0
      pkg/services/auth/model.go
  40. 38 0
      pkg/services/auth/session_cleanup.go
  41. 36 0
      pkg/services/auth/session_cleanup_test.go
  42. 1 5
      pkg/services/dashboards/dashboard_service.go
  43. 0 2
      pkg/services/session/session.go
  44. 1 0
      pkg/services/sqlstore/migrations/migrations.go
  45. 32 0
      pkg/services/sqlstore/migrations/user_auth_token_mig.go
  46. 18 6
      pkg/setting/setting.go
  47. 8 0
      pkg/util/encoding.go
  48. 29 0
      pkg/util/ip_address.go
  49. 16 0
      pkg/util/ip_address_test.go
  50. 6 2
      public/app/core/actions/location.ts
  51. 1 1
      public/app/core/components/sidemenu/SideMenuDropDown.tsx
  52. 6 2
      public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
  53. 0 1
      public/app/core/controllers/all.ts
  54. 0 71
      public/app/core/controllers/inspect_ctrl.ts
  55. 3 5
      public/app/core/reducers/location.ts
  56. 9 0
      public/app/core/specs/url.test.ts
  57. 1 1
      public/app/core/utils/explore.ts
  58. 12 2
      public/app/core/utils/url.ts
  59. 10 10
      public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
  60. 5 5
      public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
  61. 1 0
      public/app/features/dashboard/components/AddPanelWidget/index.ts
  62. 0 0
      public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts
  63. 1 0
      public/app/features/dashboard/components/RowOptions/index.ts
  64. 0 0
      public/app/features/dashboard/components/RowOptions/template.html
  65. 1 1
      public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
  66. 2 2
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  67. 1 1
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
  68. 1 3
      public/app/features/dashboard/index.ts
  69. 0 7
      public/app/features/dashboard/partials/folder_permissions.html
  70. 0 23
      public/app/features/dashboard/partials/folder_settings.html
  71. 0 82
      public/app/features/dashboard/partials/inspector.html
  72. 0 1
      public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
  73. 11 2
      public/app/features/dashboard/services/DashboardViewStateSrv.ts
  74. 2 103
      public/app/features/explore/Explore.tsx
  75. 191 0
      public/app/features/explore/ExploreToolbar.tsx
  76. 12 7
      public/app/features/explore/QueryField.tsx
  77. 1 0
      public/app/features/explore/TimePicker.tsx
  78. 21 10
      public/app/features/explore/Wrapper.tsx
  79. 9 2
      public/app/features/explore/state/actionTypes.ts
  80. 9 0
      public/app/features/explore/state/actions.ts
  81. 7 13
      public/app/features/explore/state/reducers.ts
  82. 2 1
      public/app/features/folders/CreateFolderCtrl.ts
  83. 2 1
      public/app/features/folders/FolderDashboardsCtrl.ts
  84. 0 0
      public/app/features/folders/partials/create_folder.html
  85. 0 0
      public/app/features/folders/partials/folder_dashboards.html
  86. 0 0
      public/app/features/folders/services/FolderPageLoader.ts
  87. 2 0
      public/app/features/manage-dashboards/DashboardImportCtrl.ts
  88. 0 6
      public/app/features/manage-dashboards/index.ts
  89. 0 0
      public/app/features/manage-dashboards/partials/dashboard_import.html
  90. 2 0
      public/app/features/panel/all.ts
  91. 0 13
      public/app/features/panel/panel_ctrl.ts
  92. 0 5
      public/app/features/panel/panel_directive.ts
  93. 0 0
      public/app/features/panel/panellinks/link_srv.ts
  94. 0 0
      public/app/features/panel/panellinks/module.html
  95. 0 0
      public/app/features/panel/panellinks/module.ts
  96. 0 0
      public/app/features/panel/panellinks/specs/link_srv.test.ts
  97. 0 0
      public/app/features/panel/repeat_option.ts
  98. 11 0
      public/app/features/templating/specs/template_srv.test.ts
  99. 3 1
      public/app/features/templating/template_srv.ts
  100. 1 1
      public/app/plugins/panel/singlestat/module.ts

+ 8 - 8
.circleci/config.yml

@@ -19,7 +19,7 @@ version: 2
 jobs:
 jobs:
   mysql-integration-test:
   mysql-integration-test:
     docker:
     docker:
-      - image: circleci/golang:1.11.4
+      - image: circleci/golang:1.11.5
       - image: circleci/mysql:5.6-ram
       - image: circleci/mysql:5.6-ram
         environment:
         environment:
           MYSQL_ROOT_PASSWORD: rootpass
           MYSQL_ROOT_PASSWORD: rootpass
@@ -39,7 +39,7 @@ jobs:
 
 
   postgres-integration-test:
   postgres-integration-test:
     docker:
     docker:
-      - image: circleci/golang:1.11.4
+      - image: circleci/golang:1.11.5
       - image: circleci/postgres:9.3-ram
       - image: circleci/postgres:9.3-ram
         environment:
         environment:
           POSTGRES_USER: grafanatest
           POSTGRES_USER: grafanatest
@@ -74,7 +74,7 @@ jobs:
 
 
   gometalinter:
   gometalinter:
     docker:
     docker:
-      - image: circleci/golang:1.11.4
+      - image: circleci/golang:1.11.5
         environment:
         environment:
           # we need CGO because of go-sqlite3
           # we need CGO because of go-sqlite3
           CGO_ENABLED: 1
           CGO_ENABLED: 1
@@ -106,7 +106,7 @@ jobs:
 
 
   test-backend:
   test-backend:
     docker:
     docker:
-      - image: circleci/golang:1.11.4
+      - image: circleci/golang:1.11.5
     working_directory: /go/src/github.com/grafana/grafana
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     steps:
       - checkout
       - checkout
@@ -116,7 +116,7 @@ jobs:
 
 
   build-all:
   build-all:
     docker:
     docker:
-     - image: grafana/build-container:1.2.2
+     - image: grafana/build-container:1.2.3
     working_directory: /go/src/github.com/grafana/grafana
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     steps:
       - checkout
       - checkout
@@ -164,7 +164,7 @@ jobs:
 
 
   build:
   build:
     docker:
     docker:
-     - image: grafana/build-container:1.2.2
+     - image: grafana/build-container:1.2.3
     working_directory: /go/src/github.com/grafana/grafana
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     steps:
       - checkout
       - checkout
@@ -233,7 +233,7 @@ jobs:
 
 
   build-enterprise:
   build-enterprise:
     docker:
     docker:
-     - image: grafana/build-container:1.2.2
+     - image: grafana/build-container:1.2.3
     working_directory: /go/src/github.com/grafana/grafana
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     steps:
       - checkout
       - checkout
@@ -265,7 +265,7 @@ jobs:
 
 
   build-all-enterprise:
   build-all-enterprise:
     docker:
     docker:
-    - image: grafana/build-container:1.2.2
+    - image: grafana/build-container:1.2.3
     working_directory: /go/src/github.com/grafana/grafana
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     steps:
     - checkout
     - checkout

+ 18 - 10
CHANGELOG.md

@@ -3,34 +3,42 @@
 ### New Features
 ### New Features
 * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
 * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
 * **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
 * **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
+* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl)
 * **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
 * **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
 
 
 ### Minor
 ### Minor
 
 
+* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
 * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
 * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
 * **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
 * **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
+* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
+* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
+* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
 * **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
 * **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
-* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
 * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
 * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
-* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
 * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
 * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
 * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
 * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
-* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
 * **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
 * **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
+* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
+* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
+* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
+* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
 * **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
 * **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
-* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
-* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
+* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
+* **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484)
+* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
+* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
 
 
 ### Bug fixes
 ### Bug fixes
 * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
 * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
 * **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
 * **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
+* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy)
 
 
 ### Breaking changes
 ### Breaking changes
-* **Text Panel**: The text panel does no longer by default allow unsantizied HTML.
-* [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags
-* they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings
-* `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable
-* `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
+* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable  `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
+* **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991)
 
 
 # 5.4.3 (2019-01-14)
 # 5.4.3 (2019-01-14)
 
 

+ 4 - 2
Dockerfile

@@ -1,5 +1,5 @@
 # Golang build container
 # Golang build container
-FROM golang:1.11.4
+FROM golang:1.11.5
 
 
 WORKDIR $GOPATH/src/github.com/grafana/grafana
 WORKDIR $GOPATH/src/github.com/grafana/grafana
 
 
@@ -19,11 +19,13 @@ COPY package.json package.json
 RUN go run build.go build
 RUN go run build.go build
 
 
 # Node build container
 # Node build container
-FROM node:8
+FROM node:10.14.2
 
 
 WORKDIR /usr/src/app/
 WORKDIR /usr/src/app/
 
 
 COPY package.json yarn.lock ./
 COPY package.json yarn.lock ./
+COPY packages packages
+
 RUN yarn install --pure-lockfile --no-progress
 RUN yarn install --pure-lockfile --no-progress
 
 
 COPY Gruntfile.js tsconfig.json tslint.json ./
 COPY Gruntfile.js tsconfig.json tslint.json ./

+ 1 - 1
appveyor.yml

@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
 environment:
 environment:
   nodejs_version: "8"
   nodejs_version: "8"
   GOPATH: C:\gopath
   GOPATH: C:\gopath
-  GOVERSION: 1.11.4
+  GOVERSION: 1.11.5
 
 
 install:
 install:
   - rmdir c:\go /s /q
   - rmdir c:\go /s /q

+ 19 - 5
conf/defaults.ini

@@ -106,6 +106,22 @@ path = grafana.db
 # For "sqlite3" only. cache mode setting used for connecting to the database
 # For "sqlite3" only. cache mode setting used for connecting to the database
 cache_mode = private
 cache_mode = private
 
 
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+login_remember_days = 7
+
+# How often should the login token be rotated. default to '10m'
+rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+delete_expired_token_after_days = 30
+
 #################################### Session #############################
 #################################### Session #############################
 [session]
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -175,11 +191,6 @@ admin_password = admin
 # used for signing
 # used for signing
 secret_key = SW2YcwTIb9zpOOhoPsMm
 secret_key = SW2YcwTIb9zpOOhoPsMm
 
 
-# Auto-login remember days
-login_remember_days = 7
-cookie_username = grafana_user
-cookie_remember_name = grafana_remember
-
 # disable gravatar profile images
 # disable gravatar profile images
 disable_gravatar = false
 disable_gravatar = false
 
 
@@ -189,6 +200,9 @@ data_source_proxy_whitelist =
 # disable protection against brute force login attempts
 # disable protection against brute force login attempts
 disable_brute_force_login_protection = false
 disable_brute_force_login_protection = false
 
 
+# set cookies as https only. default is false
+https_flag_cookies = false
+
 #################################### Snapshots ###########################
 #################################### Snapshots ###########################
 [snapshots]
 [snapshots]
 # snapshot sharing options
 # snapshot sharing options

+ 19 - 5
conf/sample.ini

@@ -102,6 +102,22 @@ log_queries =
 # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
 # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
 ;cache_mode = private
 ;cache_mode = private
 
 
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+;cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+;login_remember_days = 7
+
+# How often should the login token be rotated. default to '10'
+;rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+;delete_expired_token_after_days = 30
+
 #################################### Session ####################################
 #################################### Session ####################################
 [session]
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -162,11 +178,6 @@ log_queries =
 # used for signing
 # used for signing
 ;secret_key = SW2YcwTIb9zpOOhoPsMm
 ;secret_key = SW2YcwTIb9zpOOhoPsMm
 
 
-# Auto-login remember days
-;login_remember_days = 7
-;cookie_username = grafana_user
-;cookie_remember_name = grafana_remember
-
 # disable gravatar profile images
 # disable gravatar profile images
 ;disable_gravatar = false
 ;disable_gravatar = false
 
 
@@ -176,6 +187,9 @@ log_queries =
 # disable protection against brute force login attempts
 # disable protection against brute force login attempts
 ;disable_brute_force_login_protection = false
 ;disable_brute_force_login_protection = false
 
 
+# set cookies as https only. default is false
+;https_flag_cookies = false
+
 #################################### Snapshots ###########################
 #################################### Snapshots ###########################
 [snapshots]
 [snapshots]
 # snapshot sharing options
 # snapshot sharing options

+ 2 - 1
devenv/docker/ha_test/docker-compose.yaml

@@ -54,7 +54,8 @@ services:
       # - GF_DATABASE_SSL_MODE=disable
       # - GF_DATABASE_SSL_MODE=disable
       # - GF_SESSION_PROVIDER=postgres
       # - GF_SESSION_PROVIDER=postgres
       # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
       # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
-      - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
+      - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
+      - GF_LOGIN_ROTATE_TOKEN_MINUTES=2
     ports:
     ports:
       - 3000
       - 3000
     depends_on:
     depends_on:

+ 69 - 0
devenv/docker/loadtest/README.md

@@ -0,0 +1,69 @@
+# Grafana load test
+
+Runs load tests and checks using [k6](https://k6.io/).
+
+## Prerequisites
+
+Docker
+
+## Run
+
+Run load test for 15 minutes:
+
+```bash
+$ ./run.sh
+```
+
+Run load test for custom duration:
+
+```bash
+$ ./run.sh -d 10s
+```
+
+Example output:
+
+```bash
+
+          /\      |‾‾|  /‾‾/  /‾/
+     /\  /  \     |  |_/  /  / /
+    /  \/    \    |      |  /  ‾‾\
+   /          \   |  |‾\  \ | (_) |
+  / __________ \  |__|  \__\ \___/ .io
+
+  execution: local
+     output: -
+     script: src/auth_token_test.js
+
+    duration: 15m0s, iterations: -
+         vus: 2,     max: 2
+
+    done [==========================================================] 15m0s / 15m0s
+
+    █ user auth token test
+
+      █ user authenticates thru ui with username and password
+
+        ✓ response status is 200
+        ✓ response has cookie 'grafana_session' with 32 characters
+
+      █ batch tsdb requests
+
+        ✓ response status is 200
+
+    checks.....................: 100.00% ✓ 32844 ✗ 0
+    data_received..............: 411 MB  457 kB/s
+    data_sent..................: 12 MB   14 kB/s
+    group_duration.............: avg=95.64ms  min=16.42ms  med=94.35ms  max=307.52ms p(90)=137.78ms p(95)=146.75ms
+    http_req_blocked...........: avg=1.27ms   min=942ns    med=610.08µs max=48.32ms  p(90)=2.92ms   p(95)=4.25ms
+    http_req_connecting........: avg=1.06ms   min=0s       med=456.79µs max=47.19ms  p(90)=2.55ms   p(95)=3.78ms
+    http_req_duration..........: avg=58.16ms  min=1ms      med=52.59ms  max=293.35ms p(90)=109.53ms p(95)=120.19ms
+    http_req_receiving.........: avg=38.98µs  min=6.43µs   med=32.55µs  max=16.2ms   p(90)=64.63µs  p(95)=78.8µs
+    http_req_sending...........: avg=328.66µs min=8.09µs   med=110.77µs max=44.13ms  p(90)=552.65µs p(95)=1.09ms
+    http_req_tls_handshaking...: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s
+    http_req_waiting...........: avg=57.79ms  min=935.02µs med=52.15ms  max=293.06ms p(90)=109.04ms p(95)=119.71ms
+    http_reqs..................: 34486   38.317775/s
+    iteration_duration.........: avg=1.09s    min=1.81µs   med=1.09s    max=1.3s     p(90)=1.13s    p(95)=1.14s
+    iterations.................: 1642    1.824444/s
+    vus........................: 2       min=2   max=2
+    vus_max....................: 2       min=2   max=2
+```

+ 71 - 0
devenv/docker/loadtest/auth_token_test.js

@@ -0,0 +1,71 @@
+import { sleep, check, group } from 'k6';
+import { createClient, createBasicAuthClient } from './modules/client.js';
+import { createTestOrgIfNotExists, createTestdataDatasourceIfNotExists } from './modules/util.js';
+
+export let options = {
+  noCookiesReset: true
+};
+
+let endpoint = __ENV.URL || 'http://localhost:3000';
+const client = createClient(endpoint);
+
+export const setup = () => {
+  const basicAuthClient = createBasicAuthClient(endpoint, 'admin', 'admin');
+  const orgId = createTestOrgIfNotExists(basicAuthClient);
+  const datasourceId = createTestdataDatasourceIfNotExists(basicAuthClient);
+  client.withOrgId(orgId);
+  return {
+    orgId: orgId,
+    datasourceId: datasourceId,
+  };
+}
+
+export default (data) => {
+  group("user auth token test", () => {
+    if (__ITER === 0) {
+      group("user authenticates thru ui with username and password", () => {
+        let res = client.ui.login('admin', 'admin');
+
+        check(res, {
+          'response status is 200': (r) => r.status === 200,
+          'response has cookie \'grafana_session\' with 32 characters': (r) => r.cookies.grafana_session[0].value.length === 32,
+        });
+      });
+    }
+
+    if (__ITER !== 0) {
+      group("batch tsdb requests", () => {
+        const batchCount = 20;
+        const requests = [];
+        const payload = {
+          from: '1547765247624',
+          to: '1547768847624',
+          queries: [{
+            refId: 'A',
+            scenarioId: 'random_walk',
+            intervalMs: 10000,
+            maxDataPoints: 433,
+            datasourceId: data.datasourceId,
+          }]
+        };
+
+        requests.push({ method: 'GET', url: '/api/annotations?dashboardId=2074&from=1548078832772&to=1548082432772' });
+
+        for (let n = 0; n < batchCount; n++) {
+          requests.push({ method: 'POST', url: '/api/tsdb/query', body: payload });
+        }
+
+        let responses = client.batch(requests);
+        for (let n = 0; n < batchCount; n++) {
+          check(responses[n], {
+            'response status is 200': (r) => r.status === 200,
+          });
+        }
+      });
+    }
+  });
+
+  sleep(1)
+}
+
+export const teardown = (data) => {}

+ 187 - 0
devenv/docker/loadtest/modules/client.js

@@ -0,0 +1,187 @@
+import http from "k6/http";
+import encoding from 'k6/encoding';
+
+export const UIEndpoint = class UIEndpoint {
+  constructor(httpClient) {
+    this.httpClient = httpClient;
+  }
+
+  login(username, pwd) {
+    const payload = { user: username, password: pwd };
+    return this.httpClient.formPost('/login', payload);
+  }
+}
+
+export const DatasourcesEndpoint = class DatasourcesEndpoint {
+  constructor(httpClient) {
+    this.httpClient = httpClient;
+  }
+
+  getById(id) {
+    return this.httpClient.get(`/datasources/${id}`);
+  }
+
+  getByName(name) {
+    return this.httpClient.get(`/datasources/name/${name}`);
+  }
+
+  create(payload) {
+    return this.httpClient.post(`/datasources`, JSON.stringify(payload));
+  }
+
+  delete(id) {
+    return this.httpClient.delete(`/datasources/${id}`);
+  }
+}
+
+export const OrganizationsEndpoint = class OrganizationsEndpoint {
+  constructor(httpClient) {
+    this.httpClient = httpClient;
+  }
+
+  getById(id) {
+    return this.httpClient.get(`/orgs/${id}`);
+  }
+
+  getByName(name) {
+    return this.httpClient.get(`/orgs/name/${name}`);
+  }
+
+  create(name) {
+    let payload = {
+      name: name,
+    };
+    return this.httpClient.post(`/orgs`, JSON.stringify(payload));
+  }
+
+  delete(id) {
+    return this.httpClient.delete(`/orgs/${id}`);
+  }
+}
+
+export const GrafanaClient = class GrafanaClient {
+  constructor(httpClient) {
+    httpClient.onBeforeRequest = this.onBeforeRequest;
+    this.raw = httpClient;
+    this.ui = new UIEndpoint(httpClient);
+    this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api'));
+    this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api'));
+  }
+
+  batch(requests) {
+    return this.raw.batch(requests);
+  }
+
+  withOrgId(orgId) {
+    this.orgId = orgId;
+  }
+
+  onBeforeRequest(params) {
+    if (this.orgId && this.orgId > 0) {
+      params = params.headers || {};
+      params.headers["X-Grafana-Org-Id"] = this.orgId;
+    }
+  }
+}
+
+export const BaseClient = class BaseClient {
+  constructor(url, subUrl) {
+    if (url.endsWith('/')) {
+      url = url.substring(0, url.length - 1);
+    }
+
+    if (subUrl.endsWith('/')) {
+      subUrl = subUrl.substring(0, subUrl.length - 1);
+    }
+
+    this.url = url + subUrl;
+    this.onBeforeRequest = () => {};
+  }
+
+  withUrl(subUrl) {
+    let c = new BaseClient(this.url,  subUrl);
+    c.onBeforeRequest = this.onBeforeRequest;
+    return c;
+  }
+
+  beforeRequest(params) {
+
+  }
+
+  get(url, params) {
+    params = params || {};
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.get(this.url + url, params);
+  }
+
+  formPost(url, body, params) {
+    params = params || {};
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.post(this.url + url, body, params);
+  }
+
+  post(url, body, params) {
+    params = params || {};
+    params.headers = params.headers || {};
+    params.headers['Content-Type'] = 'application/json';
+
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.post(this.url + url, body, params);
+  }
+
+  delete(url, params) {
+    params = params || {};
+    this.beforeRequest(params);
+    this.onBeforeRequest(params);
+    return http.del(this.url + url, null, params);
+  }
+
+  batch(requests) {
+    for (let n = 0; n < requests.length; n++) {
+      let params = requests[n].params || {};
+      params.headers = params.headers || {};
+      params.headers['Content-Type'] = 'application/json';
+      this.beforeRequest(params);
+      this.onBeforeRequest(params);
+      requests[n].params = params;
+      requests[n].url = this.url + requests[n].url;
+      if (requests[n].body) {
+        requests[n].body = JSON.stringify(requests[n].body);
+      }
+    }
+
+    return http.batch(requests);
+  }
+}
+
+export class BasicAuthClient extends BaseClient {
+  constructor(url, subUrl, username, password) {
+    super(url, subUrl);
+    this.username = username;
+    this.password = password;
+  }
+
+  withUrl(subUrl) {
+    let c = new BasicAuthClient(this.url,  subUrl, this.username, this.password);
+    c.onBeforeRequest = this.onBeforeRequest;
+    return c;
+  }
+
+  beforeRequest(params) {
+    params = params || {};
+    params.headers = params.headers || {};
+    let token = `${this.username}:${this.password}`;
+    params.headers['Authorization'] = `Basic ${encoding.b64encode(token)}`;
+  }
+}
+
+export const createClient = (url) => {
+  return new GrafanaClient(new BaseClient(url, ''));
+}
+
+export const createBasicAuthClient = (url, username, password) => {
+  return new GrafanaClient(new BasicAuthClient(url, '', username, password));
+}

+ 35 - 0
devenv/docker/loadtest/modules/util.js

@@ -0,0 +1,35 @@
+export const createTestOrgIfNotExists = (client) => {
+  let orgId = 0;
+  let res = client.orgs.getByName('k6');
+  if (res.status === 404) {
+    res = client.orgs.create('k6');
+    if (res.status !== 200) {
+      throw new Error('Expected 200 response status when creating org');
+    }
+    orgId = res.json().orgId;
+  } else {
+    orgId = res.json().id;
+  }
+
+  client.withOrgId(orgId);
+  return orgId;
+}
+
+export const createTestdataDatasourceIfNotExists = (client) => {
+  const payload = {
+    access: 'proxy',
+    isDefault: false,
+    name: 'k6-testdata',
+    type: 'testdata',
+  };
+
+  let res = client.datasources.getByName(payload.name);
+  if (res.status === 404) {
+    res = client.datasources.create(payload);
+    if (res.status !== 200) {
+      throw new Error('Expected 200 response status when creating datasource');
+    }
+  }
+
+  return res.json().id;
+}

+ 24 - 0
devenv/docker/loadtest/run.sh

@@ -0,0 +1,24 @@
+#/bin/bash
+
+PWD=$(pwd)
+
+run() {
+  duration='15m'
+  url='http://localhost:3000'
+
+  while getopts ":d:u:" o; do
+    case "${o}" in
+				d)
+            duration=${OPTARG}
+            ;;
+        u)
+            url=${OPTARG}
+            ;;
+    esac
+	done
+	shift $((OPTIND-1))
+
+  docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus 2 --duration $duration src/auth_token_test.js
+}
+
+run "$@"

+ 26 - 1
docs/sources/http_api/other.md

@@ -82,4 +82,29 @@ HTTP/1.1 200
 Content-Type: application/json
 Content-Type: application/json
 
 
 {"message": "Logged in"}
 {"message": "Logged in"}
-```
+```
+
+# Health API
+
+## Returns health information about Grafana
+
+`GET /api/health`
+
+**Example Request**
+
+```http
+GET /api/health
+Accept: application/json
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200 OK
+
+{
+  "commit": "087143285",
+  "database": "ok",
+  "version": "5.1.3"
+}
+```

+ 23 - 9
packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx

@@ -7,10 +7,10 @@ interface Props {
   autoHide?: boolean;
   autoHide?: boolean;
   autoHideTimeout?: number;
   autoHideTimeout?: number;
   autoHideDuration?: number;
   autoHideDuration?: number;
-  autoMaxHeight?: string;
+  autoHeightMax?: string;
   hideTracksWhenNotNeeded?: boolean;
   hideTracksWhenNotNeeded?: boolean;
   scrollTop?: number;
   scrollTop?: number;
-  setScrollTop: (value: React.MouseEvent<HTMLElement>) => void;
+  setScrollTop: (event: any) => void;
   autoHeightMin?: number | string;
   autoHeightMin?: number | string;
 }
 }
 
 
@@ -20,13 +20,13 @@ interface Props {
 export class CustomScrollbar extends PureComponent<Props> {
 export class CustomScrollbar extends PureComponent<Props> {
   static defaultProps: Partial<Props> = {
   static defaultProps: Partial<Props> = {
     customClassName: 'custom-scrollbars',
     customClassName: 'custom-scrollbars',
-    autoHide: true,
+    autoHide: false,
     autoHideTimeout: 200,
     autoHideTimeout: 200,
     autoHideDuration: 200,
     autoHideDuration: 200,
-    autoMaxHeight: '100%',
-    hideTracksWhenNotNeeded: false,
     setScrollTop: () => {},
     setScrollTop: () => {},
-    autoHeightMin: '0'
+    hideTracksWhenNotNeeded: false,
+    autoHeightMin: '0',
+    autoHeightMax: '100%',
   };
   };
 
 
   private ref: React.RefObject<Scrollbars>;
   private ref: React.RefObject<Scrollbars>;
@@ -45,7 +45,7 @@ export class CustomScrollbar extends PureComponent<Props> {
       } else {
       } else {
         ref.scrollTop(this.props.scrollTop);
         ref.scrollTop(this.props.scrollTop);
       }
       }
-   }
+    }
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
@@ -57,16 +57,30 @@ export class CustomScrollbar extends PureComponent<Props> {
   }
   }
 
 
   render() {
   render() {
-    const { customClassName, children, autoMaxHeight } = this.props;
+    const {
+      customClassName,
+      children,
+      autoHeightMax,
+      autoHeightMin,
+      setScrollTop,
+      autoHide,
+      autoHideTimeout,
+      hideTracksWhenNotNeeded,
+    } = this.props;
 
 
     return (
     return (
       <Scrollbars
       <Scrollbars
         ref={this.ref}
         ref={this.ref}
         className={customClassName}
         className={customClassName}
+        onScroll={setScrollTop}
         autoHeight={true}
         autoHeight={true}
+        autoHide={autoHide}
+        autoHideTimeout={autoHideTimeout}
+        hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
         // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
         // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
         // Before these where set to inhert but that caused problems with cut of legends in firefox
         // Before these where set to inhert but that caused problems with cut of legends in firefox
-        autoHeightMax={autoMaxHeight}
+        autoHeightMax={autoHeightMax}
+        autoHeightMin={autoHeightMin}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}

+ 2 - 2
packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap

@@ -7,7 +7,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
     Object {
     Object {
       "height": "auto",
       "height": "auto",
       "maxHeight": "100%",
       "maxHeight": "100%",
-      "minHeight": 0,
+      "minHeight": "0",
       "overflow": "hidden",
       "overflow": "hidden",
       "position": "relative",
       "position": "relative",
       "width": "100%",
       "width": "100%",
@@ -24,7 +24,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
         "marginBottom": 0,
         "marginBottom": 0,
         "marginRight": 0,
         "marginRight": 0,
         "maxHeight": "calc(100% + 0px)",
         "maxHeight": "calc(100% + 0px)",
-        "minHeight": 0,
+        "minHeight": "calc(0 + 0px)",
         "overflow": "scroll",
         "overflow": "scroll",
         "position": "relative",
         "position": "relative",
         "right": undefined,
         "right": undefined,

+ 1 - 1
packages/grafana-ui/src/components/Select/Select.tsx

@@ -61,7 +61,7 @@ interface AsyncProps {
 export const MenuList = (props: any) => {
 export const MenuList = (props: any) => {
   return (
   return (
     <components.MenuList {...props}>
     <components.MenuList {...props}>
-      <CustomScrollbar autoHide={false} autoMaxHeight="inherit">{props.children}</CustomScrollbar>
+      <CustomScrollbar autoHide={false} autoHeightMax="inherit">{props.children}</CustomScrollbar>
     </components.MenuList>
     </components.MenuList>
   );
   );
 };
 };

+ 6 - 6
pkg/api/api.go

@@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() {
 
 
 	// not logged in views
 	// not logged in views
 	r.Get("/", reqSignedIn, hs.Index)
 	r.Get("/", reqSignedIn, hs.Index)
-	r.Get("/logout", Logout)
-	r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
-	r.Get("/login/:name", quota("session"), OAuthLogin)
+	r.Get("/logout", hs.Logout)
+	r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost))
+	r.Get("/login/:name", quota("session"), hs.OAuthLogin)
 	r.Get("/login", hs.LoginView)
 	r.Get("/login", hs.LoginView)
 	r.Get("/invite/:code", hs.Index)
 	r.Get("/invite/:code", hs.Index)
 
 
@@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/signup", hs.Index)
 	r.Get("/signup", hs.Index)
 	r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
 	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", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
-	r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
+	r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2))
 
 
 	// invited
 	// invited
 	r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
 	r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
-	r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
+	r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite))
 
 
 	// reset password
 	// reset password
 	r.Get("/user/password/send-reset-email", hs.Index)
 	r.Get("/user/password/send-reset-email", hs.Index)
@@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
 	r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
 
 
 	// api renew session based on remember cookie
 	// api renew session based on remember cookie
-	r.Get("/api/login/ping", quota("session"), LoginAPIPing)
+	r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
 
 
 	// authed api
 	// authed api
 	r.Group("/api", func(apiRoute routing.RouteRegister) {
 	r.Group("/api", func(apiRoute routing.RouteRegister) {

+ 32 - 10
pkg/api/common_test.go

@@ -5,7 +5,6 @@ import (
 	"net/http/httptest"
 	"net/http/httptest"
 	"path/filepath"
 	"path/filepath"
 
 
-	"github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
@@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
 }
 }
 
 
 type scenarioContext struct {
 type scenarioContext struct {
-	m              *macaron.Macaron
-	context        *m.ReqContext
-	resp           *httptest.ResponseRecorder
-	handlerFunc    handlerFunc
-	defaultHandler macaron.Handler
-	req            *http.Request
-	url            string
+	m                    *macaron.Macaron
+	context              *m.ReqContext
+	resp                 *httptest.ResponseRecorder
+	handlerFunc          handlerFunc
+	defaultHandler       macaron.Handler
+	req                  *http.Request
+	url                  string
+	userAuthTokenService *fakeUserAuthTokenService
 }
 }
 
 
 func (sc *scenarioContext) exec() {
 func (sc *scenarioContext) exec() {
@@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
 		Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 		Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 	}))
 	}))
 
 
-	sc.m.Use(middleware.GetContextHandler())
-	sc.m.Use(middleware.Sessioner(&session.Options{}, 0))
+	sc.userAuthTokenService = newFakeUserAuthTokenService()
+	sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
 
 
 	return sc
 	return sc
 }
 }
+
+type fakeUserAuthTokenService struct {
+	initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+}
+
+func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
+	return &fakeUserAuthTokenService{
+		initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
+			return false
+		},
+	}
+}
+
+func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
+	return s.initContextWithTokenProvider(ctx, orgID)
+}
+
+func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
+	return nil
+}
+
+func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}

+ 1 - 1
pkg/api/dashboard.go

@@ -336,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
 		"id":   123123,
 		"id":   123123,
 		"gridPos": map[string]interface{}{
 		"gridPos": map[string]interface{}{
 			"x": 0,
 			"x": 0,
-			"y": 0,
+			"y": 3,
 			"w": 24,
 			"w": 24,
 			"h": 4,
 			"h": 4,
 		},
 		},

+ 17 - 16
pkg/api/http_server.go

@@ -11,14 +11,8 @@ import (
 	"path"
 	"path"
 	"time"
 	"time"
 
 
-	"github.com/grafana/grafana/pkg/api/routing"
-	"github.com/prometheus/client_golang/prometheus"
-
-	"github.com/prometheus/client_golang/prometheus/promhttp"
-
-	macaron "gopkg.in/macaron.v1"
-
 	"github.com/grafana/grafana/pkg/api/live"
 	"github.com/grafana/grafana/pkg/api/live"
+	"github.com/grafana/grafana/pkg/api/routing"
 	httpstatic "github.com/grafana/grafana/pkg/api/static"
 	httpstatic "github.com/grafana/grafana/pkg/api/static"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -27,11 +21,16 @@ import (
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/services/auth"
 	"github.com/grafana/grafana/pkg/services/cache"
 	"github.com/grafana/grafana/pkg/services/cache"
 	"github.com/grafana/grafana/pkg/services/datasources"
 	"github.com/grafana/grafana/pkg/services/datasources"
 	"github.com/grafana/grafana/pkg/services/hooks"
 	"github.com/grafana/grafana/pkg/services/hooks"
 	"github.com/grafana/grafana/pkg/services/rendering"
 	"github.com/grafana/grafana/pkg/services/rendering"
+	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+	macaron "gopkg.in/macaron.v1"
 )
 )
 
 
 func init() {
 func init() {
@@ -49,13 +48,14 @@ type HTTPServer struct {
 	streamManager *live.StreamManager
 	streamManager *live.StreamManager
 	httpSrv       *http.Server
 	httpSrv       *http.Server
 
 
-	RouteRegister   routing.RouteRegister    `inject:""`
-	Bus             bus.Bus                  `inject:""`
-	RenderService   rendering.Service        `inject:""`
-	Cfg             *setting.Cfg             `inject:""`
-	HooksService    *hooks.HooksService      `inject:""`
-	CacheService    *cache.CacheService      `inject:""`
-	DatasourceCache datasources.CacheService `inject:""`
+	RouteRegister    routing.RouteRegister     `inject:""`
+	Bus              bus.Bus                   `inject:""`
+	RenderService    rendering.Service         `inject:""`
+	Cfg              *setting.Cfg              `inject:""`
+	HooksService     *hooks.HooksService       `inject:""`
+	CacheService     *cache.CacheService       `inject:""`
+	DatasourceCache  datasources.CacheService  `inject:""`
+	AuthTokenService auth.UserAuthTokenService `inject:""`
 }
 }
 
 
 func (hs *HTTPServer) Init() error {
 func (hs *HTTPServer) Init() error {
@@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
 	hs.macaron = hs.newMacaron()
 	hs.macaron = hs.newMacaron()
 	hs.registerRoutes()
 	hs.registerRoutes()
 
 
+	session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
+
 	return nil
 	return nil
 }
 }
 
 
@@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
 
 
 	m.Use(hs.healthHandler)
 	m.Use(hs.healthHandler)
 	m.Use(hs.metricsEndpoint)
 	m.Use(hs.metricsEndpoint)
-	m.Use(middleware.GetContextHandler())
-	m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
+	m.Use(middleware.GetContextHandler(hs.AuthTokenService))
 	m.Use(middleware.OrgRedirect())
 	m.Use(middleware.OrgRedirect())
 
 
 	// needs to be after context handler
 	// needs to be after context handler

+ 59 - 67
pkg/api/login.go

@@ -1,6 +1,8 @@
 package api
 package api
 
 
 import (
 import (
+	"encoding/hex"
+	"net/http"
 	"net/url"
 	"net/url"
 
 
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/api/dtos"
@@ -9,12 +11,13 @@ import (
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	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/setting"
+	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
 const (
 const (
-	ViewIndex = "index"
+	ViewIndex            = "index"
+	LoginErrorCookieName = "login_error"
 )
 )
 
 
 func (hs *HTTPServer) LoginView(c *m.ReqContext) {
 func (hs *HTTPServer) LoginView(c *m.ReqContext) {
@@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
 	viewData.Settings["loginHint"] = setting.LoginHint
 	viewData.Settings["loginHint"] = setting.LoginHint
 	viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
 	viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
 
 
-	if loginError, ok := c.Session.Get("loginError").(string); ok {
-		c.Session.Delete("loginError")
+	if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
+		deleteCookie(c, LoginErrorCookieName)
 		viewData.Settings["loginError"] = loginError
 		viewData.Settings["loginError"] = loginError
 	}
 	}
 
 
@@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
 		return
 		return
 	}
 	}
 
 
-	if !tryLoginUsingRememberCookie(c) {
+	if !c.IsSignedIn {
 		c.HTML(200, ViewIndex, viewData)
 		c.HTML(200, ViewIndex, viewData)
 		return
 		return
 	}
 	}
@@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool {
 	return false
 	return false
 }
 }
 
 
-func tryLoginUsingRememberCookie(c *m.ReqContext) bool {
-	// Check auto-login.
-	uname := c.GetCookie(setting.CookieUserName)
-	if len(uname) == 0 {
-		return false
-	}
-
-	isSucceed := false
-	defer func() {
-		if !isSucceed {
-			log.Trace("auto-login cookie cleared: %s", uname)
-			c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
-			c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
-			return
-		}
-	}()
-
-	userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname}
-	if err := bus.Dispatch(&userQuery); err != nil {
-		return false
-	}
-
-	user := userQuery.Result
-
-	// validate remember me cookie
-	signingKey := user.Rands + user.Password
-	if len(signingKey) < 10 {
-		c.Logger.Error("Invalid user signingKey")
-		return false
+func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
+	if c.IsSignedIn || c.IsAnonymous {
+		return JSON(200, "Logged in")
 	}
 	}
 
 
-	if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login {
-		return false
-	}
-
-	isSucceed = true
-	loginUserWithUser(user, c)
-	return true
+	return Error(401, "Unauthorized", nil)
 }
 }
 
 
-func LoginAPIPing(c *m.ReqContext) {
-	if !tryLoginUsingRememberCookie(c) {
-		c.JsonApiErr(401, "Unauthorized", nil)
-		return
-	}
-
-	c.JsonOK("Logged in")
-}
-
-func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
+func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
 	if setting.DisableLoginForm {
 	if setting.DisableLoginForm {
 		return Error(401, "Login is disabled", nil)
 		return Error(401, "Login is disabled", nil)
 	}
 	}
@@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
 
 
 	user := authQuery.User
 	user := authQuery.User
 
 
-	loginUserWithUser(user, c)
+	hs.loginUserWithUser(user, c)
 
 
 	result := map[string]interface{}{
 	result := map[string]interface{}{
 		"message": "Logged in",
 		"message": "Logged in",
@@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
 	return JSON(200, result)
 	return JSON(200, result)
 }
 }
 
 
-func loginUserWithUser(user *m.User, c *m.ReqContext) {
+func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
 	if user == nil {
 	if user == nil {
-		log.Error(3, "User login with nil user")
+		hs.log.Error("User login with nil user")
 	}
 	}
 
 
-	c.Resp.Header().Del("Set-Cookie")
-
-	days := 86400 * setting.LogInRememberDays
-	if days > 0 {
-		c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/")
-		c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/")
+	err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
+	if err != nil {
+		hs.log.Error("User auth hook failed", "error", err)
 	}
 	}
-
-	c.Session.RegenerateId(c.Context)
-	c.Session.Set(session.SESS_KEY_USERID, user.Id)
 }
 }
 
 
-func Logout(c *m.ReqContext) {
-	c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
-	c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
-	c.Session.Destory(c.Context)
+func (hs *HTTPServer) Logout(c *m.ReqContext) {
+	hs.AuthTokenService.UserSignedOutHook(c)
+
 	if setting.SignoutRedirectUrl != "" {
 	if setting.SignoutRedirectUrl != "" {
 		c.Redirect(setting.SignoutRedirectUrl)
 		c.Redirect(setting.SignoutRedirectUrl)
 	} else {
 	} else {
 		c.Redirect(setting.AppSubUrl + "/login")
 		c.Redirect(setting.AppSubUrl + "/login")
 	}
 	}
 }
 }
+
+func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) {
+	cookie := ctx.GetCookie(cookieName)
+	if cookie == "" {
+		return "", false
+	}
+
+	decoded, err := hex.DecodeString(cookie)
+	if err != nil {
+		return "", false
+	}
+
+	decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey)
+	return string(decryptedError), err == nil
+}
+
+func deleteCookie(ctx *m.ReqContext, cookieName string) {
+	ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/")
+}
+
+func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error {
+	encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey)
+	if err != nil {
+		return err
+	}
+
+	http.SetCookie(ctx.Resp, &http.Cookie{
+		Name:     cookieName,
+		MaxAge:   60,
+		Value:    hex.EncodeToString(encryptedError),
+		HttpOnly: true,
+		Path:     setting.AppSubUrl + "/",
+		Secure:   hs.Cfg.SecurityHTTPSCookies,
+	})
+
+	return nil
+}

+ 49 - 16
pkg/api/login_oauth.go

@@ -3,9 +3,11 @@ package api
 import (
 import (
 	"context"
 	"context"
 	"crypto/rand"
 	"crypto/rand"
+	"crypto/sha256"
 	"crypto/tls"
 	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509"
 	"encoding/base64"
 	"encoding/base64"
+	"encoding/hex"
 	"fmt"
 	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"net/http"
 	"net/http"
@@ -18,12 +20,14 @@ import (
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	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/setting"
 	"github.com/grafana/grafana/pkg/social"
 	"github.com/grafana/grafana/pkg/social"
 )
 )
 
 
-var oauthLogger = log.New("oauth")
+var (
+	oauthLogger          = log.New("oauth")
+	OauthStateCookieName = "oauth_state"
+)
 
 
 func GenStateString() string {
 func GenStateString() string {
 	rnd := make([]byte, 32)
 	rnd := make([]byte, 32)
@@ -31,7 +35,7 @@ func GenStateString() string {
 	return base64.URLEncoding.EncodeToString(rnd)
 	return base64.URLEncoding.EncodeToString(rnd)
 }
 }
 
 
-func OAuthLogin(ctx *m.ReqContext) {
+func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
 	if setting.OAuthService == nil {
 	if setting.OAuthService == nil {
 		ctx.Handle(404, "OAuth not enabled", nil)
 		ctx.Handle(404, "OAuth not enabled", nil)
 		return
 		return
@@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) {
 	if errorParam != "" {
 	if errorParam != "" {
 		errorDesc := ctx.Query("error_description")
 		errorDesc := ctx.Query("error_description")
 		oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
 		oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
-		redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
+		hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
 		return
 		return
 	}
 	}
 
 
 	code := ctx.Query("code")
 	code := ctx.Query("code")
 	if code == "" {
 	if code == "" {
 		state := GenStateString()
 		state := GenStateString()
-		ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state)
+		hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
+		hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60)
 		if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
 		if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
 			ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
 			ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
 		} else {
 		} else {
@@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) {
 		return
 		return
 	}
 	}
 
 
-	savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string)
-	if !ok {
+	cookieState := ctx.GetCookie(OauthStateCookieName)
+
+	// delete cookie
+	ctx.Resp.Header().Del("Set-Cookie")
+	hs.deleteCookie(ctx.Resp, OauthStateCookieName)
+
+	if cookieState == "" {
 		ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
 		ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
 		return
 		return
 	}
 	}
 
 
-	queryState := ctx.Query("state")
-	if savedState != queryState {
+	queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret)
+	oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState)
+	if cookieState != queryState {
 		ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
 		ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
 		return
 		return
 	}
 	}
@@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) {
 	userInfo, err := connect.UserInfo(client, token)
 	userInfo, err := connect.UserInfo(client, token)
 	if err != nil {
 	if err != nil {
 		if sErr, ok := err.(*social.Error); ok {
 		if sErr, ok := err.(*social.Error); ok {
-			redirectWithError(ctx, sErr)
+			hs.redirectWithError(ctx, sErr)
 		} else {
 		} else {
 			ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
 			ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
 		}
 		}
@@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) {
 
 
 	// validate that we got at least an email address
 	// validate that we got at least an email address
 	if userInfo.Email == "" {
 	if userInfo.Email == "" {
-		redirectWithError(ctx, login.ErrNoEmail)
+		hs.redirectWithError(ctx, login.ErrNoEmail)
 		return
 		return
 	}
 	}
 
 
 	// validate that the email is allowed to login to grafana
 	// validate that the email is allowed to login to grafana
 	if !connect.IsEmailAllowed(userInfo.Email) {
 	if !connect.IsEmailAllowed(userInfo.Email) {
-		redirectWithError(ctx, login.ErrEmailNotAllowed)
+		hs.redirectWithError(ctx, login.ErrEmailNotAllowed)
 		return
 		return
 	}
 	}
 
 
@@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) {
 		ExternalUser:  extUser,
 		ExternalUser:  extUser,
 		SignupAllowed: connect.IsSignupAllowed(),
 		SignupAllowed: connect.IsSignupAllowed(),
 	}
 	}
+
 	err = bus.Dispatch(cmd)
 	err = bus.Dispatch(cmd)
 	if err != nil {
 	if err != nil {
-		redirectWithError(ctx, err)
+		hs.redirectWithError(ctx, err)
 		return
 		return
 	}
 	}
 
 
 	// login
 	// login
-	loginUserWithUser(cmd.Result, ctx)
+	hs.loginUserWithUser(cmd.Result, ctx)
 
 
 	metrics.M_Api_Login_OAuth.Inc()
 	metrics.M_Api_Login_OAuth.Inc()
 
 
@@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) {
 	ctx.Redirect(setting.AppSubUrl + "/")
 	ctx.Redirect(setting.AppSubUrl + "/")
 }
 }
 
 
-func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
+func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) {
+	hs.writeCookie(w, name, "", -1)
+}
+
+func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     name,
+		MaxAge:   maxAge,
+		Value:    value,
+		HttpOnly: true,
+		Path:     setting.AppSubUrl + "/",
+		Secure:   hs.Cfg.SecurityHTTPSCookies,
+	})
+}
+
+func hashStatecode(code, seed string) string {
+	hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed))
+	return hex.EncodeToString(hashBytes[:])
+}
+
+func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
 	ctx.Logger.Error(err.Error(), v...)
 	ctx.Logger.Error(err.Error(), v...)
-	ctx.Session.Set("loginError", err.Error())
+	hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60)
+
 	ctx.Redirect(setting.AppSubUrl + "/login")
 	ctx.Redirect(setting.AppSubUrl + "/login")
 }
 }

+ 2 - 2
pkg/api/org_invite.go

@@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response {
 	})
 	})
 }
 }
 
 
-func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
+func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
 	query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
 	query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
 
 
 	if err := bus.Dispatch(&query); err != nil {
 	if err := bus.Dispatch(&query); err != nil {
@@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res
 		return rsp
 		return rsp
 	}
 	}
 
 
-	loginUserWithUser(user, c)
+	hs.loginUserWithUser(user, c)
 
 
 	metrics.M_Api_User_SignUpCompleted.Inc()
 	metrics.M_Api_User_SignUpCompleted.Inc()
 	metrics.M_Api_User_SignUpInvite.Inc()
 	metrics.M_Api_User_SignUpInvite.Inc()

+ 2 - 2
pkg/api/signup.go

@@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response {
 	return JSON(200, util.DynMap{"status": "SignUpCreated"})
 	return JSON(200, util.DynMap{"status": "SignUpCreated"})
 }
 }
 
 
-func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
+func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
 	if !setting.AllowUserSignUp {
 	if !setting.AllowUserSignUp {
 		return Error(401, "User signup is disabled", nil)
 		return Error(401, "User signup is disabled", nil)
 	}
 	}
@@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
 		apiResponse["code"] = "redirect-to-select-org"
 		apiResponse["code"] = "redirect-to-select-org"
 	}
 	}
 
 
-	loginUserWithUser(user, c)
+	hs.loginUserWithUser(user, c)
 	metrics.M_Api_User_SignUpCompleted.Inc()
 	metrics.M_Api_User_SignUpCompleted.Inc()
 
 
 	return JSON(200, apiResponse)
 	return JSON(200, apiResponse)

+ 0 - 11
pkg/middleware/auth.go

@@ -7,7 +7,6 @@ import (
 	"gopkg.in/macaron.v1"
 	"gopkg.in/macaron.v1"
 
 
 	m "github.com/grafana/grafana/pkg/models"
 	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/setting"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
 )
 )
@@ -17,16 +16,6 @@ type AuthOptions struct {
 	ReqSignedIn     bool
 	ReqSignedIn     bool
 }
 }
 
 
-func getRequestUserId(c *m.ReqContext) int64 {
-	userID := c.Session.Get(session.SESS_KEY_USERID)
-
-	if userID != nil {
-		return userID.(int64)
-	}
-
-	return 0
-}
-
 func getApiKey(c *m.ReqContext) string {
 func getApiKey(c *m.ReqContext) string {
 	header := c.Req.Header.Get("Authorization")
 	header := c.Req.Header.Get("Authorization")
 	parts := strings.SplitN(header, " ", 2)
 	parts := strings.SplitN(header, " ", 2)

+ 19 - 1
pkg/middleware/auth_proxy.go

@@ -16,7 +16,9 @@ import (
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
-var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
+var (
+	AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
+)
 
 
 func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 	if !setting.AuthProxyEnabled {
 	if !setting.AuthProxyEnabled {
@@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 		return false
 		return false
 	}
 	}
 
 
+	defer func() {
+		if err := ctx.Session.Release(); err != nil {
+			ctx.Logger.Error("failed to save session data", "error", err)
+		}
+	}()
+
 	query := &m.GetSignedInUserQuery{OrgId: orgID}
 	query := &m.GetSignedInUserQuery{OrgId: orgID}
 
 
 	// if this session has already been authenticated by authProxy just load the user
 	// if this session has already been authenticated by authProxy just load the user
@@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error {
 	return nil
 	return nil
 }
 }
 
 
+func getRequestUserId(c *m.ReqContext) int64 {
+	userID := c.Session.Get(session.SESS_KEY_USERID)
+
+	if userID != nil {
+		return userID.(int64)
+	}
+
+	return 0
+}
+
 func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
 func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
 	if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
 	if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
 		return nil
 		return nil

+ 5 - 28
pkg/middleware/middleware.go

@@ -3,15 +3,15 @@ package middleware
 import (
 import (
 	"strconv"
 	"strconv"
 
 
-	"gopkg.in/macaron.v1"
-
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/apikeygen"
 	"github.com/grafana/grafana/pkg/components/apikeygen"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/auth"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
+	macaron "gopkg.in/macaron.v1"
 )
 )
 
 
 var (
 var (
@@ -21,12 +21,12 @@ var (
 	ReqOrgAdmin     = RoleAuth(m.ROLE_ADMIN)
 	ReqOrgAdmin     = RoleAuth(m.ROLE_ADMIN)
 )
 )
 
 
-func GetContextHandler() macaron.Handler {
+func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
 	return func(c *macaron.Context) {
 	return func(c *macaron.Context) {
 		ctx := &m.ReqContext{
 		ctx := &m.ReqContext{
 			Context:        c,
 			Context:        c,
 			SignedInUser:   &m.SignedInUser{},
 			SignedInUser:   &m.SignedInUser{},
-			Session:        session.GetSession(),
+			Session:        session.GetSession(), // should only be used by auth_proxy
 			IsSignedIn:     false,
 			IsSignedIn:     false,
 			AllowAnonymous: false,
 			AllowAnonymous: false,
 			SkipCache:      false,
 			SkipCache:      false,
@@ -49,7 +49,7 @@ func GetContextHandler() macaron.Handler {
 		case initContextWithApiKey(ctx):
 		case initContextWithApiKey(ctx):
 		case initContextWithBasicAuth(ctx, orgId):
 		case initContextWithBasicAuth(ctx, orgId):
 		case initContextWithAuthProxy(ctx, orgId):
 		case initContextWithAuthProxy(ctx, orgId):
-		case initContextWithUserSessionCookie(ctx, orgId):
+		case ats.InitContextWithToken(ctx, orgId):
 		case initContextWithAnonymousUser(ctx):
 		case initContextWithAnonymousUser(ctx):
 		}
 		}
 
 
@@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
 	return true
 	return true
 }
 }
 
 
-func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool {
-	// initialize session
-	if err := ctx.Session.Start(ctx.Context); err != nil {
-		ctx.Logger.Error("Failed to start session", "error", err)
-		return false
-	}
-
-	var userId int64
-	if userId = getRequestUserId(ctx); userId == 0 {
-		return false
-	}
-
-	query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId}
-	if err := bus.Dispatch(&query); err != nil {
-		ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err)
-		return false
-	}
-
-	ctx.SignedInUser = query.Result
-	ctx.IsSignedIn = true
-	return true
-}
-
 func initContextWithApiKey(ctx *m.ReqContext) bool {
 func initContextWithApiKey(ctx *m.ReqContext) bool {
 	var keyString string
 	var keyString string
 	if keyString = getApiKey(ctx); keyString == "" {
 	if keyString = getApiKey(ctx); keyString == "" {

+ 48 - 30
pkg/middleware/middleware_test.go

@@ -7,7 +7,7 @@ import (
 	"path/filepath"
 	"path/filepath"
 	"testing"
 	"testing"
 
 
-	ms "github.com/go-macaron/session"
+	msession "github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/services/session"
@@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) {
 			So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
 			So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
 		})
 		})
 
 
-		middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
-			sc.fakeReq("GET", "/").exec()
-			So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
-		})
-
 		middlewareScenario("Invalid api key", func(sc *scenarioContext) {
 		middlewareScenario("Invalid api key", func(sc *scenarioContext) {
 			sc.apiKey = "invalid_key_test"
 			sc.apiKey = "invalid_key_test"
 			sc.fakeReq("GET", "/").exec()
 			sc.fakeReq("GET", "/").exec()
@@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
-		middlewareScenario("UserId in session", func(sc *scenarioContext) {
-
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
-
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
-				return nil
-			})
+		middlewareScenario("Auth token service", func(sc *scenarioContext) {
+			var wasCalled bool
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				wasCalled = true
+				return false
+			}
 
 
 			sc.fakeReq("GET", "/").exec()
 			sc.fakeReq("GET", "/").exec()
 
 
-			Convey("should init context with user info", func() {
-				So(sc.context.IsSignedIn, ShouldBeTrue)
-				So(sc.context.UserId, ShouldEqual, 12)
+			Convey("should call middleware", func() {
+				So(wasCalled, ShouldBeTrue)
 			})
 			})
 		})
 		})
 
 
@@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) {
 				return nil
 				return nil
 			})
 			})
 
 
+			setting.SessionOptions = msession.Options{}
 			sc.fakeReq("GET", "/")
 			sc.fakeReq("GET", "/")
 			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
 			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
 			sc.exec()
 			sc.exec()
@@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
 
 
 		sc := &scenarioContext{}
 		sc := &scenarioContext{}
+
 		viewsPath, _ := filepath.Abs("../../public/views")
 		viewsPath, _ := filepath.Abs("../../public/views")
 
 
 		sc.m = macaron.New()
 		sc.m = macaron.New()
@@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 		}))
 		}))
 
 
-		sc.m.Use(GetContextHandler())
+		session.Init(&msession.Options{}, 0)
+		sc.userAuthTokenService = newFakeUserAuthTokenService()
+		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		// mock out gc goroutine
 		// mock out gc goroutine
 		session.StartSessionGC = func() {}
 		session.StartSessionGC = func() {}
-		sc.m.Use(Sessioner(&ms.Options{}, 0))
+		setting.SessionOptions = msession.Options{}
+
 		sc.m.Use(OrgRedirect())
 		sc.m.Use(OrgRedirect())
 		sc.m.Use(AddDefaultResponseHeaders())
 		sc.m.Use(AddDefaultResponseHeaders())
 
 
@@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 }
 }
 
 
 type scenarioContext struct {
 type scenarioContext struct {
-	m              *macaron.Macaron
-	context        *m.ReqContext
-	resp           *httptest.ResponseRecorder
-	apiKey         string
-	authHeader     string
-	respJson       map[string]interface{}
-	handlerFunc    handlerFunc
-	defaultHandler macaron.Handler
-	url            string
+	m                    *macaron.Macaron
+	context              *m.ReqContext
+	resp                 *httptest.ResponseRecorder
+	apiKey               string
+	authHeader           string
+	respJson             map[string]interface{}
+	handlerFunc          handlerFunc
+	defaultHandler       macaron.Handler
+	url                  string
+	userAuthTokenService *fakeUserAuthTokenService
 
 
 	req *http.Request
 	req *http.Request
 }
 }
@@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() {
 
 
 type scenarioFunc func(c *scenarioContext)
 type scenarioFunc func(c *scenarioContext)
 type handlerFunc func(c *m.ReqContext)
 type handlerFunc func(c *m.ReqContext)
+
+type fakeUserAuthTokenService struct {
+	initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+}
+
+func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
+	return &fakeUserAuthTokenService{
+		initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
+			return false
+		},
+	}
+}
+
+func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
+	return s.initContextWithTokenProvider(ctx, orgID)
+}
+
+func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
+	return nil
+}
+
+func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}

+ 0 - 1
pkg/middleware/org_redirect.go

@@ -9,7 +9,6 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
-
 	"gopkg.in/macaron.v1"
 	"gopkg.in/macaron.v1"
 )
 )
 
 

+ 11 - 13
pkg/middleware/org_redirect_test.go

@@ -7,7 +7,6 @@ import (
 
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/session"
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 )
 )
 
 
@@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 
 
 	Convey("Can redirect to correct org", t, func() {
 	Convey("Can redirect to correct org", t, func() {
 		middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
 		middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
-
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 				return nil
 				return nil
 			})
 			})
 
 
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
-				return nil
-			})
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
+				ctx.IsSignedIn = true
+				return true
+			}
 
 
 			sc.m.Get("/", sc.defaultHandler)
 			sc.m.Get("/", sc.defaultHandler)
 			sc.fakeReq("GET", "/?orgId=3").exec()
 			sc.fakeReq("GET", "/?orgId=3").exec()
@@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 		})
 		})
 
 
 		middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
 		middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
-
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 				return fmt.Errorf("")
 				return fmt.Errorf("")
 			})
 			})
 
 
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
+				ctx.IsSignedIn = true
+				return true
+			}
+
 			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
 			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
 				query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
 				query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
 				return nil
 				return nil

+ 5 - 8
pkg/middleware/quota_test.go

@@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) {
 		})
 		})
 
 
 		middlewareScenario("with user logged in", func(sc *scenarioContext) {
 		middlewareScenario("with user logged in", func(sc *scenarioContext) {
-			// log us in, so we have a user_id and org_id in the context
-			sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
-				c.Session.Set(session.SESS_KEY_USERID, int64(12))
-			}).exec()
+			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
+				ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
+				ctx.IsSignedIn = true
+				return true
+			}
 
 
-			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
-				query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
-				return nil
-			})
 			bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
 			bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
 				query.Result = &m.GlobalQuotaDTO{
 				query.Result = &m.GlobalQuotaDTO{
 					Target: query.Target,
 					Target: query.Target,

+ 3 - 4
pkg/middleware/recovery_test.go

@@ -4,13 +4,12 @@ import (
 	"path/filepath"
 	"path/filepath"
 	"testing"
 	"testing"
 
 
-	ms "github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
-	"gopkg.in/macaron.v1"
+	macaron "gopkg.in/macaron.v1"
 )
 )
 
 
 func TestRecoveryMiddleware(t *testing.T) {
 func TestRecoveryMiddleware(t *testing.T) {
@@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
 			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 			Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 		}))
 		}))
 
 
-		sc.m.Use(GetContextHandler())
+		sc.userAuthTokenService = newFakeUserAuthTokenService()
+		sc.m.Use(GetContextHandler(sc.userAuthTokenService))
 		// mock out gc goroutine
 		// mock out gc goroutine
 		session.StartSessionGC = func() {}
 		session.StartSessionGC = func() {}
-		sc.m.Use(Sessioner(&ms.Options{}, 0))
 		sc.m.Use(OrgRedirect())
 		sc.m.Use(OrgRedirect())
 		sc.m.Use(AddDefaultResponseHeaders())
 		sc.m.Use(AddDefaultResponseHeaders())
 
 

+ 0 - 21
pkg/middleware/session.go

@@ -1,21 +0,0 @@
-package middleware
-
-import (
-	ms "github.com/go-macaron/session"
-	"gopkg.in/macaron.v1"
-
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/session"
-)
-
-func Sessioner(options *ms.Options, sessionConnMaxLifetime int64) macaron.Handler {
-	session.Init(options, sessionConnMaxLifetime)
-
-	return func(ctx *m.ReqContext) {
-		ctx.Next()
-
-		if err := ctx.Session.Release(); err != nil {
-			panic("session(release): " + err.Error())
-		}
-	}
-}

+ 3 - 3
pkg/models/context.go

@@ -3,18 +3,18 @@ package models
 import (
 import (
 	"strings"
 	"strings"
 
 
-	"github.com/prometheus/client_golang/prometheus"
-	"gopkg.in/macaron.v1"
-
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/services/session"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/prometheus/client_golang/prometheus"
+	"gopkg.in/macaron.v1"
 )
 )
 
 
 type ReqContext struct {
 type ReqContext struct {
 	*macaron.Context
 	*macaron.Context
 	*SignedInUser
 	*SignedInUser
 
 
+	// This should only be used by the auth_proxy
 	Session session.SessionStore
 	Session session.SessionStore
 
 
 	IsSignedIn     bool
 	IsSignedIn     bool

+ 13 - 3
pkg/services/alerting/engine.go

@@ -105,8 +105,9 @@ func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
 var (
 var (
 	unfinishedWorkTimeout = time.Second * 5
 	unfinishedWorkTimeout = time.Second * 5
 	// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
 	// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
-	alertTimeout     = time.Second * 30
-	alertMaxAttempts = 3
+	alertTimeout        = time.Second * 30
+	resultHandleTimeout = time.Second * 30
+	alertMaxAttempts    = 3
 )
 )
 
 
 func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
 func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
@@ -116,7 +117,7 @@ func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *J
 		}
 		}
 	}()
 	}()
 
 
-	cancelChan := make(chan context.CancelFunc, alertMaxAttempts)
+	cancelChan := make(chan context.CancelFunc, alertMaxAttempts*2)
 	attemptChan := make(chan int, 1)
 	attemptChan := make(chan int, 1)
 
 
 	// Initialize with first attemptID=1
 	// Initialize with first attemptID=1
@@ -204,6 +205,15 @@ func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancel
 			}
 			}
 		}
 		}
 
 
+		// create new context with timeout for notifications
+		resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), resultHandleTimeout)
+		cancelChan <- resultHandleCancelFn
+
+		// override the context used for evaluation with a new context for notifications.
+		// This makes it possible for notifiers to execute when datasources
+		// dont respond within the timeout limit. We should rewrite this so notifications
+		// dont reuse the evalContext and get its own context.
+		evalContext.Ctx = resultHandleCtx
 		evalContext.Rule.State = evalContext.GetNewState()
 		evalContext.Rule.State = evalContext.GetNewState()
 		e.resultHandler.Handle(evalContext)
 		e.resultHandler.Handle(evalContext)
 		span.Finish()
 		span.Finish()

+ 148 - 0
pkg/services/alerting/engine_integration_test.go

@@ -0,0 +1,148 @@
+// +build integration
+
+package alerting
+
+import (
+	"context"
+	"errors"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestEngineTimeouts(t *testing.T) {
+	Convey("Alerting engine timeout tests", t, func() {
+		engine := NewEngine()
+		engine.resultHandler = &FakeResultHandler{}
+		job := &Job{Running: true, Rule: &Rule{}}
+
+		Convey("Should trigger as many retries as needed", func() {
+			Convey("pended alert for datasource -> result handler should be worked", func() {
+				// reduce alert timeout to test quickly
+				originAlertTimeout := alertTimeout
+				alertTimeout = 2 * time.Second
+				transportTimeoutInterval := 2 * time.Second
+				serverBusySleepDuration := 1 * time.Second
+
+				evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
+				resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
+				engine.evalHandler = evalHandler
+				engine.resultHandler = resultHandler
+
+				engine.processJobWithRetry(context.TODO(), job)
+
+				So(evalHandler.EvalSucceed, ShouldEqual, true)
+				So(resultHandler.ResultHandleSucceed, ShouldEqual, true)
+
+				// initialize for other tests.
+				alertTimeout = originAlertTimeout
+				engine.resultHandler = &FakeResultHandler{}
+			})
+		})
+	})
+}
+
+type FakeCommonTimeoutHandler struct {
+	TransportTimeoutDuration time.Duration
+	ServerBusySleepDuration  time.Duration
+	EvalSucceed              bool
+	ResultHandleSucceed      bool
+}
+
+func NewFakeCommonTimeoutHandler(transportTimeoutDuration time.Duration, serverBusySleepDuration time.Duration) *FakeCommonTimeoutHandler {
+	return &FakeCommonTimeoutHandler{
+		TransportTimeoutDuration: transportTimeoutDuration,
+		ServerBusySleepDuration:  serverBusySleepDuration,
+		EvalSucceed:              false,
+		ResultHandleSucceed:      false,
+	}
+}
+
+func (handler *FakeCommonTimeoutHandler) Eval(evalContext *EvalContext) {
+	// 1. prepare mock server
+	path := "/evaltimeout"
+	srv := runBusyServer(path, handler.ServerBusySleepDuration)
+	defer srv.Close()
+
+	// 2. send requests
+	url := srv.URL + path
+	res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
+	if res != nil {
+		defer res.Body.Close()
+	}
+
+	if err != nil {
+		evalContext.Error = errors.New("Fake evaluation timeout test failure")
+		return
+	}
+
+	if res.StatusCode == 200 {
+		handler.EvalSucceed = true
+	}
+
+	evalContext.Error = errors.New("Fake evaluation timeout test failure; wrong response")
+}
+
+func (handler *FakeCommonTimeoutHandler) Handle(evalContext *EvalContext) error {
+	// 1. prepare mock server
+	path := "/resulthandle"
+	srv := runBusyServer(path, handler.ServerBusySleepDuration)
+	defer srv.Close()
+
+	// 2. send requests
+	url := srv.URL + path
+	res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
+	if res != nil {
+		defer res.Body.Close()
+	}
+
+	if err != nil {
+		evalContext.Error = errors.New("Fake result handle timeout test failure")
+		return evalContext.Error
+	}
+
+	if res.StatusCode == 200 {
+		handler.ResultHandleSucceed = true
+		return nil
+	}
+
+	evalContext.Error = errors.New("Fake result handle timeout test failure; wrong response")
+
+	return evalContext.Error
+}
+
+func runBusyServer(path string, serverBusySleepDuration time.Duration) *httptest.Server {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+
+	mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(serverBusySleepDuration)
+	})
+
+	return server
+}
+
+func sendRequest(context context.Context, url string, transportTimeoutInterval time.Duration) (resp *http.Response, err error) {
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(context)
+
+	transport := http.Transport{
+		Dial: (&net.Dialer{
+			Timeout:   transportTimeoutInterval,
+			KeepAlive: transportTimeoutInterval,
+		}).Dial,
+	}
+	client := http.Client{
+		Transport: &transport,
+	}
+
+	return client.Do(req)
+}

+ 266 - 0
pkg/services/auth/auth_token.go

@@ -0,0 +1,266 @@
+package auth
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/infra/serverlock"
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+func init() {
+	registry.RegisterService(&UserAuthTokenServiceImpl{})
+}
+
+var (
+	getTime          = time.Now
+	UrgentRotateTime = 1 * time.Minute
+	oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
+)
+
+// UserAuthTokenService are used for generating and validating user auth tokens
+type UserAuthTokenService interface {
+	InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
+	UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
+	UserSignedOutHook(c *models.ReqContext)
+}
+
+type UserAuthTokenServiceImpl struct {
+	SQLStore          *sqlstore.SqlStore            `inject:""`
+	ServerLockService *serverlock.ServerLockService `inject:""`
+	Cfg               *setting.Cfg                  `inject:""`
+	log               log.Logger
+}
+
+// Init this service
+func (s *UserAuthTokenServiceImpl) Init() error {
+	s.log = log.New("auth")
+	return nil
+}
+
+func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool {
+	//auth User
+	unhashedToken := ctx.GetCookie(s.Cfg.LoginCookieName)
+	if unhashedToken == "" {
+		return false
+	}
+
+	userToken, err := s.LookupToken(unhashedToken)
+	if err != nil {
+		ctx.Logger.Info("failed to look up user based on cookie", "error", err)
+		return false
+	}
+
+	query := models.GetSignedInUserQuery{UserId: userToken.UserId, OrgId: orgID}
+	if err := bus.Dispatch(&query); err != nil {
+		ctx.Logger.Error("Failed to get user with id", "userId", userToken.UserId, "error", err)
+		return false
+	}
+
+	ctx.SignedInUser = query.Result
+	ctx.IsSignedIn = true
+
+	//rotate session token if needed.
+	rotated, err := s.RefreshToken(userToken, ctx.RemoteAddr(), ctx.Req.UserAgent())
+	if err != nil {
+		ctx.Logger.Error("failed to rotate token", "error", err, "userId", userToken.UserId, "tokenId", userToken.Id)
+		return true
+	}
+
+	if rotated {
+		s.writeSessionCookie(ctx, userToken.UnhashedToken, oneYearInSeconds)
+	}
+
+	return true
+}
+
+func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) {
+	if setting.Env == setting.DEV {
+		ctx.Logger.Info("new token", "unhashed token", value)
+	}
+
+	ctx.Resp.Header().Del("Set-Cookie")
+	cookie := http.Cookie{
+		Name:     s.Cfg.LoginCookieName,
+		Value:    url.QueryEscape(value),
+		HttpOnly: true,
+		Path:     setting.AppSubUrl + "/",
+		Secure:   s.Cfg.SecurityHTTPSCookies,
+		MaxAge:   maxAge,
+	}
+
+	http.SetCookie(ctx.Resp, &cookie)
+}
+
+func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *models.ReqContext) error {
+	userToken, err := s.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
+	if err != nil {
+		return err
+	}
+
+	s.writeSessionCookie(c, userToken.UnhashedToken, oneYearInSeconds)
+	return nil
+}
+
+func (s *UserAuthTokenServiceImpl) UserSignedOutHook(c *models.ReqContext) {
+	s.writeSessionCookie(c, "", -1)
+}
+
+func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
+	clientIP = util.ParseIPAddress(clientIP)
+	token, err := util.RandomHex(16)
+	if err != nil {
+		return nil, err
+	}
+
+	hashedToken := hashToken(token)
+
+	now := getTime().Unix()
+
+	userToken := userAuthToken{
+		UserId:        userId,
+		AuthToken:     hashedToken,
+		PrevAuthToken: hashedToken,
+		ClientIp:      clientIP,
+		UserAgent:     userAgent,
+		RotatedAt:     now,
+		CreatedAt:     now,
+		UpdatedAt:     now,
+		SeenAt:        0,
+		AuthTokenSeen: false,
+	}
+	_, err = s.SQLStore.NewSession().Insert(&userToken)
+	if err != nil {
+		return nil, err
+	}
+
+	userToken.UnhashedToken = token
+
+	return &userToken, nil
+}
+
+func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
+	hashedToken := hashToken(unhashedToken)
+	if setting.Env == setting.DEV {
+		s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
+	}
+
+	expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
+
+	var userToken userAuthToken
+	exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
+	if err != nil {
+		return nil, err
+	}
+
+	if !exists {
+		return nil, ErrAuthTokenNotFound
+	}
+
+	if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen {
+		userTokenCopy := userToken
+		userTokenCopy.AuthTokenSeen = false
+		expireBefore := getTime().Add(-UrgentRotateTime).Unix()
+		affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", userTokenCopy.Id, userTokenCopy.PrevAuthToken, expireBefore).AllCols().Update(&userTokenCopy)
+		if err != nil {
+			return nil, err
+		}
+
+		if affectedRows == 0 {
+			s.log.Debug("prev seen token unchanged", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		} else {
+			s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		}
+	}
+
+	if !userToken.AuthTokenSeen && userToken.AuthToken == hashedToken {
+		userTokenCopy := userToken
+		userTokenCopy.AuthTokenSeen = true
+		userTokenCopy.SeenAt = getTime().Unix()
+		affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy)
+		if err != nil {
+			return nil, err
+		}
+
+		if affectedRows == 1 {
+			userToken = userTokenCopy
+		}
+
+		if affectedRows == 0 {
+			s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		} else {
+			s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+		}
+	}
+
+	userToken.UnhashedToken = unhashedToken
+
+	return &userToken, nil
+}
+
+func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) {
+	if token == nil {
+		return false, nil
+	}
+
+	now := getTime()
+
+	needsRotation := false
+	rotatedAt := time.Unix(token.RotatedAt, 0)
+	if token.AuthTokenSeen {
+		needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute))
+	} else {
+		needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime))
+	}
+
+	if !needsRotation {
+		return false, nil
+	}
+
+	s.log.Debug("refresh token needs rotation?", "auth_token_seen", token.AuthTokenSeen, "rotated_at", rotatedAt, "token.Id", token.Id)
+
+	clientIP = util.ParseIPAddress(clientIP)
+	newToken, _ := util.RandomHex(16)
+	hashedToken := hashToken(newToken)
+
+	// very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
+	sql := `
+		UPDATE user_auth_token
+		SET
+			seen_at = 0,
+			user_agent = ?,
+			client_ip = ?,
+			prev_auth_token = case when auth_token_seen = ? then auth_token else prev_auth_token end,
+			auth_token = ?,
+			auth_token_seen = ?,
+			rotated_at = ?
+		WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)`
+
+	res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), token.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix())
+	if err != nil {
+		return false, err
+	}
+
+	affected, _ := res.RowsAffected()
+	s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId)
+	if affected > 0 {
+		token.UnhashedToken = newToken
+		return true, nil
+	}
+
+	return false, nil
+}
+
+func hashToken(token string) string {
+	hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
+	return hex.EncodeToString(hashBytes[:])
+}

+ 339 - 0
pkg/services/auth/auth_token_test.go

@@ -0,0 +1,339 @@
+package auth
+
+import (
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/setting"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/services/sqlstore"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserAuthToken(t *testing.T) {
+	Convey("Test user auth token", t, func() {
+		ctx := createTestContext(t)
+		userAuthTokenService := ctx.tokenService
+		userID := int64(10)
+
+		t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC)
+		getTime = func() time.Time {
+			return t
+		}
+
+		Convey("When creating token", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+			So(token.AuthTokenSeen, ShouldBeFalse)
+
+			Convey("When lookup unhashed token should return user auth token", func() {
+				LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+				So(err, ShouldBeNil)
+				So(LookupToken, ShouldNotBeNil)
+				So(LookupToken.UserId, ShouldEqual, userID)
+				So(LookupToken.AuthTokenSeen, ShouldBeTrue)
+
+				storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id)
+				So(err, ShouldBeNil)
+				So(storedAuthToken, ShouldNotBeNil)
+				So(storedAuthToken.AuthTokenSeen, ShouldBeTrue)
+			})
+
+			Convey("When lookup hashed token should return user auth token not found error", func() {
+				LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken)
+				So(err, ShouldEqual, ErrAuthTokenNotFound)
+				So(LookupToken, ShouldBeNil)
+			})
+		})
+
+		Convey("expires correctly", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(time.Hour)
+			}
+
+			refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+
+			stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(stillGood, ShouldNotBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(24 * 7 * time.Hour)
+			}
+			notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldEqual, ErrAuthTokenNotFound)
+			So(notGood, ShouldBeNil)
+		})
+
+		Convey("can properly rotate tokens", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			prevToken := token.AuthToken
+			unhashedPrev := token.UnhashedToken
+
+			refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeFalse)
+
+			updated, err := ctx.markAuthTokenAsSeen(token.Id)
+			So(err, ShouldBeNil)
+			So(updated, ShouldBeTrue)
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(time.Hour)
+			}
+
+			refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			unhashedToken := token.UnhashedToken
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+			token.UnhashedToken = unhashedToken
+
+			So(token.RotatedAt, ShouldEqual, getTime().Unix())
+			So(token.ClientIp, ShouldEqual, "192.168.10.12")
+			So(token.UserAgent, ShouldEqual, "a new user agent")
+			So(token.AuthTokenSeen, ShouldBeFalse)
+			So(token.SeenAt, ShouldEqual, 0)
+			So(token.PrevAuthToken, ShouldEqual, prevToken)
+
+			// ability to auth using an old token
+
+			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+			So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
+
+			lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.Id, ShouldEqual, token.Id)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+
+			getTime = func() time.Time {
+				return t.Add(time.Hour + (2 * time.Minute))
+			}
+
+			lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+
+			lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeFalse)
+
+			refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			token, err = ctx.getAuthTokenByID(token.Id)
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+			So(token.SeenAt, ShouldEqual, 0)
+		})
+
+		Convey("keeps prev token valid for 1 minute after it is confirmed", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+
+			getTime = func() time.Time {
+				return t.Add(10 * time.Minute)
+			}
+
+			prevToken := token.UnhashedToken
+			refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+			So(err, ShouldBeNil)
+			So(refreshed, ShouldBeTrue)
+
+			getTime = func() time.Time {
+				return t.Add(20 * time.Minute)
+			}
+
+			current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(current, ShouldNotBeNil)
+
+			prev, err := userAuthTokenService.LookupToken(prevToken)
+			So(err, ShouldBeNil)
+			So(prev, ShouldNotBeNil)
+		})
+
+		Convey("will not mark token unseen when prev and current are the same", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+
+			lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+
+			lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+			So(err, ShouldBeNil)
+			So(lookedUp, ShouldNotBeNil)
+			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+		})
+
+		Convey("Rotate token", func() {
+			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			So(err, ShouldBeNil)
+			So(token, ShouldNotBeNil)
+
+			prevToken := token.AuthToken
+
+			Convey("Should rotate current token and previous token when auth token seen", func() {
+				updated, err := ctx.markAuthTokenAsSeen(token.Id)
+				So(err, ShouldBeNil)
+				So(updated, ShouldBeTrue)
+
+				getTime = func() time.Time {
+					return t.Add(10 * time.Minute)
+				}
+
+				refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				So(err, ShouldBeNil)
+				So(refreshed, ShouldBeTrue)
+
+				storedToken, err := ctx.getAuthTokenByID(token.Id)
+				So(err, ShouldBeNil)
+				So(storedToken, ShouldNotBeNil)
+				So(storedToken.AuthTokenSeen, ShouldBeFalse)
+				So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+				So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+
+				prevToken = storedToken.AuthToken
+
+				updated, err = ctx.markAuthTokenAsSeen(token.Id)
+				So(err, ShouldBeNil)
+				So(updated, ShouldBeTrue)
+
+				getTime = func() time.Time {
+					return t.Add(20 * time.Minute)
+				}
+
+				refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				So(err, ShouldBeNil)
+				So(refreshed, ShouldBeTrue)
+
+				storedToken, err = ctx.getAuthTokenByID(token.Id)
+				So(err, ShouldBeNil)
+				So(storedToken, ShouldNotBeNil)
+				So(storedToken.AuthTokenSeen, ShouldBeFalse)
+				So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+				So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+			})
+
+			Convey("Should rotate current token, but keep previous token when auth token not seen", func() {
+				token.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
+
+				getTime = func() time.Time {
+					return t.Add(2 * time.Minute)
+				}
+
+				refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				So(err, ShouldBeNil)
+				So(refreshed, ShouldBeTrue)
+
+				storedToken, err := ctx.getAuthTokenByID(token.Id)
+				So(err, ShouldBeNil)
+				So(storedToken, ShouldNotBeNil)
+				So(storedToken.AuthTokenSeen, ShouldBeFalse)
+				So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+				So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+			})
+		})
+
+		Reset(func() {
+			getTime = time.Now
+		})
+	})
+}
+
+func createTestContext(t *testing.T) *testContext {
+	t.Helper()
+
+	sqlstore := sqlstore.InitTestDB(t)
+	tokenService := &UserAuthTokenServiceImpl{
+		SQLStore: sqlstore,
+		Cfg: &setting.Cfg{
+			LoginCookieName:                   "grafana_session",
+			LoginCookieMaxDays:                7,
+			LoginDeleteExpiredTokensAfterDays: 30,
+			LoginCookieRotation:               10,
+		},
+		log: log.New("test-logger"),
+	}
+
+	UrgentRotateTime = time.Minute
+
+	return &testContext{
+		sqlstore:     sqlstore,
+		tokenService: tokenService,
+	}
+}
+
+type testContext struct {
+	sqlstore     *sqlstore.SqlStore
+	tokenService *UserAuthTokenServiceImpl
+}
+
+func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
+	sess := c.sqlstore.NewSession()
+	var t userAuthToken
+	found, err := sess.ID(id).Get(&t)
+	if err != nil || !found {
+		return nil, err
+	}
+
+	return &t, nil
+}
+
+func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
+	sess := c.sqlstore.NewSession()
+	res, err := sess.Exec("UPDATE user_auth_token SET auth_token_seen = ? WHERE id = ?", c.sqlstore.Dialect.BooleanStr(true), id)
+	if err != nil {
+		return false, err
+	}
+
+	rowsAffected, err := res.RowsAffected()
+	if err != nil {
+		return false, err
+	}
+	return rowsAffected == 1, nil
+}

+ 25 - 0
pkg/services/auth/model.go

@@ -0,0 +1,25 @@
+package auth
+
+import (
+	"errors"
+)
+
+// Typed errors
+var (
+	ErrAuthTokenNotFound = errors.New("User auth token not found")
+)
+
+type userAuthToken struct {
+	Id            int64
+	UserId        int64
+	AuthToken     string
+	PrevAuthToken string
+	UserAgent     string
+	ClientIp      string
+	AuthTokenSeen bool
+	SeenAt        int64
+	RotatedAt     int64
+	CreatedAt     int64
+	UpdatedAt     int64
+	UnhashedToken string `xorm:"-"`
+}

+ 38 - 0
pkg/services/auth/session_cleanup.go

@@ -0,0 +1,38 @@
+package auth
+
+import (
+	"context"
+	"time"
+)
+
+func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error {
+	ticker := time.NewTicker(time.Hour * 12)
+	deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays)
+
+	for {
+		select {
+		case <-ticker.C:
+			srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() {
+				srv.deleteOldSession(deleteSessionAfter)
+			})
+
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+}
+
+func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) {
+	sql := `DELETE from user_auth_token WHERE rotated_at < ?`
+
+	deleteBefore := getTime().Add(-deleteSessionAfter)
+	res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix())
+	if err != nil {
+		return 0, err
+	}
+
+	affected, err := res.RowsAffected()
+	srv.log.Info("deleted old sessions", "count", affected)
+
+	return affected, err
+}

+ 36 - 0
pkg/services/auth/session_cleanup_test.go

@@ -0,0 +1,36 @@
+package auth
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserAuthTokenCleanup(t *testing.T) {
+
+	Convey("Test user auth token cleanup", t, func() {
+		ctx := createTestContext(t)
+
+		insertToken := func(token string, prev string, rotatedAt int64) {
+			ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
+			_, err := ctx.sqlstore.NewSession().Insert(&ut)
+			So(err, ShouldBeNil)
+		}
+
+		// insert three old tokens that should be deleted
+		for i := 0; i < 3; i++ {
+			insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i))
+		}
+
+		// insert three active tokens that should not be deleted
+		for i := 0; i < 3; i++ {
+			insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix())
+		}
+
+		affected, err := ctx.tokenService.deleteOldSession(time.Hour)
+		So(err, ShouldBeNil)
+		So(affected, ShouldEqual, 3)
+	})
+}

+ 1 - 5
pkg/services/dashboards/dashboard_service.go

@@ -164,11 +164,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand,
 		User:      dto.User,
 		User:      dto.User,
 	}
 	}
 
 
-	if err := bus.Dispatch(&alertCmd); err != nil {
-		return err
-	}
-
-	return nil
+	return bus.Dispatch(&alertCmd)
 }
 }
 
 
 func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
 func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {

+ 0 - 2
pkg/services/session/session.go

@@ -14,8 +14,6 @@ import (
 
 
 const (
 const (
 	SESS_KEY_USERID       = "uid"
 	SESS_KEY_USERID       = "uid"
-	SESS_KEY_OAUTH_STATE  = "state"
-	SESS_KEY_APIKEY       = "apikey_id" // used for render requests with api keys
 	SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
 	SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
 )
 )
 
 

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

@@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) {
 	addLoginAttemptMigrations(mg)
 	addLoginAttemptMigrations(mg)
 	addUserAuthMigrations(mg)
 	addUserAuthMigrations(mg)
 	addServerlockMigrations(mg)
 	addServerlockMigrations(mg)
+	addUserAuthTokenMigrations(mg)
 }
 }
 
 
 func addMigrationLogMigrations(mg *Migrator) {
 func addMigrationLogMigrations(mg *Migrator) {

+ 32 - 0
pkg/services/sqlstore/migrations/user_auth_token_mig.go

@@ -0,0 +1,32 @@
+package migrations
+
+import (
+	. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+)
+
+func addUserAuthTokenMigrations(mg *Migrator) {
+	userAuthTokenV1 := Table{
+		Name: "user_auth_token",
+		Columns: []*Column{
+			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+			{Name: "user_id", Type: DB_BigInt, Nullable: false},
+			{Name: "auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
+			{Name: "prev_auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
+			{Name: "user_agent", Type: DB_NVarchar, Length: 255, Nullable: false},
+			{Name: "client_ip", Type: DB_NVarchar, Length: 255, Nullable: false},
+			{Name: "auth_token_seen", Type: DB_Bool, Nullable: false},
+			{Name: "seen_at", Type: DB_Int, Nullable: true},
+			{Name: "rotated_at", Type: DB_Int, Nullable: false},
+			{Name: "created_at", Type: DB_Int, Nullable: false},
+			{Name: "updated_at", Type: DB_Int, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"auth_token"}, Type: UniqueIndex},
+			{Cols: []string{"prev_auth_token"}, Type: UniqueIndex},
+		},
+	}
+
+	mg.AddMigration("create user auth token table", NewAddTableMigration(userAuthTokenV1))
+	mg.AddMigration("add unique index user_auth_token.auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[0]))
+	mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1]))
+}

+ 18 - 6
pkg/setting/setting.go

@@ -83,9 +83,6 @@ var (
 
 
 	// Security settings.
 	// Security settings.
 	SecretKey                        string
 	SecretKey                        string
-	LogInRememberDays                int
-	CookieUserName                   string
-	CookieRememberName               string
 	DisableGravatar                  bool
 	DisableGravatar                  bool
 	EmailCodeValidMinutes            int
 	EmailCodeValidMinutes            int
 	DataProxyWhiteList               map[string]bool
 	DataProxyWhiteList               map[string]bool
@@ -224,6 +221,13 @@ type Cfg struct {
 	EnableAlphaPanels                bool
 	EnableAlphaPanels                bool
 	DisableSanitizeHtml              bool
 	DisableSanitizeHtml              bool
 	EnterpriseLicensePath            string
 	EnterpriseLicensePath            string
+
+	LoginCookieName                   string
+	LoginCookieMaxDays                int
+	LoginCookieRotation               int
+	LoginDeleteExpiredTokensAfterDays int
+
+	SecurityHTTPSCookies bool
 }
 }
 
 
 type CommandLineArgs struct {
 type CommandLineArgs struct {
@@ -547,6 +551,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 		ApplicationName = APP_NAME_ENTERPRISE
 		ApplicationName = APP_NAME_ENTERPRISE
 	}
 	}
 
 
+	//login
+	login := iniFile.Section("login")
+	cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
+	cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
+	cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
+	cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
+	if cfg.LoginCookieRotation < 2 {
+		cfg.LoginCookieRotation = 2
+	}
+
 	Env = iniFile.Section("").Key("app_mode").MustString("development")
 	Env = iniFile.Section("").Key("app_mode").MustString("development")
 	InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
 	InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
 	PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
 	PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
@@ -587,11 +601,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	// read security settings
 	// read security settings
 	security := iniFile.Section("security")
 	security := iniFile.Section("security")
 	SecretKey = security.Key("secret_key").String()
 	SecretKey = security.Key("secret_key").String()
-	LogInRememberDays = security.Key("login_remember_days").MustInt()
-	CookieUserName = security.Key("cookie_username").String()
-	CookieRememberName = security.Key("cookie_remember_name").String()
 	DisableGravatar = security.Key("disable_gravatar").MustBool(true)
 	DisableGravatar = security.Key("disable_gravatar").MustBool(true)
 	cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
 	cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
+	cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
 	DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
 	DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
 
 
 	// read snapshots settings
 	// read snapshots settings

+ 8 - 0
pkg/util/encoding.go

@@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
 
 
 	return userAndPass[0], userAndPass[1], nil
 	return userAndPass[0], userAndPass[1], nil
 }
 }
+
+func RandomHex(n int) (string, error) {
+	bytes := make([]byte, n)
+	if _, err := rand.Read(bytes); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(bytes), nil
+}

+ 29 - 0
pkg/util/ip_address.go

@@ -0,0 +1,29 @@
+package util
+
+import (
+	"net"
+	"strings"
+)
+
+// ParseIPAddress parses an IP address and removes port and/or IPV6 format
+func ParseIPAddress(input string) string {
+	s := input
+	lastIndex := strings.LastIndex(input, ":")
+
+	if lastIndex != -1 {
+		if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" {
+			s = input[:lastIndex]
+		}
+	}
+
+	s = strings.Replace(s, "[", "", -1)
+	s = strings.Replace(s, "]", "", -1)
+
+	ip := net.ParseIP(s)
+
+	if ip.IsLoopback() {
+		return "127.0.0.1"
+	}
+
+	return ip.String()
+}

+ 16 - 0
pkg/util/ip_address_test.go

@@ -0,0 +1,16 @@
+package util
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestParseIPAddress(t *testing.T) {
+	Convey("Test parse ip address", t, func() {
+		So(ParseIPAddress("192.168.0.140:456"), ShouldEqual, "192.168.0.140")
+		So(ParseIPAddress("[::1:456]"), ShouldEqual, "127.0.0.1")
+		So(ParseIPAddress("[::1]"), ShouldEqual, "127.0.0.1")
+		So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140")
+	})
+}

+ 6 - 2
public/app/core/actions/location.ts

@@ -1,13 +1,17 @@
 import { LocationUpdate } from 'app/types';
 import { LocationUpdate } from 'app/types';
 
 
+export enum CoreActionTypes {
+  UpdateLocation = 'UPDATE_LOCATION',
+}
+
 export type Action = UpdateLocationAction;
 export type Action = UpdateLocationAction;
 
 
 export interface UpdateLocationAction {
 export interface UpdateLocationAction {
-  type: 'UPDATE_LOCATION';
+  type: CoreActionTypes.UpdateLocation;
   payload: LocationUpdate;
   payload: LocationUpdate;
 }
 }
 
 
 export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
 export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
-  type: 'UPDATE_LOCATION',
+  type: CoreActionTypes.UpdateLocation,
   payload: location,
   payload: location,
 });
 });

+ 1 - 1
public/app/core/components/sidemenu/SideMenuDropDown.tsx

@@ -10,7 +10,7 @@ const SideMenuDropDown: FC<Props> = props => {
   return (
   return (
     <ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
     <ul className="dropdown-menu dropdown-menu--sidemenu" role="menu">
       <li className="side-menu-header">
       <li className="side-menu-header">
-        <a href={link.url}>
+        <a className="side-menu-header-link" href={link.url}>
           <span className="sidemenu-item-text">{link.text}</span>
           <span className="sidemenu-item-text">{link.text}</span>
         </a>
         </a>
       </li>
       </li>

+ 6 - 2
public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap

@@ -8,7 +8,9 @@ exports[`Render should render children 1`] = `
   <li
   <li
     className="side-menu-header"
     className="side-menu-header"
   >
   >
-    <a>
+    <a
+      className="side-menu-header-link"
+    >
       <span
       <span
         className="sidemenu-item-text"
         className="sidemenu-item-text"
       >
       >
@@ -51,7 +53,9 @@ exports[`Render should render component 1`] = `
   <li
   <li
     className="side-menu-header"
     className="side-menu-header"
   >
   >
-    <a>
+    <a
+      className="side-menu-header-link"
+    >
       <span
       <span
         className="sidemenu-item-text"
         className="sidemenu-item-text"
       >
       >

+ 0 - 1
public/app/core/controllers/all.ts

@@ -1,4 +1,3 @@
-import './inspect_ctrl';
 import './json_editor_ctrl';
 import './json_editor_ctrl';
 import './login_ctrl';
 import './login_ctrl';
 import './invited_ctrl';
 import './invited_ctrl';

+ 0 - 71
public/app/core/controllers/inspect_ctrl.ts

@@ -1,71 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-import $ from 'jquery';
-import coreModule from '../core_module';
-
-export class InspectCtrl {
-  /** @ngInject */
-  constructor($scope, $sanitize) {
-    const model = $scope.inspector;
-
-    $scope.init = function() {
-      $scope.editor = { index: 0 };
-
-      if (!model.error) {
-        return;
-      }
-
-      if (_.isString(model.error.data)) {
-        $scope.response = $('<div>' + model.error.data + '</div>').text();
-      } else if (model.error.data) {
-        if (model.error.data.response) {
-          $scope.response = $sanitize(model.error.data.response);
-        } else {
-          $scope.response = angular.toJson(model.error.data, true);
-        }
-      } else if (model.error.message) {
-        $scope.message = model.error.message;
-      }
-
-      if (model.error.config && model.error.config.params) {
-        $scope.request_parameters = _.map(model.error.config.params, (value, key) => {
-          return { key: key, value: value };
-        });
-      }
-
-      if (model.error.stack) {
-        $scope.editor.index = 3;
-        $scope.stack_trace = model.error.stack;
-        $scope.message = model.error.message;
-      }
-
-      if (model.error.config && model.error.config.data) {
-        $scope.editor.index = 2;
-
-        if (_.isString(model.error.config.data)) {
-          $scope.request_parameters = this.getParametersFromQueryString(model.error.config.data);
-        } else {
-          $scope.request_parameters = _.map(model.error.config.data, (value, key) => {
-            return { key: key, value: angular.toJson(value, true) };
-          });
-        }
-      }
-    };
-  }
-  getParametersFromQueryString(queryString) {
-    const result = [];
-    const parameters = queryString.split('&');
-    for (let i = 0; i < parameters.length; i++) {
-      const keyValue = parameters[i].split('=');
-      if (keyValue[1].length > 0) {
-        result.push({
-          key: keyValue[0],
-          value: (window as any).unescape(keyValue[1]),
-        });
-      }
-    }
-    return result;
-  }
-}
-
-coreModule.controller('InspectCtrl', InspectCtrl);

+ 3 - 5
public/app/core/reducers/location.ts

@@ -1,4 +1,4 @@
-import { Action } from 'app/core/actions/location';
+import { Action, CoreActionTypes } from 'app/core/actions/location';
 import { LocationState } from 'app/types';
 import { LocationState } from 'app/types';
 import { renderUrl } from 'app/core/utils/url';
 import { renderUrl } from 'app/core/utils/url';
 import _ from 'lodash';
 import _ from 'lodash';
@@ -12,7 +12,7 @@ export const initialState: LocationState = {
 
 
 export const locationReducer = (state = initialState, action: Action): LocationState => {
 export const locationReducer = (state = initialState, action: Action): LocationState => {
   switch (action.type) {
   switch (action.type) {
-    case 'UPDATE_LOCATION': {
+    case CoreActionTypes.UpdateLocation: {
       const { path, routeParams } = action.payload;
       const { path, routeParams } = action.payload;
       let query = action.payload.query || state.query;
       let query = action.payload.query || state.query;
 
 
@@ -24,9 +24,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
       return {
       return {
         url: renderUrl(path || state.path, query),
         url: renderUrl(path || state.path, query),
         path: path || state.path,
         path: path || state.path,
-        query: {
-          ...query,
-        },
+        query: { ...query },
         routeParams: routeParams || state.routeParams,
         routeParams: routeParams || state.routeParams,
       };
       };
     }
     }

+ 9 - 0
public/app/core/specs/url.test.ts

@@ -14,3 +14,12 @@ describe('toUrlParams', () => {
     expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined=');
     expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined=');
   });
   });
 });
 });
+
+describe('toUrlParams', () => {
+  it('should encode the same way as angularjs', () => {
+    const url = toUrlParams({
+      server: ':@',
+    });
+    expect(url).toBe('server=:@');
+  });
+});

+ 1 - 1
public/app/core/utils/explore.ts

@@ -84,7 +84,7 @@ export async function getExploreUrl(
     }
     }
 
 
     const exploreState = JSON.stringify(state);
     const exploreState = JSON.stringify(state);
-    url = renderUrl('/explore', { state: exploreState });
+    url = renderUrl('/explore', { left: exploreState });
   }
   }
   return url;
   return url;
 }
 }

+ 12 - 2
public/app/core/utils/url.ts

@@ -11,6 +11,16 @@ export function renderUrl(path: string, query: UrlQueryMap | undefined): string
   return path;
   return path;
 }
 }
 
 
+export function encodeURIComponentAsAngularJS(val, pctEncodeSpaces) {
+  return encodeURIComponent(val).
+             replace(/%40/gi, '@').
+             replace(/%3A/gi, ':').
+             replace(/%24/g, '$').
+             replace(/%2C/gi, ',').
+             replace(/%3B/gi, ';').
+             replace(/%20/g, (pctEncodeSpaces ? '%20' : '+'));
+}
+
 export function toUrlParams(a) {
 export function toUrlParams(a) {
   const s = [];
   const s = [];
   const rbracket = /\[\]$/;
   const rbracket = /\[\]$/;
@@ -22,9 +32,9 @@ export function toUrlParams(a) {
   const add = (k, v) => {
   const add = (k, v) => {
     v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
     v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
     if (typeof v !== 'boolean') {
     if (typeof v !== 'boolean') {
-      s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
+      s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true);
     } else {
     } else {
-      s[s.length] = encodeURIComponent(k);
+      s[s.length] = encodeURIComponentAsAngularJS(k, true);
     }
     }
   };
   };
 
 

+ 10 - 10
public/app/features/dashboard/dashgrid/AddPanelPanel.tsx → public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx

@@ -1,23 +1,23 @@
 import React from 'react';
 import React from 'react';
 import _ from 'lodash';
 import _ from 'lodash';
 import config from 'app/core/config';
 import config from 'app/core/config';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import { PanelModel } from '../../panel_model';
+import { DashboardModel } from '../../dashboard_model';
 import store from 'app/core/store';
 import store from 'app/core/store';
 import { LS_PANEL_COPY_KEY } from 'app/core/constants';
 import { LS_PANEL_COPY_KEY } from 'app/core/constants';
 import { updateLocation } from 'app/core/actions';
 import { updateLocation } from 'app/core/actions';
 import { store as reduxStore } from 'app/store/store';
 import { store as reduxStore } from 'app/store/store';
 
 
-export interface AddPanelPanelProps {
+export interface Props {
   panel: PanelModel;
   panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
 }
 }
 
 
-export interface AddPanelPanelState {
+export interface State {
   copiedPanelPlugins: any[];
   copiedPanelPlugins: any[];
 }
 }
 
 
-export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelPanelState> {
+export class AddPanelWidget extends React.Component<Props, State> {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
     this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
     this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
@@ -133,15 +133,15 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
     }
     }
 
 
     return (
     return (
-      <div className="panel-container add-panel-container">
-        <div className="add-panel">
-          <div className="add-panel__header grid-drag-handle">
+      <div className="panel-container add-panel-widget-container">
+        <div className="add-panel-widget">
+          <div className="add-panel-widget__header grid-drag-handle">
             <i className="gicon gicon-add-panel" />
             <i className="gicon gicon-add-panel" />
-            <button className="add-panel__close" onClick={this.handleCloseAddPanel}>
+            <button className="add-panel-widget__close" onClick={this.handleCloseAddPanel}>
               <i className="fa fa-close" />
               <i className="fa fa-close" />
             </button>
             </button>
           </div>
           </div>
-          <div className="add-panel-btn-container">
+          <div className="add-panel-widget__btn-container">
             <button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
             <button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
               Edit Panel
               Edit Panel
             </button>
             </button>

+ 5 - 5
public/sass/components/_panel_add_panel.scss → public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss

@@ -1,12 +1,12 @@
-.add-panel-container {
+.add-panel-widget-container {
   height: 100%;
   height: 100%;
 }
 }
 
 
-.add-panel {
+.add-panel-widget {
   height: 100%;
   height: 100%;
 }
 }
 
 
-.add-panel__header {
+.add-panel-widget__header {
   top: 0;
   top: 0;
   position: absolute;
   position: absolute;
   padding: 0 15px;
   padding: 0 15px;
@@ -26,7 +26,7 @@
   }
   }
 }
 }
 
 
-.add-panel__close {
+.add-panel-widget__close {
   margin-left: auto;
   margin-left: auto;
   background-color: transparent;
   background-color: transparent;
   border: 0;
   border: 0;
@@ -34,7 +34,7 @@
   margin-right: -10px;
   margin-right: -10px;
 }
 }
 
 
-.add-panel-btn-container {
+.add-panel-widget__btn-container {
   display: flex;
   display: flex;
   justify-content: center;
   justify-content: center;
   align-items: center;
   align-items: center;

+ 1 - 0
public/app/features/dashboard/components/AddPanelWidget/index.ts

@@ -0,0 +1 @@
+export { AddPanelWidget } from './AddPanelWidget';

+ 0 - 0
public/app/features/dashboard/dashgrid/RowOptions.ts → public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts


+ 1 - 0
public/app/features/dashboard/components/RowOptions/index.ts

@@ -0,0 +1 @@
+export { RowOptionsCtrl } from './RowOptionsCtrl';

+ 0 - 0
public/app/features/dashboard/partials/row_options.html → public/app/features/dashboard/components/RowOptions/template.html


+ 1 - 1
public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts

@@ -1,5 +1,5 @@
 import config from 'app/core/config';
 import config from 'app/core/config';
-import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
 import { ShareModalCtrl } from './ShareModalCtrl';
 import { ShareModalCtrl } from './ShareModalCtrl';
 
 
 describe('ShareModalCtrl', () => {
 describe('ShareModalCtrl', () => {

+ 2 - 2
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -5,7 +5,7 @@ import classNames from 'classnames';
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
 import { importPluginModule } from 'app/features/plugins/plugin_loader';
 import { importPluginModule } from 'app/features/plugins/plugin_loader';
 
 
-import { AddPanelPanel } from './AddPanelPanel';
+import { AddPanelWidget } from '../components/AddPanelWidget';
 import { getPanelPluginNotFound } from './PanelPluginNotFound';
 import { getPanelPluginNotFound } from './PanelPluginNotFound';
 import { DashboardRow } from './DashboardRow';
 import { DashboardRow } from './DashboardRow';
 import { PanelChrome } from './PanelChrome';
 import { PanelChrome } from './PanelChrome';
@@ -53,7 +53,7 @@ export class DashboardPanel extends PureComponent<Props, State> {
   }
   }
 
 
   renderAddPanel() {
   renderAddPanel() {
-    return <AddPanelPanel panel={this.props.panel} dashboard={this.props.dashboard} />;
+    return <AddPanelWidget panel={this.props.panel} dashboard={this.props.dashboard} />;
   }
   }
 
 
   onPluginTypeChanged = (plugin: PanelPlugin) => {
   onPluginTypeChanged = (plugin: PanelPlugin) => {

+ 1 - 1
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx

@@ -3,7 +3,7 @@ import Remarkable from 'remarkable';
 import { Tooltip } from '@grafana/ui';
 import { Tooltip } from '@grafana/ui';
 import { PanelModel } from 'app/features/dashboard/panel_model';
 import { PanelModel } from 'app/features/dashboard/panel_model';
 import templateSrv from 'app/features/templating/template_srv';
 import templateSrv from 'app/features/templating/template_srv';
-import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
 import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
 import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
 
 
 enum InfoModes {
 enum InfoModes {

+ 1 - 3
public/app/features/dashboard/index.ts

@@ -1,9 +1,6 @@
 import './dashboard_ctrl';
 import './dashboard_ctrl';
 import './time_srv';
 import './time_srv';
-import './repeat_option/repeat_option';
 import './dashgrid/DashboardGridDirective';
 import './dashgrid/DashboardGridDirective';
-import './dashgrid/RowOptions';
-import './panellinks/module';
 
 
 // Services
 // Services
 import './services/DashboardViewStateSrv';
 import './services/DashboardViewStateSrv';
@@ -25,6 +22,7 @@ import './components/UnsavedChangesModal';
 import './components/SaveModals';
 import './components/SaveModals';
 import './components/ShareModal';
 import './components/ShareModal';
 import './components/AdHocFilters';
 import './components/AdHocFilters';
+import './components/RowOptions';
 
 
 import DashboardPermissions from './components/DashboardPermissions/DashboardPermissions';
 import DashboardPermissions from './components/DashboardPermissions/DashboardPermissions';
 
 

+ 0 - 7
public/app/features/dashboard/partials/folder_permissions.html

@@ -1,7 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <dashboard-permissions ng-if="ctrl.dashboard && ctrl.meta"
-    dashboardId="ctrl.dashboard.id"
-  />
-</div>

+ 0 - 23
public/app/features/dashboard/partials/folder_settings.html

@@ -1,23 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-	<h2 class="page-sub-heading">Folder Settings</h2>
-
-	<div class="section gf-form-group">
-		<form name="folderSettingsForm" ng-submit="ctrl.save()">
-			<div class="gf-form">
-				<label class="gf-form-label width-7">Name</label>
-				<input type="text" class="gf-form-input width-30" ng-model='ctrl.title' ng-change="ctrl.titleChanged()"></input>
-			</div>
-			<div class="gf-form-button-row">
-				<button type="submit" class="btn btn-success" ng-disabled="!ctrl.canSave || !ctrl.hasChanged">
-					<i class="fa fa-save"></i>Save
-				</button>
-				<button class="btn btn-danger" ng-click="ctrl.delete($event)" ng-disabled="!ctrl.canSave">
-					<i class="fa fa-trash"></i>
-					Delete
-				</button>
-			</div>
-		</form>
-	</div>
-</div>

+ 0 - 82
public/app/features/dashboard/partials/inspector.html

@@ -1,82 +0,0 @@
-<div class="modal-body" ng-controller="InspectCtrl" ng-init="init()">
-	<div class="modal-header">
-		<h2 class="modal-header-title">
-			<i class="fa fa-info-circle"></i>
-			<span class="p-l-1">Inspector</span>
-		</h2>
-
-		<ul class="gf-tabs">
-			<li class="gf-tabs-item" ng-repeat="tab in ['Panel Description', 'Request', 'Response', 'JS Error']">
-				<a class="gf-tabs-link" ng-click="editor.index = $index" ng-class="{active: editor.index === $index}">
-					{{::tab}}
-				</a>
-			</li>
-		</ul>
-
-		<a class="modal-header-close" ng-click="dismiss();">
-			<i class="fa fa-remove"></i>
-		</a>
-	</div>
-
-	<div class="modal-content">
-		<div ng-if="editor.index == 0" ng-bind-html="panelInfoHtml">
-		</div>
-
-		<div ng-if="editor.index == 1">
-			<h5 class="section-heading">Request details</h5>
-			<table class="filter-table gf-form-group">
-				<tr>
-					<td>Url</td>
-					<td>{{inspector.error.config.url}}</td>
-				</tr>
-				<tr>
-					<td>Method</td>
-					<td>{{inspector.error.config.method}}</td>
-				</tr>
-				<tr ng-repeat="(key, value) in inspector.error.config.headers">
-					<td>
-						{{key}}
-					</td>
-					<td>
-						{{value}}
-					</td>
-				</tr>
-			</table>
-
-			<h5 class="section-heading">Request parameters</h5>
-			<table class="filter-table">
-				<tr ng-repeat="param in request_parameters">
-					<td>
-						{{param.key}}
-					</td>
-					<td>
-						{{param.value}}
-					</td>
-				</tr>
-			</table>
-		</div>
-
-		<div ng-if="editor.index == 2">
-			<h5 ng-show="message">{{message}}</h5>
- 			<pre class="small">
-{{response}}
-			</pre>
-		</div>
-
-		<div ng-if="editor.index == 3">
-
-			<label>Message:</label>
-<pre>
-{{message}}
-</pre>
-
-			<label>Stack trace:</label>
-<pre>
-{{stack_trace}}
-</pre>
-
-		</div>
-
-	</div>
-</div>
-

+ 0 - 1
public/app/features/dashboard/services/DashboardViewStateSrv.test.ts

@@ -58,7 +58,6 @@ describe('when updating view state', () => {
     it('should remove params from query string', () => {
     it('should remove params from query string', () => {
       viewState.update({ fullscreen: true, panelId: 1, edit: true });
       viewState.update({ fullscreen: true, panelId: 1, edit: true });
       viewState.update({ fullscreen: false });
       viewState.update({ fullscreen: false });
-      expect(viewState.dashboard.meta.fullscreen).toBe(false);
       expect(viewState.state.fullscreen).toBe(null);
       expect(viewState.state.fullscreen).toBe(null);
     });
     });
   });
   });

+ 11 - 2
public/app/features/dashboard/services/DashboardViewStateSrv.ts

@@ -72,7 +72,6 @@ export class DashboardViewStateSrv {
     }
     }
 
 
     _.extend(this.state, state);
     _.extend(this.state, state);
-    this.dashboard.meta.fullscreen = this.state.fullscreen;
 
 
     if (!this.state.fullscreen) {
     if (!this.state.fullscreen) {
       this.state.fullscreen = null;
       this.state.fullscreen = null;
@@ -117,10 +116,20 @@ export class DashboardViewStateSrv {
   }
   }
 
 
   syncState() {
   syncState() {
-    if (this.dashboard.meta.fullscreen) {
+    if (this.state.fullscreen) {
       const panel = this.dashboard.getPanelById(this.state.panelId);
       const panel = this.dashboard.getPanelById(this.state.panelId);
 
 
       if (!panel) {
       if (!panel) {
+        this.state.fullscreen = null;
+        this.state.panelId = null;
+        this.state.edit = null;
+
+        this.update(this.state);
+
+        setTimeout(() => {
+          appEvents.emit('alert-error', ['Error', 'Panel not found']);
+        }, 100);
+
         return;
         return;
       }
       }
 
 

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

@@ -9,8 +9,6 @@ import { AutoSizer } from 'react-virtualized';
 import store from 'app/core/store';
 import store from 'app/core/store';
 
 
 // Components
 // Components
-import { DataSourceSelectItem } from '@grafana/ui/src/types';
-import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { Alert } from './Error';
 import { Alert } from './Error';
 import ErrorBoundary from './ErrorBoundary';
 import ErrorBoundary from './ErrorBoundary';
 import GraphContainer from './GraphContainer';
 import GraphContainer from './GraphContainer';
@@ -21,18 +19,13 @@ import TimePicker, { parseTime } from './TimePicker';
 
 
 // Actions
 // Actions
 import {
 import {
-  changeDatasource,
   changeSize,
   changeSize,
   changeTime,
   changeTime,
-  clearQueries,
   initializeExplore,
   initializeExplore,
   modifyQueries,
   modifyQueries,
-  runQueries,
   scanStart,
   scanStart,
   scanStop,
   scanStop,
   setQueries,
   setQueries,
-  splitClose,
-  splitOpen,
 } from './state/actions';
 } from './state/actions';
 
 
 // Types
 // Types
@@ -41,27 +34,23 @@ import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/
 import { StoreState } from 'app/types';
 import { StoreState } from 'app/types';
 import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore';
 import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore';
 import { Emitter } from 'app/core/utils/emitter';
 import { Emitter } from 'app/core/utils/emitter';
+import { ExploreToolbar } from './ExploreToolbar';
 
 
 interface ExploreProps {
 interface ExploreProps {
   StartPage?: any;
   StartPage?: any;
-  changeDatasource: typeof changeDatasource;
   changeSize: typeof changeSize;
   changeSize: typeof changeSize;
   changeTime: typeof changeTime;
   changeTime: typeof changeTime;
-  clearQueries: typeof clearQueries;
   datasourceError: string;
   datasourceError: string;
   datasourceInstance: any;
   datasourceInstance: any;
   datasourceLoading: boolean | null;
   datasourceLoading: boolean | null;
   datasourceMissing: boolean;
   datasourceMissing: boolean;
-  exploreDatasources: DataSourceSelectItem[];
   exploreId: ExploreId;
   exploreId: ExploreId;
   initialDatasource?: string;
   initialDatasource?: string;
   initialQueries: DataQuery[];
   initialQueries: DataQuery[];
   initializeExplore: typeof initializeExplore;
   initializeExplore: typeof initializeExplore;
   initialized: boolean;
   initialized: boolean;
-  loading: boolean;
   modifyQueries: typeof modifyQueries;
   modifyQueries: typeof modifyQueries;
   range: RawTimeRange;
   range: RawTimeRange;
-  runQueries: typeof runQueries;
   scanner?: RangeScanner;
   scanner?: RangeScanner;
   scanning?: boolean;
   scanning?: boolean;
   scanRange?: RawTimeRange;
   scanRange?: RawTimeRange;
@@ -69,8 +58,6 @@ interface ExploreProps {
   scanStop: typeof scanStop;
   scanStop: typeof scanStop;
   setQueries: typeof setQueries;
   setQueries: typeof setQueries;
   split: boolean;
   split: boolean;
-  splitClose: typeof splitClose;
-  splitOpen: typeof splitOpen;
   showingStartPage?: boolean;
   showingStartPage?: boolean;
   supportsGraph: boolean | null;
   supportsGraph: boolean | null;
   supportsLogs: boolean | null;
   supportsLogs: boolean | null;
@@ -145,10 +132,6 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.el = el;
     this.el = el;
   };
   };
 
 
-  onChangeDatasource = async option => {
-    this.props.changeDatasource(this.props.exploreId, option.value);
-  };
-
   onChangeTime = (range: TimeRange, changedByScanner?: boolean) => {
   onChangeTime = (range: TimeRange, changedByScanner?: boolean) => {
     if (this.props.scanning && !changedByScanner) {
     if (this.props.scanning && !changedByScanner) {
       this.onStopScanning();
       this.onStopScanning();
@@ -156,23 +139,11 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.props.changeTime(this.props.exploreId, range);
     this.props.changeTime(this.props.exploreId, range);
   };
   };
 
 
-  onClickClear = () => {
-    this.props.clearQueries(this.props.exploreId);
-  };
-
-  onClickCloseSplit = () => {
-    this.props.splitClose();
-  };
-
   // Use this in help pages to set page to a single query
   // Use this in help pages to set page to a single query
   onClickExample = (query: DataQuery) => {
   onClickExample = (query: DataQuery) => {
     this.props.setQueries(this.props.exploreId, [query]);
     this.props.setQueries(this.props.exploreId, [query]);
   };
   };
 
 
-  onClickSplit = () => {
-    this.props.splitOpen();
-  };
-
   onClickLabel = (key: string, value: string) => {
   onClickLabel = (key: string, value: string) => {
     this.onModifyQueries({ type: 'ADD_FILTER', key, value });
     this.onModifyQueries({ type: 'ADD_FILTER', key, value });
   };
   };
@@ -204,10 +175,6 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.props.scanStop(this.props.exploreId);
     this.props.scanStop(this.props.exploreId);
   };
   };
 
 
-  onSubmit = () => {
-    this.props.runQueries(this.props.exploreId);
-  };
-
   render() {
   render() {
     const {
     const {
       StartPage,
       StartPage,
@@ -215,11 +182,8 @@ export class Explore extends React.PureComponent<ExploreProps> {
       datasourceError,
       datasourceError,
       datasourceLoading,
       datasourceLoading,
       datasourceMissing,
       datasourceMissing,
-      exploreDatasources,
       exploreId,
       exploreId,
-      loading,
       initialQueries,
       initialQueries,
-      range,
       showingStartPage,
       showingStartPage,
       split,
       split,
       supportsGraph,
       supportsGraph,
@@ -227,64 +191,10 @@ export class Explore extends React.PureComponent<ExploreProps> {
       supportsTable,
       supportsTable,
     } = this.props;
     } = this.props;
     const exploreClass = split ? 'explore explore-split' : 'explore';
     const exploreClass = split ? 'explore explore-split' : 'explore';
-    const selectedDatasource = datasourceInstance
-      ? exploreDatasources.find(d => d.name === datasourceInstance.name)
-      : undefined;
 
 
     return (
     return (
       <div className={exploreClass} ref={this.getRef}>
       <div className={exploreClass} ref={this.getRef}>
-        <div className="navbar">
-          {exploreId === 'left' ? (
-            <div>
-              <a className="navbar-page-btn">
-                <i className="fa fa-rocket" />
-                Explore
-              </a>
-            </div>
-          ) : (
-            <>
-              <div className="navbar-page-btn" />
-              <div className="navbar-buttons explore-first-button">
-                <button className="btn navbar-button" onClick={this.onClickCloseSplit}>
-                  Close Split
-                </button>
-              </div>
-            </>
-          )}
-          {!datasourceMissing ? (
-            <div className="navbar-buttons">
-              <DataSourcePicker
-                onChange={this.onChangeDatasource}
-                datasources={exploreDatasources}
-                current={selectedDatasource}
-              />
-            </div>
-          ) : null}
-          <div className="navbar__spacer" />
-          {exploreId === 'left' && !split ? (
-            <div className="navbar-buttons">
-              <button className="btn navbar-button" onClick={this.onClickSplit}>
-                Split
-              </button>
-            </div>
-          ) : null}
-          <TimePicker ref={this.timepickerRef} range={range} onChangeTime={this.onChangeTime} />
-          <div className="navbar-buttons">
-            <button className="btn navbar-button navbar-button--no-icon" onClick={this.onClickClear}>
-              Clear All
-            </button>
-          </div>
-          <div className="navbar-buttons relative">
-            <button className="btn navbar-button navbar-button--primary" onClick={this.onSubmit}>
-              Run Query{' '}
-              {loading ? (
-                <i className="fa fa-spinner fa-fw fa-spin run-icon" />
-              ) : (
-                <i className="fa fa-level-down fa-fw run-icon" />
-              )}
-            </button>
-          </div>
-        </div>
+        <ExploreToolbar exploreId={exploreId} timepickerRef={this.timepickerRef} onChangeTime={this.onChangeTime} />
         {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
         {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
         {datasourceMissing ? (
         {datasourceMissing ? (
           <div className="explore-container">Please add a datasource that supports Explore (e.g., Prometheus).</div>
           <div className="explore-container">Please add a datasource that supports Explore (e.g., Prometheus).</div>
@@ -341,30 +251,24 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     datasourceInstance,
     datasourceInstance,
     datasourceLoading,
     datasourceLoading,
     datasourceMissing,
     datasourceMissing,
-    exploreDatasources,
     initialDatasource,
     initialDatasource,
     initialQueries,
     initialQueries,
     initialized,
     initialized,
-    queryTransactions,
     range,
     range,
     showingStartPage,
     showingStartPage,
     supportsGraph,
     supportsGraph,
     supportsLogs,
     supportsLogs,
     supportsTable,
     supportsTable,
   } = item;
   } = item;
-  const loading = queryTransactions.some(qt => !qt.done);
   return {
   return {
     StartPage,
     StartPage,
     datasourceError,
     datasourceError,
     datasourceInstance,
     datasourceInstance,
     datasourceLoading,
     datasourceLoading,
     datasourceMissing,
     datasourceMissing,
-    exploreDatasources,
     initialDatasource,
     initialDatasource,
     initialQueries,
     initialQueries,
     initialized,
     initialized,
-    loading,
-    queryTransactions,
     range,
     range,
     showingStartPage,
     showingStartPage,
     split,
     split,
@@ -375,18 +279,13 @@ function mapStateToProps(state: StoreState, { exploreId }) {
 }
 }
 
 
 const mapDispatchToProps = {
 const mapDispatchToProps = {
-  changeDatasource,
   changeSize,
   changeSize,
   changeTime,
   changeTime,
-  clearQueries,
   initializeExplore,
   initializeExplore,
   modifyQueries,
   modifyQueries,
-  runQueries,
   scanStart,
   scanStart,
   scanStop,
   scanStop,
   setQueries,
   setQueries,
-  splitClose,
-  splitOpen,
 };
 };
 
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Explore));
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Explore));

+ 191 - 0
public/app/features/explore/ExploreToolbar.tsx

@@ -0,0 +1,191 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { hot } from 'react-hot-loader';
+
+import { ExploreId } from 'app/types/explore';
+import { DataSourceSelectItem, RawTimeRange, TimeRange } from '@grafana/ui';
+import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
+import { StoreState } from 'app/types/store';
+import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions';
+import TimePicker from './TimePicker';
+
+enum IconSide {
+  left = 'left',
+  right = 'right',
+}
+
+const createResponsiveButton = (options: {
+  splitted: boolean;
+  title: string;
+  onClick: () => void;
+  buttonClassName?: string;
+  iconClassName?: string;
+  iconSide?: IconSide;
+}) => {
+  const defaultOptions = {
+    iconSide: IconSide.left,
+  };
+  const props = { ...options, defaultOptions };
+  const { title, onClick, buttonClassName, iconClassName, splitted, iconSide } = props;
+
+  return (
+    <button className={`btn navbar-button ${buttonClassName ? buttonClassName : ''}`} onClick={onClick}>
+      {iconClassName && iconSide === IconSide.left ? <i className={`${iconClassName} icon-margin-right`} /> : null}
+      <span className="btn-title">{!splitted ? title : ''}</span>
+      {iconClassName && iconSide === IconSide.right ? <i className={`${iconClassName} icon-margin-left`} /> : null}
+    </button>
+  );
+};
+
+interface OwnProps {
+  exploreId: ExploreId;
+  timepickerRef: React.RefObject<TimePicker>;
+  onChangeTime: (range: TimeRange, changedByScanner?: boolean) => void;
+}
+
+interface StateProps {
+  datasourceMissing: boolean;
+  exploreDatasources: DataSourceSelectItem[];
+  loading: boolean;
+  range: RawTimeRange;
+  selectedDatasource: DataSourceSelectItem;
+  splitted: boolean;
+}
+
+interface DispatchProps {
+  changeDatasource: typeof changeDatasource;
+  clearAll: typeof clearQueries;
+  runQuery: typeof runQueries;
+  closeSplit: typeof splitClose;
+  split: typeof splitOpen;
+}
+
+type Props = StateProps & DispatchProps & OwnProps;
+
+export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
+  constructor(props) {
+    super(props);
+  }
+
+  onChangeDatasource = async option => {
+    this.props.changeDatasource(this.props.exploreId, option.value);
+  };
+
+  onClearAll = () => {
+    this.props.clearAll(this.props.exploreId);
+  };
+
+  onRunQuery = () => {
+    this.props.runQuery(this.props.exploreId);
+  };
+
+  render() {
+    const {
+      datasourceMissing,
+      exploreDatasources,
+      exploreId,
+      loading,
+      range,
+      selectedDatasource,
+      splitted,
+      timepickerRef,
+    } = this.props;
+
+    return (
+      <div className={splitted ? 'explore-toolbar splitted' : 'explore-toolbar'}>
+        <div className="explore-toolbar-item">
+          <div className="explore-toolbar-header">
+            <div className="explore-toolbar-header-title">
+              {exploreId === 'left' && (
+                <a className="navbar-page-btn">
+                  <i className="fa fa-rocket fa-fw" />
+                  Explore
+                </a>
+              )}
+            </div>
+            <div className="explore-toolbar-header-close">
+              {exploreId === 'right' && (
+                <a onClick={this.props.closeSplit}>
+                  <i className="fa fa-times fa-fw" />
+                </a>
+              )}
+            </div>
+          </div>
+        </div>
+        <div className="explore-toolbar-item">
+          <div className="explore-toolbar-content">
+            {!datasourceMissing ? (
+              <div className="explore-toolbar-content-item">
+                <div className="datasource-picker">
+                  <DataSourcePicker
+                    onChange={this.onChangeDatasource}
+                    datasources={exploreDatasources}
+                    current={selectedDatasource}
+                  />
+                </div>
+              </div>
+            ) : null}
+            {exploreId === 'left' && !splitted ? (
+              <div className="explore-toolbar-content-item">
+                {createResponsiveButton({
+                  splitted,
+                  title: 'Split',
+                  onClick: this.props.split,
+                  iconClassName: 'fa fa-fw fa-columns icon-margin-right',
+                  iconSide: IconSide.left,
+                })}
+              </div>
+            ) : null}
+            <div className="explore-toolbar-content-item timepicker">
+              <TimePicker ref={timepickerRef} range={range} onChangeTime={this.props.onChangeTime} />
+            </div>
+            <div className="explore-toolbar-content-item">
+              <button className="btn navbar-button navbar-button--no-icon" onClick={this.onClearAll}>
+                Clear All
+              </button>
+            </div>
+            <div className="explore-toolbar-content-item">
+              {createResponsiveButton({
+                splitted,
+                title: 'Run Query',
+                onClick: this.onRunQuery,
+                buttonClassName: 'navbar-button--primary',
+                iconClassName: loading ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-level-down fa-fw run-icon',
+                iconSide: IconSide.right,
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => {
+  const splitted = state.explore.split;
+  const exploreItem = state.explore[exploreId];
+  const { datasourceInstance, datasourceMissing, exploreDatasources, queryTransactions, range } = exploreItem;
+  const selectedDatasource = datasourceInstance
+    ? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
+    : undefined;
+  const loading = queryTransactions.some(qt => !qt.done);
+
+  return {
+    datasourceMissing,
+    exploreDatasources,
+    loading,
+    range,
+    selectedDatasource,
+    splitted,
+  };
+};
+
+const mapDispatchToProps: DispatchProps = {
+  changeDatasource,
+  clearAll: clearQueries,
+  runQuery: runQueries,
+  closeSplit: splitClose,
+  split: splitOpen,
+};
+
+export const ExploreToolbar = hot(module)(connect(mapStateToProps, mapDispatchToProps)(UnConnectedExploreToolbar));

+ 12 - 7
public/app/features/explore/QueryField.tsx

@@ -73,6 +73,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
   placeholdersBuffer: PlaceholdersBuffer;
   placeholdersBuffer: PlaceholdersBuffer;
   plugins: any[];
   plugins: any[];
   resetTimer: any;
   resetTimer: any;
+  mounted: boolean;
 
 
   constructor(props: QueryFieldProps, context) {
   constructor(props: QueryFieldProps, context) {
     super(props, context);
     super(props, context);
@@ -93,10 +94,12 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
+    this.mounted = true;
     this.updateMenu();
     this.updateMenu();
   }
   }
 
 
   componentWillUnmount() {
   componentWillUnmount() {
+    this.mounted = false;
     clearTimeout(this.resetTimer);
     clearTimeout(this.resetTimer);
   }
   }
 
 
@@ -347,13 +350,15 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
   };
   };
 
 
   resetTypeahead = () => {
   resetTypeahead = () => {
-    this.setState({
-      suggestions: [],
-      typeaheadIndex: 0,
-      typeaheadPrefix: '',
-      typeaheadContext: null,
-    });
-    this.resetTimer = null;
+    if (this.mounted) {
+      this.setState({
+        suggestions: [],
+        typeaheadIndex: 0,
+        typeaheadPrefix: '',
+        typeaheadContext: null,
+      });
+      this.resetTimer = null;
+    }
   };
   };
 
 
   handleBlur = () => {
   handleBlur = () => {

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

@@ -293,6 +293,7 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
 
 
   render() {
   render() {
     const { isUtc, rangeString, refreshInterval } = this.state;
     const { isUtc, rangeString, refreshInterval } = this.state;
+
     return (
     return (
       <div className="timepicker">
       <div className="timepicker">
         <div className="navbar-buttons">
         <div className="navbar-buttons">

+ 21 - 10
public/app/features/explore/Wrapper.tsx

@@ -7,14 +7,16 @@ import { StoreState } from 'app/types';
 import { ExploreId, ExploreUrlState } from 'app/types/explore';
 import { ExploreId, ExploreUrlState } from 'app/types/explore';
 import { parseUrlState } from 'app/core/utils/explore';
 import { parseUrlState } from 'app/core/utils/explore';
 
 
-import { initializeExploreSplit } from './state/actions';
+import { initializeExploreSplit, resetExplore } from './state/actions';
 import ErrorBoundary from './ErrorBoundary';
 import ErrorBoundary from './ErrorBoundary';
 import Explore from './Explore';
 import Explore from './Explore';
+import { CustomScrollbar } from '@grafana/ui';
 
 
 interface WrapperProps {
 interface WrapperProps {
   initializeExploreSplit: typeof initializeExploreSplit;
   initializeExploreSplit: typeof initializeExploreSplit;
   split: boolean;
   split: boolean;
   updateLocation: typeof updateLocation;
   updateLocation: typeof updateLocation;
+  resetExplore: typeof resetExplore;
   urlStates: { [key: string]: string };
   urlStates: { [key: string]: string };
 }
 }
 
 
@@ -41,20 +43,28 @@ export class Wrapper extends Component<WrapperProps> {
     }
     }
   }
   }
 
 
+  componentWillUnmount() {
+    this.props.resetExplore();
+  }
+
   render() {
   render() {
     const { split } = this.props;
     const { split } = this.props;
     const { leftState, rightState } = this.urlStates;
     const { leftState, rightState } = this.urlStates;
 
 
     return (
     return (
-      <div className="explore-wrapper">
-        <ErrorBoundary>
-          <Explore exploreId={ExploreId.left} urlState={leftState} />
-        </ErrorBoundary>
-        {split && (
-          <ErrorBoundary>
-            <Explore exploreId={ExploreId.right} urlState={rightState} />
-          </ErrorBoundary>
-        )}
+      <div className="page-scrollbar-wrapper">
+        <CustomScrollbar autoHeightMin={'100%'}>
+          <div className="explore-wrapper">
+            <ErrorBoundary>
+              <Explore exploreId={ExploreId.left} urlState={leftState} />
+            </ErrorBoundary>
+            {split && (
+              <ErrorBoundary>
+                <Explore exploreId={ExploreId.right} urlState={rightState} />
+              </ErrorBoundary>
+            )}
+          </div>
+        </CustomScrollbar>
       </div>
       </div>
     );
     );
   }
   }
@@ -69,6 +79,7 @@ const mapStateToProps = (state: StoreState) => {
 const mapDispatchToProps = {
 const mapDispatchToProps = {
   initializeExploreSplit,
   initializeExploreSplit,
   updateLocation,
   updateLocation,
+  resetExplore,
 };
 };
 
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper));
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper));

+ 9 - 2
public/app/features/explore/state/actionTypes.ts

@@ -1,6 +1,6 @@
 // Types
 // Types
 import { Emitter } from 'app/core/core';
 import { Emitter } from 'app/core/core';
-import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem, DataSourceApi  } from '@grafana/ui/src/types';
+import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem, DataSourceApi } from '@grafana/ui/src/types';
 import {
 import {
   ExploreId,
   ExploreId,
   ExploreItemState,
   ExploreItemState,
@@ -42,6 +42,7 @@ export enum ActionTypes {
   ToggleLogs = 'explore/TOGGLE_LOGS',
   ToggleLogs = 'explore/TOGGLE_LOGS',
   ToggleTable = 'explore/TOGGLE_TABLE',
   ToggleTable = 'explore/TOGGLE_TABLE',
   UpdateDatasourceInstance = 'explore/UPDATE_DATASOURCE_INSTANCE',
   UpdateDatasourceInstance = 'explore/UPDATE_DATASOURCE_INSTANCE',
+  ResetExplore = 'explore/RESET_EXPLORE',
 }
 }
 
 
 export interface AddQueryRowAction {
 export interface AddQueryRowAction {
@@ -279,6 +280,11 @@ export interface UpdateDatasourceInstanceAction {
   };
   };
 }
 }
 
 
+export interface ResetExploreAction {
+  type: ActionTypes.ResetExplore;
+  payload: {};
+}
+
 export type Action =
 export type Action =
   | AddQueryRowAction
   | AddQueryRowAction
   | ChangeQueryAction
   | ChangeQueryAction
@@ -307,4 +313,5 @@ export type Action =
   | ToggleGraphAction
   | ToggleGraphAction
   | ToggleLogsAction
   | ToggleLogsAction
   | ToggleTableAction
   | ToggleTableAction
-  | UpdateDatasourceInstanceAction;
+  | UpdateDatasourceInstanceAction
+  | ResetExploreAction;

+ 9 - 0
public/app/features/explore/state/actions.ts

@@ -783,3 +783,12 @@ export function toggleTable(exploreId: ExploreId): ThunkResult<void> {
     }
     }
   };
   };
 }
 }
+
+/**
+ * Resets state for explore.
+ */
+export function resetExplore(): ThunkResult<void> {
+  return dispatch => {
+    dispatch({ type: ActionTypes.ResetExplore, payload: {} });
+  };
+}

+ 7 - 13
public/app/features/explore/state/reducers.ts

@@ -436,25 +436,19 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
 export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
 export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
   switch (action.type) {
   switch (action.type) {
     case ActionTypes.SplitClose: {
     case ActionTypes.SplitClose: {
-      return {
-        ...state,
-        split: false,
-      };
+      return { ...state, split: false };
     }
     }
 
 
     case ActionTypes.SplitOpen: {
     case ActionTypes.SplitOpen: {
-      return {
-        ...state,
-        split: true,
-        right: action.payload.itemState,
-      };
+      return { ...state, split: true, right: action.payload.itemState };
     }
     }
 
 
     case ActionTypes.InitializeExploreSplit: {
     case ActionTypes.InitializeExploreSplit: {
-      return {
-        ...state,
-        split: true,
-      };
+      return { ...state, split: true };
+    }
+
+    case ActionTypes.ResetExplore: {
+      return initialExploreState;
     }
     }
   }
   }
 
 

+ 2 - 1
public/app/features/manage-dashboards/CreateFolderCtrl.ts → public/app/features/folders/CreateFolderCtrl.ts

@@ -1,7 +1,7 @@
 import appEvents from 'app/core/app_events';
 import appEvents from 'app/core/app_events';
 import locationUtil from 'app/core/utils/location_util';
 import locationUtil from 'app/core/utils/location_util';
 
 
-export class CreateFolderCtrl {
+export default class CreateFolderCtrl {
   title = '';
   title = '';
   navModel: any;
   navModel: any;
   titleTouched = false;
   titleTouched = false;
@@ -38,3 +38,4 @@ export class CreateFolderCtrl {
       });
       });
   }
   }
 }
 }
+

+ 2 - 1
public/app/features/manage-dashboards/FolderDashboardsCtrl.ts → public/app/features/folders/FolderDashboardsCtrl.ts

@@ -1,7 +1,7 @@
 import { FolderPageLoader } from './services/FolderPageLoader';
 import { FolderPageLoader } from './services/FolderPageLoader';
 import locationUtil from 'app/core/utils/location_util';
 import locationUtil from 'app/core/utils/location_util';
 
 
-export class FolderDashboardsCtrl {
+export default class FolderDashboardsCtrl {
   navModel: any;
   navModel: any;
   folderId: number;
   folderId: number;
   uid: string;
   uid: string;
@@ -23,3 +23,4 @@ export class FolderDashboardsCtrl {
     }
     }
   }
   }
 }
 }
+

+ 0 - 0
public/app/features/dashboard/partials/create_folder.html → public/app/features/folders/partials/create_folder.html


+ 0 - 0
public/app/features/dashboard/partials/folder_dashboards.html → public/app/features/folders/partials/folder_dashboards.html


+ 0 - 0
public/app/features/manage-dashboards/services/FolderPageLoader.ts → public/app/features/folders/services/FolderPageLoader.ts


+ 2 - 0
public/app/features/manage-dashboards/DashboardImportCtrl.ts

@@ -232,3 +232,5 @@ export class DashboardImportCtrl {
     this.gnetInfo = '';
     this.gnetInfo = '';
   }
   }
 }
 }
+
+export default DashboardImportCtrl;

+ 0 - 6
public/app/features/manage-dashboards/index.ts

@@ -8,14 +8,8 @@ export * from './components/UploadDashboard';
 // Controllers
 // Controllers
 import { DashboardListCtrl } from './DashboardListCtrl';
 import { DashboardListCtrl } from './DashboardListCtrl';
 import { SnapshotListCtrl } from './SnapshotListCtrl';
 import { SnapshotListCtrl } from './SnapshotListCtrl';
-import { FolderDashboardsCtrl } from './FolderDashboardsCtrl';
-import { DashboardImportCtrl } from './DashboardImportCtrl';
-import { CreateFolderCtrl } from './CreateFolderCtrl';
 
 
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 
 
 coreModule.controller('DashboardListCtrl', DashboardListCtrl);
 coreModule.controller('DashboardListCtrl', DashboardListCtrl);
 coreModule.controller('SnapshotListCtrl', SnapshotListCtrl);
 coreModule.controller('SnapshotListCtrl', SnapshotListCtrl);
-coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
-coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
-coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

+ 0 - 0
public/app/features/dashboard/partials/dashboard_import.html → public/app/features/manage-dashboards/partials/dashboard_import.html


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

@@ -4,3 +4,5 @@ import './solo_panel_ctrl';
 import './query_ctrl';
 import './query_ctrl';
 import './panel_editor_tab';
 import './panel_editor_tab';
 import './query_editor_row';
 import './query_editor_row';
+import './repeat_option';
+import './panellinks/module';

+ 0 - 13
public/app/features/panel/panel_ctrl.ts

@@ -290,17 +290,4 @@ export class PanelCtrl {
     html += '</div>';
     html += '</div>';
     return sanitize(html);
     return sanitize(html);
   }
   }
-
-  openInspector() {
-    const modalScope = this.$scope.$new();
-    modalScope.panel = this.panel;
-    modalScope.dashboard = this.dashboard;
-    modalScope.panelInfoHtml = this.getInfoContent({ mode: 'inspector' });
-
-    modalScope.inspector = $.extend(true, {}, this.inspector);
-    this.publishAppEvent('show-modal', {
-      src: 'public/app/features/dashboard/partials/inspector.html',
-      scope: modalScope,
-    });
-  }
 }
 }

+ 0 - 5
public/app/features/panel/panel_directive.ts

@@ -192,11 +192,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
       scope.$watchGroup(['ctrl.error', 'ctrl.panel.description'], updatePanelCornerInfo);
       scope.$watchGroup(['ctrl.error', 'ctrl.panel.description'], updatePanelCornerInfo);
       scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo);
       scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo);
 
 
-      cornerInfoElem.on('click', () => {
-        infoDrop.close();
-        scope.$apply(ctrl.openInspector.bind(ctrl));
-      });
-
       elem.on('mouseenter', mouseEnter);
       elem.on('mouseenter', mouseEnter);
       elem.on('mouseleave', mouseLeave);
       elem.on('mouseleave', mouseLeave);
 
 

+ 0 - 0
public/app/features/dashboard/panellinks/link_srv.ts → public/app/features/panel/panellinks/link_srv.ts


+ 0 - 0
public/app/features/dashboard/panellinks/module.html → public/app/features/panel/panellinks/module.html


+ 0 - 0
public/app/features/dashboard/panellinks/module.ts → public/app/features/panel/panellinks/module.ts


+ 0 - 0
public/app/features/dashboard/panellinks/specs/link_srv.test.ts → public/app/features/panel/panellinks/specs/link_srv.test.ts


+ 0 - 0
public/app/features/dashboard/repeat_option/repeat_option.ts → public/app/features/panel/repeat_option.ts


+ 11 - 0
public/app/features/templating/specs/template_srv.test.ts

@@ -469,6 +469,11 @@ describe('templateSrv', () => {
           name: 'empty_on_init',
           name: 'empty_on_init',
           current: { value: '', text: '' },
           current: { value: '', text: '' },
         },
         },
+        {
+          type: 'custom',
+          name: 'foo',
+          current: { value: 'constructor', text: 'constructor' },
+        }
       ]);
       ]);
       _templateSrv.setGrafanaVariable('$__auto_interval_interval', '13m');
       _templateSrv.setGrafanaVariable('$__auto_interval_interval', '13m');
       _templateSrv.updateTemplateData();
       _templateSrv.updateTemplateData();
@@ -483,6 +488,12 @@ describe('templateSrv', () => {
       const target = _templateSrv.replaceWithText('Hello $empty_on_init');
       const target = _templateSrv.replaceWithText('Hello $empty_on_init');
       expect(target).toBe('Hello ');
       expect(target).toBe('Hello ');
     });
     });
+
+    it('should not return a string representation of a constructor property', () => {
+      const target = _templateSrv.replaceWithText('$foo');
+      expect(target).not.toBe('function Object() { [native code] }');
+      expect(target).toBe('constructor');
+    });
   });
   });
 
 
   describe('built in interval variables', () => {
   describe('built in interval variables', () => {

+ 3 - 1
public/app/features/templating/template_srv.ts

@@ -254,7 +254,9 @@ export class TemplateSrv {
         return match;
         return match;
       }
       }
 
 
-      return this.grafanaVariables[variable.current.value] || variable.current.text;
+      const value = this.grafanaVariables[variable.current.value];
+
+      return typeof(value) === 'string' ? value : variable.current.text;
     });
     });
   }
   }
 
 

+ 1 - 1
public/app/plugins/panel/singlestat/module.ts

@@ -2,7 +2,7 @@ import _ from 'lodash';
 import $ from 'jquery';
 import $ from 'jquery';
 import 'vendor/flot/jquery.flot';
 import 'vendor/flot/jquery.flot';
 import 'vendor/flot/jquery.flot.gauge';
 import 'vendor/flot/jquery.flot.gauge';
-import 'app/features/dashboard/panellinks/link_srv';
+import 'app/features/panel/panellinks/link_srv';
 
 
 import kbn from 'app/core/utils/kbn';
 import kbn from 'app/core/utils/kbn';
 import config from 'app/core/config';
 import config from 'app/core/config';

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików