Browse Source

Merge branch 'master' into core/theming

Torkel Ödegaard 7 years ago
parent
commit
bb8bec5aaa
100 changed files with 9013 additions and 1484 deletions
  1. 1 0
      .gitignore
  2. 1 0
      CHANGELOG.md
  3. 17 21
      conf/defaults.ini
  4. 18 22
      conf/sample.ini
  5. 54 0
      devenv/docker/blocks/freeipa/docker-compose.yaml
  6. 74 0
      devenv/docker/blocks/freeipa/ldap_freeipa.toml
  7. 32 0
      devenv/docker/blocks/freeipa/notes.md
  8. 13 8
      devenv/docker/ha_test/docker-compose.yaml
  9. 6 0
      devenv/docker/ha_test/grafana/provisioning/dashboards/dashboards.yaml
  10. 5397 0
      devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json
  11. 7 7
      devenv/docker/ha_test/prometheus/prometheus.yml
  12. 13 1
      devenv/docker/loadtest/README.md
  13. 1 1
      devenv/docker/loadtest/auth_token_test.js
  14. 6 2
      devenv/docker/loadtest/run.sh
  15. 29 0
      docs/sources/auth/overview.md
  16. 8 0
      docs/sources/installation/configuration.md
  17. 1 0
      package.json
  18. 8 32
      pkg/api/common_test.go
  19. 1 0
      pkg/api/dtos/playlist.go
  20. 8 9
      pkg/api/http_server.go
  21. 13 5
      pkg/api/login.go
  22. 2 1
      pkg/api/login_oauth.go
  23. 1 0
      pkg/api/playlist_play.go
  24. 1 0
      pkg/cmd/grafana-server/server.go
  25. 20 8
      pkg/login/ldap.go
  26. 45 3
      pkg/login/ldap_test.go
  27. 68 3
      pkg/middleware/middleware.go
  28. 134 15
      pkg/middleware/middleware_test.go
  29. 19 10
      pkg/middleware/org_redirect_test.go
  30. 11 4
      pkg/middleware/quota_test.go
  31. 1 0
      pkg/models/context.go
  32. 32 0
      pkg/models/user_token.go
  33. 82 136
      pkg/services/auth/auth_token.go
  34. 243 140
      pkg/services/auth/auth_token_test.go
  35. 50 5
      pkg/services/auth/model.go
  36. 0 38
      pkg/services/auth/session_cleanup.go
  37. 0 36
      pkg/services/auth/session_cleanup_test.go
  38. 57 0
      pkg/services/auth/token_cleanup.go
  39. 68 0
      pkg/services/auth/token_cleanup_test.go
  40. 46 34
      pkg/setting/setting.go
  41. 2 1
      public/app/core/app_events.ts
  42. 42 0
      public/app/core/components/AlertBox/AlertBox.tsx
  43. 8 12
      public/app/core/components/AppNotifications/AppNotificationItem.tsx
  44. 0 7
      public/app/core/components/Page/Page.tsx
  45. 0 40
      public/app/core/components/gf_page.ts
  46. 0 43
      public/app/core/components/scroll/page_scroll.ts
  47. 1 1
      public/app/core/config.ts
  48. 9 6
      public/app/core/copy/appNotification.ts
  49. 0 4
      public/app/core/core.ts
  50. 3 1
      public/app/core/reducers/location.ts
  51. 15 0
      public/app/core/redux/actionCreatorFactory.ts
  52. 2 4
      public/app/core/redux/index.ts
  53. 14 0
      public/app/core/services/__mocks__/backend_srv.ts
  54. 4 0
      public/app/core/services/bridge_srv.ts
  55. 4 6
      public/app/core/services/keybindingSrv.ts
  56. 17 0
      public/app/core/utils/errors.ts
  57. 1 1
      public/app/core/utils/location_util.ts
  58. 14 1
      public/app/core/utils/version.ts
  59. 6 1
      public/app/features/annotations/editor_ctrl.ts
  60. 4 2
      public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
  61. 3 1
      public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
  62. 253 0
      public/app/features/dashboard/components/DashNav/DashNav.tsx
  63. 33 0
      public/app/features/dashboard/components/DashNav/DashNavButton.tsx
  64. 0 119
      public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
  65. 2 1
      public/app/features/dashboard/components/DashNav/index.ts
  66. 0 61
      public/app/features/dashboard/components/DashNav/template.html
  67. 1 0
      public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
  68. 2 2
      public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
  69. 36 0
      public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx
  70. 1 0
      public/app/features/dashboard/components/DashboardSettings/index.ts
  71. 36 0
      public/app/features/dashboard/components/SubMenu/SubMenu.tsx
  72. 1 0
      public/app/features/dashboard/components/SubMenu/index.ts
  73. 1 1
      public/app/features/dashboard/components/SubMenu/template.html
  74. 0 156
      public/app/features/dashboard/containers/DashboardCtrl.ts
  75. 251 0
      public/app/features/dashboard/containers/DashboardPage.test.tsx
  76. 309 0
      public/app/features/dashboard/containers/DashboardPage.tsx
  77. 39 52
      public/app/features/dashboard/containers/SoloPanelPage.tsx
  78. 546 0
      public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap
  79. 27 14
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  80. 0 2
      public/app/features/dashboard/index.ts
  81. 68 19
      public/app/features/dashboard/services/DashboardSrv.ts
  82. 0 64
      public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
  83. 0 185
      public/app/features/dashboard/services/DashboardViewStateSrv.ts
  84. 34 11
      public/app/features/dashboard/state/DashboardModel.ts
  85. 28 23
      public/app/features/dashboard/state/actions.ts
  86. 152 0
      public/app/features/dashboard/state/initDashboard.test.ts
  87. 233 0
      public/app/features/dashboard/state/initDashboard.ts
  88. 56 9
      public/app/features/dashboard/state/reducers.test.ts
  89. 78 9
      public/app/features/dashboard/state/reducers.ts
  90. 28 22
      public/app/features/explore/Explore.tsx
  91. 2 2
      public/app/features/explore/ExploreToolbar.tsx
  92. 2 3
      public/app/features/panel/panel_ctrl.ts
  93. 18 5
      public/app/features/playlist/playlist_srv.ts
  94. 2 6
      public/app/features/playlist/specs/playlist_srv.test.ts
  95. 14 0
      public/app/features/profile/state/reducers.ts
  96. 0 1
      public/app/features/templating/specs/variable_srv.test.ts
  97. 1 5
      public/app/features/templating/specs/variable_srv_init.test.ts
  98. 5 5
      public/app/features/templating/variable_srv.ts
  99. 0 17
      public/app/partials/dashboard.html
  100. 17 18
      public/app/routes/GrafanaCtrl.ts

+ 1 - 0
.gitignore

@@ -46,6 +46,7 @@ devenv/docker-compose.yaml
 /conf/provisioning/**/custom.yaml
 /conf/provisioning/**/dev.yaml
 /conf/ldap_dev.toml
+/conf/ldap_freeipa.toml
 profile.cov
 /grafana
 /local

+ 1 - 0
CHANGELOG.md

@@ -7,6 +7,7 @@
 * **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson)
 * **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen)
 * **Annotations**: Support PATCH verb in annotations http api [#12546](https://github.com/grafana/grafana/issues/12546), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Templating**: Add json formatting to variable interpolation [#15291](https://github.com/grafana/grafana/issues/15291), thx [@mtanda](https://github.com/mtanda)
 
 # 6.0.0-beta1 (2019-01-30)
 

+ 17 - 21
conf/defaults.ini

@@ -106,25 +106,6 @@ path = grafana.db
 # For "sqlite3" only. cache mode setting used for connecting to the database
 cache_mode = private
 
-#################################### Login ###############################
-
-[login]
-
-# Login cookie name
-cookie_name = grafana_session
-
-# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
-cookie_samesite = lax
-
-# 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]
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -206,8 +187,11 @@ data_source_proxy_whitelist =
 # disable protection against brute force login attempts
 disable_brute_force_login_protection = false
 
-# set cookies as https only. default is false
-https_flag_cookies = false
+# set to true if you host Grafana behind HTTPS. default is false.
+cookie_secure = false
+
+# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none"
+cookie_samesite = lax
 
 #################################### Snapshots ###########################
 [snapshots]
@@ -260,6 +244,18 @@ external_manage_info =
 viewers_can_edit = false
 
 [auth]
+# Login cookie name
+login_cookie_name = grafana_session
+
+# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days.
+login_maximum_inactive_lifetime_days = 7
+
+# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
+login_maximum_lifetime_days = 30
+
+# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
+token_rotation_interval_minutes = 10
+
 # Set to true to disable (hide) the login form, useful if you use OAuth
 disable_login_form = false
 

+ 18 - 22
conf/sample.ini

@@ -102,25 +102,6 @@ log_queries =
 # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
 ;cache_mode = private
 
-#################################### Login ###############################
-
-[login]
-
-# Login cookie name
-;cookie_name = grafana_session
-
-# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
-;cookie_samesite = lax
-
-# 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]
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -193,8 +174,11 @@ log_queries =
 # disable protection against brute force login attempts
 ;disable_brute_force_login_protection = false
 
-# set cookies as https only. default is false
-;https_flag_cookies = false
+# set to true if you host Grafana behind HTTPS. default is false.
+;cookie_secure = false
+
+# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none"
+;cookie_samesite = lax
 
 #################################### Snapshots ###########################
 [snapshots]
@@ -240,6 +224,18 @@ log_queries =
 ;viewers_can_edit = false
 
 [auth]
+# Login cookie name
+;login_cookie_name = grafana_session
+
+# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days,
+;login_maximum_inactive_lifetime_days = 7
+
+# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
+;login_maximum_lifetime_days = 30
+
+# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
+;token_rotation_interval_minutes = 10
+
 # Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false
 ;disable_login_form = false
 
@@ -253,7 +249,7 @@ log_queries =
 # This setting is ignored if multiple OAuth providers are configured.
 ;oauth_auto_login = false
 
-#################################### Anonymous Auth ##########################
+#################################### Anonymous Auth ######################
 [auth.anonymous]
 # enable anonymous access
 ;enabled = false

+ 54 - 0
devenv/docker/blocks/freeipa/docker-compose.yaml

@@ -0,0 +1,54 @@
+version: '3'
+
+volumes:
+  freeipa_data: {}
+
+services:
+  freeipa:
+    image: freeipa/freeipa-server:fedora-29
+    container_name: freeipa
+    stdin_open: true
+    tty: true
+    sysctls:
+      - net.ipv6.conf.all.disable_ipv6=0
+    hostname: ipa.example.test
+    environment:
+      # - DEBUG_TRACE=1
+      - IPA_SERVER_IP=172.17.0.2
+      - DEBUG_NO_EXIT=1
+      - IPA_SERVER_HOSTNAME=ipa.example.test
+      - PASSWORD=Secret123
+      - HOSTNAME=ipa.example.test
+    command:
+      - --admin-password=Secret123
+      - --ds-password=Secret123
+      - -U
+      - --realm=EXAMPLE.TEST
+    ports:
+      # FreeIPA WebUI
+      - "80:80"
+      - "443:443"
+      # Kerberos
+      - "88:88/udp"
+      - "88:88"
+      - "464:464/udp"
+      - "464:464"
+      # LDAP
+      - "389:389"
+      - "636:636"
+      # DNS
+      # - "53:53/udp"
+      # - "53:53"
+      # NTP
+      - "123:123/udp"
+      # other
+      - "7389:7389"
+      - "9443:9443"
+      - "9444:9444"
+      - "9445:9445"
+    tmpfs:
+      - /run
+      - /tmp
+    volumes:
+      - freeipa_data:/data:Z
+      - /sys/fs/cgroup:/sys/fs/cgroup:ro

+ 74 - 0
devenv/docker/blocks/freeipa/ldap_freeipa.toml

@@ -0,0 +1,74 @@
+# To troubleshoot and get more log info enable ldap debug logging in grafana.ini
+# [log]
+# filters = ldap:debug
+
+[[servers]]
+# Ldap server host (specify multiple hosts space separated)
+host = "172.17.0.1"
+# Default port is 389 or 636 if use_ssl = true
+port = 389
+# Set to true if ldap server supports TLS
+use_ssl = false
+# Set to true if connect ldap server with STARTTLS pattern (create connection in insecure, then upgrade to secure connection with TLS)
+start_tls = false
+# set to true if you want to skip ssl cert validation
+ssl_skip_verify = false
+# set to the path to your root CA certificate or leave unset to use system defaults
+# root_ca_cert = "/path/to/certificate.crt"
+
+# Search user bind dn
+bind_dn = "uid=admin,cn=users,cn=accounts,dc=example,dc=test"
+# Search user bind password
+# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
+bind_password = 'Secret123'
+
+# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)"
+search_filter = "(uid=%s)"
+
+# An array of base dns to search through
+search_base_dns = ["cn=users,cn=accounts,dc=example,dc=test"]
+
+# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups.
+# This is done by enabling group_search_filter below. You must also set member_of= "cn"
+# in [servers.attributes] below.
+
+# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN
+# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of
+# below in such a way that the user's recursive group membership is considered.
+#
+# Nested Groups + Active Directory (AD) Example:
+#
+#   AD groups store the Distinguished Names (DNs) of members, so your filter must
+#   recursively search your groups for the authenticating user's DN. For example:
+#
+#     group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)"
+#     group_search_filter_user_attribute = "distinguishedName"
+#     group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
+#
+#     [servers.attributes]
+#     ...
+#     member_of = "distinguishedName"
+
+## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available)
+# group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))"
+## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter.
+## Defaults to the value of username in [server.attributes]
+## Valid options are any of your values in [servers.attributes]
+## If you are using nested groups you probably want to set this and member_of in
+## [servers.attributes] to "distinguishedName"
+# group_search_filter_user_attribute = "distinguishedName"
+## An array of the base DNs to search through for groups. Typically uses ou=groups
+# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
+
+# Specify names of the ldap attributes your ldap uses
+[servers.attributes]
+name = "givenName"
+username = "uid"
+member_of = "memberOf"
+# surname = "sn"
+# email =  "mail"
+
+[[servers.group_mappings]]
+# If you want to match all (or no ldap groups) then you can use wildcard
+group_dn = "*"
+org_role = "Viewer"

+ 32 - 0
devenv/docker/blocks/freeipa/notes.md

@@ -0,0 +1,32 @@
+# Notes on FreeIPA LDAP Docker Block
+
+Users have to be created manually. The docker-compose up command takes a few minutes to run.
+
+## Create a user
+
+`docker exec -it freeipa /bin/bash`
+
+To create a user with username: `ldap-viewer` and password: `grafana123`
+
+```bash
+kinit admin
+```
+
+Log in with password `Secret123`
+
+```bash
+ipa user-add ldap-viewer --first ldap --last viewer
+ipa passwd ldap-viewer
+ldappasswd -D uid=ldap-viewer,cn=users,cn=accounts,dc=example,dc=org -w test -a test -s grafana123
+```
+
+## Enabling FreeIPA LDAP in Grafana
+
+Copy the ldap_freeipa.toml file in this folder into your `conf` folder (it is gitignored already). To enable it in the .ini file to get Grafana to use this block:
+
+```ini
+[auth.ldap]
+enabled = true
+config_file = conf/ldap_freeipa.toml
+; allow_sign_up = true
+```

+ 13 - 8
devenv/docker/ha_test/docker-compose.yaml

@@ -15,6 +15,7 @@ services:
       MYSQL_DATABASE: grafana
       MYSQL_USER: grafana
       MYSQL_PASSWORD: password
+    command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all, --max-connections=1001]
     ports:
       - 3306
     healthcheck:
@@ -22,6 +23,16 @@ services:
       timeout: 10s
       retries: 10
 
+  mysqld-exporter:
+    image: prom/mysqld-exporter
+    environment:
+      - DATA_SOURCE_NAME=root:rootpass@(db:3306)/
+    ports:
+      - 9104
+    depends_on:
+      db:
+        condition: service_healthy
+
   # db:
   #   image: postgres:9.3
   #   environment:
@@ -47,6 +58,7 @@ services:
       - GF_DATABASE_PASSWORD=password
       - GF_DATABASE_TYPE=mysql
       - GF_DATABASE_HOST=db:3306
+      - GF_DATABASE_MAX_OPEN_CONN=300
       - GF_SESSION_PROVIDER=mysql
       - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true
       # - GF_DATABASE_TYPE=postgres
@@ -55,7 +67,7 @@ services:
       # - GF_SESSION_PROVIDER=postgres
       # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
       - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
-      - GF_LOGIN_ROTATE_TOKEN_MINUTES=2
+      - GF_AUTH_TOKEN_ROTATION_INTERVAL_MINUTES=2
     ports:
       - 3000
     depends_on:
@@ -70,10 +82,3 @@ services:
       - VIRTUAL_HOST=prometheus.loc
     ports:
       - 9090
-
-  # mysqld-exporter:
-  #   image: prom/mysqld-exporter
-  #   environment:
-  #     - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
-  #   ports:
-  #     - 9104

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

@@ -6,3 +6,9 @@ providers:
    type: file
    options:
      path: /etc/grafana/provisioning/dashboards/alerts
+
+ - name: 'MySQL'
+   folder: 'MySQL'
+   type: file
+   options:
+     path: /etc/grafana/provisioning/dashboards/mysql

+ 5397 - 0
devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json

@@ -0,0 +1,5397 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": false,
+        "hide": true,
+        "iconColor": "#e0752d",
+        "limit": 100,
+        "name": "PMM Annotations",
+        "showIn": 0,
+        "tags": [
+          "pmm_annotation"
+        ],
+        "type": "tags"
+      },
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": false,
+        "hide": true,
+        "iconColor": "#6ed0e0",
+        "limit": 100,
+        "name": "Annotations & Alerts",
+        "showIn": 0,
+        "tags": [
+
+        ],
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 1,
+  "id": null,
+  "iteration": 1540971751770,
+  "links": [
+    {
+      "icon": "dashboard",
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "QAN"
+      ],
+      "targetBlank": false,
+      "title": "Query Analytics",
+      "type": "link",
+      "url": "/graph/dashboard/db/_pmm-query-analytics"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "OS"
+      ],
+      "targetBlank": false,
+      "title": "OS",
+      "type": "dashboards"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "MySQL"
+      ],
+      "targetBlank": false,
+      "title": "MySQL",
+      "type": "dashboards"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "MongoDB"
+      ],
+      "targetBlank": false,
+      "title": "MongoDB",
+      "type": "dashboards"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "PostgreSQL"
+      ],
+      "targetBlank": false,
+      "title": "PostgreSQL",
+      "type": "dashboards"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "HA"
+      ],
+      "targetBlank": false,
+      "title": "HA",
+      "type": "dashboards"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "Cloud"
+      ],
+      "targetBlank": false,
+      "title": "Cloud",
+      "type": "dashboards"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "Insight"
+      ],
+      "targetBlank": false,
+      "title": "Insight",
+      "type": "dashboards"
+    },
+    {
+      "asDropdown": true,
+      "includeVars": true,
+      "keepTime": true,
+      "tags": [
+        "PMM"
+      ],
+      "targetBlank": false,
+      "title": "PMM",
+      "type": "dashboards"
+    }
+  ],
+  "panels": [
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 382,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "",
+      "type": "row"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "datasource": "Prometheus",
+      "decimals": 1,
+      "description": "**MySQL Uptime**\n\nThe amount of time since the last restart of the MySQL server process.",
+      "editable": true,
+      "error": false,
+      "format": "s",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 6,
+        "x": 0,
+        "y": 1
+      },
+      "height": "125px",
+      "id": 12,
+      "interval": "$interval",
+      "links": [
+
+      ],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "s",
+      "postfixFontSize": "80%",
+      "prefix": "",
+      "prefixFontSize": "80%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": false,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "calculatedInterval": "10m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_uptime{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "5m",
+          "intervalFactor": 1,
+          "legendFormat": "",
+          "metric": "",
+          "refId": "A",
+          "step": 300
+        }
+      ],
+      "thresholds": "300,3600",
+      "title": "MySQL Uptime",
+      "transparent": false,
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+
+      ],
+      "valueName": "current"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": false,
+      "colors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**Current QPS**\n\nBased on the queries reported by MySQL's ``SHOW STATUS`` command, it is the number of statements executed by the server within the last second. This variable includes statements executed within stored programs, unlike the Questions variable. It does not count \n``COM_PING`` or ``COM_STATISTICS`` commands.",
+      "editable": true,
+      "error": false,
+      "format": "short",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 6,
+        "x": 6,
+        "y": 1
+      },
+      "height": "125px",
+      "id": 13,
+      "interval": "$interval",
+      "links": [
+        {
+          "targetBlank": true,
+          "title": "MySQL Server Status Variables",
+          "type": "absolute",
+          "url": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Queries"
+        }
+      ],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "80%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": false,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "calculatedInterval": "10m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_queries{instance=\"$host\"}[$interval]) or irate(mysql_global_status_queries{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": "35,75",
+      "title": "Current QPS",
+      "transparent": false,
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+
+      ],
+      "valueName": "current"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": false,
+      "colors": [
+        "rgba(50, 172, 45, 0.97)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(245, 54, 54, 0.9)"
+      ],
+      "datasource": "Prometheus",
+      "decimals": 0,
+      "description": "**InnoDB Buffer Pool Size**\n\nInnoDB maintains a storage area called the buffer pool for caching data and indexes in memory.  Knowing how the InnoDB buffer pool works, and taking advantage of it to keep frequently accessed data in memory, is one of the most important aspects of MySQL tuning. The goal is to keep the working set in memory. In most cases, this should be between 60%-90% of available memory on a dedicated database host, but depends on many factors.",
+      "editable": true,
+      "error": false,
+      "format": "bytes",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 6,
+        "x": 12,
+        "y": 1
+      },
+      "height": "125px",
+      "id": 51,
+      "interval": "$interval",
+      "links": [
+        {
+          "targetBlank": true,
+          "title": "Tuning the InnoDB Buffer Pool Size",
+          "type": "absolute",
+          "url": "https://www.percona.com/blog/2015/06/02/80-ram-tune-innodb_buffer_pool_size/"
+        }
+      ],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "80%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": false,
+        "lineColor": "rgb(31, 120, 193)",
+        "show": false
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "calculatedInterval": "10m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_variables_innodb_buffer_pool_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "5m",
+          "intervalFactor": 1,
+          "legendFormat": "",
+          "metric": "",
+          "refId": "A",
+          "step": 300
+        }
+      ],
+      "thresholds": "90,95",
+      "title": "InnoDB Buffer Pool Size",
+      "transparent": false,
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+
+      ],
+      "valueName": "current"
+    },
+    {
+      "cacheTimeout": null,
+      "colorBackground": false,
+      "colorValue": true,
+      "colors": [
+        "rgba(245, 54, 54, 0.9)",
+        "rgba(237, 129, 40, 0.89)",
+        "rgba(50, 172, 45, 0.97)"
+      ],
+      "datasource": "Prometheus",
+      "decimals": 0,
+      "description": "**InnoDB Buffer Pool Size % of Total RAM**\n\nInnoDB maintains a storage area called the buffer pool for caching data and indexes in memory.  Knowing how the InnoDB buffer pool works, and taking advantage of it to keep frequently accessed data in memory, is one of the most important aspects of MySQL tuning. The goal is to keep the working set in memory. In most cases, this should be between 60%-90% of available memory on a dedicated database host, but depends on many factors.",
+      "editable": true,
+      "error": false,
+      "format": "percent",
+      "gauge": {
+        "maxValue": 100,
+        "minValue": 0,
+        "show": false,
+        "thresholdLabels": false,
+        "thresholdMarkers": true
+      },
+      "gridPos": {
+        "h": 2,
+        "w": 6,
+        "x": 18,
+        "y": 1
+      },
+      "height": "125px",
+      "id": 52,
+      "interval": "$interval",
+      "links": [
+        {
+          "targetBlank": true,
+          "title": "Tuning the InnoDB Buffer Pool Size",
+          "type": "absolute",
+          "url": "https://www.percona.com/blog/2015/06/02/80-ram-tune-innodb_buffer_pool_size/"
+        }
+      ],
+      "mappingType": 1,
+      "mappingTypes": [
+        {
+          "name": "value to text",
+          "value": 1
+        },
+        {
+          "name": "range to text",
+          "value": 2
+        }
+      ],
+      "maxDataPoints": 100,
+      "nullPointMode": "connected",
+      "nullText": null,
+      "postfix": "",
+      "postfixFontSize": "50%",
+      "prefix": "",
+      "prefixFontSize": "80%",
+      "rangeMaps": [
+        {
+          "from": "null",
+          "text": "N/A",
+          "to": "null"
+        }
+      ],
+      "repeat": null,
+      "sparkline": {
+        "fillColor": "rgba(31, 118, 189, 0.18)",
+        "full": false,
+        "lineColor": "rgb(31, 120, 193)",
+        "maxValue": 100,
+        "minValue": 0,
+        "show": true
+      },
+      "tableColumn": "",
+      "targets": [
+        {
+          "calculatedInterval": "10m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "(mysql_global_variables_innodb_buffer_pool_size{instance=\"$host\"} * 100) / on (instance) node_memory_MemTotal{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "5m",
+          "intervalFactor": 1,
+          "legendFormat": "",
+          "metric": "",
+          "refId": "A",
+          "step": 300
+        }
+      ],
+      "thresholds": "40,80",
+      "title": "Buffer Pool Size of Total RAM",
+      "transparent": false,
+      "type": "singlestat",
+      "valueFontSize": "80%",
+      "valueMaps": [
+
+      ],
+      "valueName": "current"
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 3
+      },
+      "id": 383,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Connections",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 0,
+      "description": "**Max Connections** \n\nMax Connections is the maximum permitted number of simultaneous client connections. By default, this is 151. Increasing this value increases the number of file descriptors that mysqld requires. If the required number of descriptors are not available, the server reduces the value of Max Connections.\n\nmysqld actually permits Max Connections + 1 clients to connect. The extra connection is reserved for use by accounts that have the SUPER privilege, such as root.\n\nMax Used Connections is the maximum number of connections that have been in use simultaneously since the server started.\n\nConnections is the number of connection attempts (successful or not) to the MySQL server.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 4
+      },
+      "height": "250px",
+      "id": 92,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "targetBlank": true,
+          "title": "MySQL Server System Variables",
+          "type": "absolute",
+          "url": "https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_max_connections"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Max Connections",
+          "fill": 0
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "max(max_over_time(mysql_global_status_threads_connected{instance=\"$host\"}[$interval])  or mysql_global_status_threads_connected{instance=\"$host\"} )",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Connections",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_max_used_connections{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Max Used Connections",
+          "metric": "",
+          "refId": "C",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_variables_max_connections{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Max Connections",
+          "metric": "",
+          "refId": "B",
+          "step": 20,
+          "target": ""
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Connections",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Active Threads**\n\nThreads Connected is the number of open connections, while Threads Running is the number of threads not sleeping.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 4
+      },
+      "id": 10,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Peak Threads Running",
+          "color": "#E24D42",
+          "lines": false,
+          "pointradius": 1,
+          "points": true
+        },
+        {
+          "alias": "Peak Threads Connected",
+          "color": "#1F78C1"
+        },
+        {
+          "alias": "Avg Threads Running",
+          "color": "#EAB839"
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "max_over_time(mysql_global_status_threads_connected{instance=\"$host\"}[$interval]) or\nmax_over_time(mysql_global_status_threads_connected{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "hide": false,
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Peak Threads Connected",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "max_over_time(mysql_global_status_threads_running{instance=\"$host\"}[$interval]) or\nmax_over_time(mysql_global_status_threads_running{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Peak Threads Running",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "expr": "avg_over_time(mysql_global_status_threads_running{instance=\"$host\"}[$interval]) or \navg_over_time(mysql_global_status_threads_running{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Avg Threads Running",
+          "refId": "C",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Client Thread Activity",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+          "total"
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": "Threads",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 11
+      },
+      "id": 384,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Table Locks",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Questions**\n\nThe number of statements executed by the server. This includes only statements sent to the server by clients and not statements executed within stored programs, unlike the Queries used in the QPS calculation. \n\nThis variable does not count the following commands:\n* ``COM_PING``\n* ``COM_STATISTICS``\n* ``COM_STMT_PREPARE``\n* ``COM_STMT_CLOSE``\n* ``COM_STMT_RESET``",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 12
+      },
+      "id": 53,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "targetBlank": true,
+          "title": "MySQL Queries and Questions",
+          "type": "absolute",
+          "url": "https://www.percona.com/blog/2014/05/29/how-mysql-queries-and-questions-are-measured/"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_questions{instance=\"$host\"}[$interval]) or irate(mysql_global_status_questions{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Questions",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Questions",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Thread Cache**\n\nThe thread_cache_size variable sets how many threads the server should cache to reuse. When a client disconnects, the client's threads are put in the cache if the cache is not full. It is autosized in MySQL 5.6.8 and above (capped to 100). Requests for threads are satisfied by reusing threads taken from the cache if possible, and only when the cache is empty is a new thread created.\n\n* *Threads_created*: The number of threads created to handle connections.\n* *Threads_cached*: The number of threads in the thread cache.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 12
+      },
+      "id": 11,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "title": "Tuning information",
+          "type": "absolute",
+          "url": "https://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_thread_cache_size"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Threads Created",
+          "fill": 0
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_variables_thread_cache_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Thread Cache Size",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_threads_cached{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Threads Cached",
+          "metric": "",
+          "refId": "C",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_threads_created{instance=\"$host\"}[$interval]) or irate(mysql_global_status_threads_created{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Threads Created",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Thread Cache",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "transparent": false,
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 19
+      },
+      "id": 385,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Temporary Objects",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 20
+      },
+      "id": 22,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_created_tmp_tables{instance=\"$host\"}[$interval]) or irate(mysql_global_status_created_tmp_tables{instance=\"$host\"}[5m])",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Created Tmp Tables",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_created_tmp_disk_tables{instance=\"$host\"}[$interval]) or irate(mysql_global_status_created_tmp_disk_tables{instance=\"$host\"}[5m])",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Created Tmp Disk Tables",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_created_tmp_files{instance=\"$host\"}[$interval]) or irate(mysql_global_status_created_tmp_files{instance=\"$host\"}[5m])",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Created Tmp Files",
+          "metric": "",
+          "refId": "C",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Temporary Objects",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Select Types**\n\nAs with most relational databases, selecting based on indexes is more efficient than scanning an entire table's data. Here we see the counters for selects not done with indexes.\n\n* ***Select Scan*** is how many queries caused full table scans, in which all the data in the table had to be read and either discarded or returned.\n* ***Select Range*** is how many queries used a range scan, which means MySQL scanned all rows in a given range.\n* ***Select Full Join*** is the number of joins that are not joined on an index, this is usually a huge performance hit.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 20
+      },
+      "height": "250px",
+      "id": 311,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_select_full_join{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_full_join{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Select Full Join",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_select_full_range_join{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_full_range_join{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Select Full Range Join",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_select_range{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_range{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Select Range",
+          "metric": "",
+          "refId": "C",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_select_range_check{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_range_check{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Select Range Check",
+          "metric": "",
+          "refId": "D",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_select_scan{instance=\"$host\"}[$interval]) or irate(mysql_global_status_select_scan{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Select Scan",
+          "metric": "",
+          "refId": "E",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Select Types",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 27
+      },
+      "id": 386,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Sorts",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Sorts**\n\nDue to a query's structure, order, or other requirements, MySQL sorts the rows before returning them. For example, if a table is ordered 1 to 10 but you want the results reversed, MySQL then has to sort the rows to return 10 to 1.\n\nThis graph also shows when sorts had to scan a whole table or a given range of a table in order to return the results and which could not have been sorted via an index.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 28
+      },
+      "id": 30,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_sort_rows{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_rows{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Sort Rows",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_sort_range{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_range{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Sort Range",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_sort_merge_passes{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_merge_passes{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Sort Merge Passes",
+          "metric": "",
+          "refId": "C",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_sort_scan{instance=\"$host\"}[$interval]) or irate(mysql_global_status_sort_scan{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Sort Scan",
+          "metric": "",
+          "refId": "D",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Sorts",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Slow Queries**\n\nSlow queries are defined as queries being slower than the long_query_time setting. For example, if you have long_query_time set to 3, all queries that take longer than 3 seconds to complete will show on this graph.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 28
+      },
+      "id": 48,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_slow_queries{instance=\"$host\"}[$interval]) or irate(mysql_global_status_slow_queries{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Slow Queries",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Slow Queries",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 35
+      },
+      "id": 387,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Aborted",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**Aborted Connections**\n\nWhen a given host connects to MySQL and the connection is interrupted in the middle (for example due to bad credentials), MySQL keeps that info in a system table (since 5.6 this table is exposed in performance_schema).\n\nIf the amount of failed requests without a successful connection reaches the value of max_connect_errors, mysqld assumes that something is wrong and blocks the host from further connection.\n\nTo allow connections from that host again, you need to issue the ``FLUSH HOSTS`` statement.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 36
+      },
+      "id": 47,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_aborted_connects{instance=\"$host\"}[$interval]) or irate(mysql_global_status_aborted_connects{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Aborted Connects (attempts)",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_aborted_clients{instance=\"$host\"}[$interval]) or irate(mysql_global_status_aborted_clients{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Aborted Clients (timeout)",
+          "metric": "",
+          "refId": "B",
+          "step": 20,
+          "target": ""
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Aborted Connections",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "cumulative"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**Table Locks**\n\nMySQL takes a number of different locks for varying reasons. In this graph we see how many Table level locks MySQL has requested from the storage engine. In the case of InnoDB, many times the locks could actually be row locks as it only takes table level locks in a few specific cases.\n\nIt is most useful to compare Locks Immediate and Locks Waited. If Locks waited is rising, it means you have lock contention. Otherwise, Locks Immediate rising and falling is normal activity.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 36
+      },
+      "id": 32,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_table_locks_immediate{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_locks_immediate{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Table Locks Immediate",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_table_locks_waited{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_locks_waited{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Table Locks Waited",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Table Locks",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 43
+      },
+      "id": 388,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Network",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Network Traffic**\n\nHere we can see how much network traffic is generated by MySQL. Outbound is network traffic sent from MySQL and Inbound is network traffic MySQL has received.",
+      "editable": true,
+      "error": false,
+      "fill": 6,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 44
+      },
+      "id": 9,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_bytes_received{instance=\"$host\"}[$interval]) or irate(mysql_global_status_bytes_received{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Inbound",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_bytes_sent{instance=\"$host\"}[$interval]) or irate(mysql_global_status_bytes_sent{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Outbound",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Network Traffic",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "Bps",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "none",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": true,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Network Usage Hourly**\n\nHere we can see how much network traffic is generated by MySQL per hour. You can use the bar graph to compare data sent by MySQL and data received by MySQL.",
+      "editable": true,
+      "error": false,
+      "fill": 6,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 44
+      },
+      "height": "250px",
+      "id": 381,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": false,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "increase(mysql_global_status_bytes_received{instance=\"$host\"}[1h])",
+          "format": "time_series",
+          "interval": "1h",
+          "intervalFactor": 1,
+          "legendFormat": "Received",
+          "metric": "",
+          "refId": "A",
+          "step": 3600
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "increase(mysql_global_status_bytes_sent{instance=\"$host\"}[1h])",
+          "format": "time_series",
+          "interval": "1h",
+          "intervalFactor": 1,
+          "legendFormat": "Sent",
+          "metric": "",
+          "refId": "B",
+          "step": 3600
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": "24h",
+      "timeShift": null,
+      "title": "MySQL Network Usage Hourly",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "none",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 51
+      },
+      "id": 389,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Memory",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 0,
+      "description": "***System Memory***: Total Memory for the system.\\\n***InnoDB Buffer Pool Data***: InnoDB maintains a storage area called the buffer pool for caching data and indexes in memory.\\\n***TokuDB Cache Size***: Similar in function to the InnoDB Buffer Pool,  TokuDB will allocate 50% of the installed RAM for its own cache.\\\n***Key Buffer Size***: Index blocks for MYISAM tables are buffered and are shared by all threads. key_buffer_size is the size of the buffer used for index blocks.\\\n***Adaptive Hash Index Size***: When InnoDB notices that some index values are being accessed very frequently, it builds a hash index for them in memory on top of B-Tree indexes.\\\n ***Query Cache Size***: The query cache stores the text of a SELECT statement together with the corresponding result that was sent to the client. The query cache has huge scalability problems in that only one thread can do an operation in the query cache at the same time.\\\n***InnoDB Dictionary Size***: The data dictionary is InnoDB ‘s internal catalog of tables. InnoDB stores the data dictionary on disk, and loads entries into memory while the server is running.\\\n***InnoDB Log Buffer Size***: The MySQL InnoDB log buffer allows transactions to run without having to write the log to disk before the transactions commit.",
+      "editable": true,
+      "error": false,
+      "fill": 6,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 52
+      },
+      "id": 50,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": true,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "title": "Detailed descriptions about metrics",
+          "type": "absolute",
+          "url": "https://www.percona.com/doc/percona-monitoring-and-management/dashboard.mysql-overview.html#mysql-internal-memory-overview"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "System Memory",
+          "fill": 0,
+          "stack": false
+        }
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "node_memory_MemTotal{instance=\"$host\"}",
+          "format": "time_series",
+          "intervalFactor": 2,
+          "legendFormat": "System Memory",
+          "refId": "G",
+          "step": 4
+        },
+        {
+          "expr": "mysql_global_status_innodb_page_size{instance=\"$host\"} * on (instance) mysql_global_status_buffer_pool_pages{instance=\"$host\",state=\"data\"}",
+          "format": "time_series",
+          "hide": false,
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "InnoDB Buffer Pool Data",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "expr": "mysql_global_variables_innodb_log_buffer_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "InnoDB Log Buffer Size",
+          "refId": "D",
+          "step": 20
+        },
+        {
+          "expr": "mysql_global_variables_innodb_additional_mem_pool_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 2,
+          "legendFormat": "InnoDB Additional Memory Pool Size",
+          "refId": "H",
+          "step": 40
+        },
+        {
+          "expr": "mysql_global_status_innodb_mem_dictionary{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "InnoDB Dictionary Size",
+          "refId": "F",
+          "step": 20
+        },
+        {
+          "expr": "mysql_global_variables_key_buffer_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Key Buffer Size",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "expr": "mysql_global_variables_query_cache_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Query Cache Size",
+          "refId": "C",
+          "step": 20
+        },
+        {
+          "expr": "mysql_global_status_innodb_mem_adaptive_hash{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Adaptive Hash Index Size",
+          "refId": "E",
+          "step": 20
+        },
+        {
+          "expr": "mysql_global_variables_tokudb_cache_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "TokuDB Cache Size",
+          "refId": "I",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Internal Memory Overview",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 59
+      },
+      "id": 390,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Command, Handlers, Processes",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**Top Command Counters**\n\nThe Com_{{xxx}} statement counter variables indicate the number of times each xxx statement has been executed. There is one status variable for each type of statement. For example, Com_delete and Com_update count [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements, respectively. Com_delete_multi and Com_update_multi are similar but apply to [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements that use multiple-table syntax.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 60
+      },
+      "id": 14,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": false,
+        "hideZero": false,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "title": "Server Status Variables (Com_xxx)",
+          "type": "absolute",
+          "url": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Com_xxx"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "topk(5, rate(mysql_global_status_commands_total{instance=\"$host\"}[$interval])>0) or topk(5, irate(mysql_global_status_commands_total{instance=\"$host\"}[5m])>0)",
+          "format": "time_series",
+          "hide": false,
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Com_{{ command }}",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Top Command Counters",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": true,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**Top Command Counters Hourly**\n\nThe Com_{{xxx}} statement counter variables indicate the number of times each xxx statement has been executed. There is one status variable for each type of statement. For example, Com_delete and Com_update count [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements, respectively. Com_delete_multi and Com_update_multi are similar but apply to [``DELETE``](https://dev.mysql.com/doc/refman/5.7/en/delete.html) and [``UPDATE``](https://dev.mysql.com/doc/refman/5.7/en/update.html) statements that use multiple-table syntax.",
+      "editable": true,
+      "error": false,
+      "fill": 6,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 67
+      },
+      "id": 39,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": false,
+      "linewidth": 2,
+      "links": [
+        {
+          "dashboard": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Com_xxx",
+          "title": "Server Status Variables (Com_xxx)",
+          "type": "absolute",
+          "url": "https://dev.mysql.com/doc/refman/5.7/en/server-status-variables.html#statvar_Com_xxx"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "topk(5, increase(mysql_global_status_commands_total{instance=\"$host\"}[1h])>0)",
+          "format": "time_series",
+          "interval": "1h",
+          "intervalFactor": 1,
+          "legendFormat": "Com_{{ command }}",
+          "metric": "",
+          "refId": "A",
+          "step": 3600
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": "24h",
+      "timeShift": null,
+      "title": "Top Command Counters Hourly",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Handlers**\n\nHandler statistics are internal statistics on how MySQL is selecting, updating, inserting, and modifying rows, tables, and indexes.\n\nThis is in fact the layer between the Storage Engine and MySQL.\n\n* `read_rnd_next` is incremented when the server performs a full table scan and this is a counter you don't really want to see with a high value.\n* `read_key` is incremented when a read is done with an index.\n* `read_next` is incremented when the storage engine is asked to 'read the next index entry'. A high value means a lot of index scans are being done.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 74
+      },
+      "id": 8,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_handlers_total{instance=\"$host\", handler!~\"commit|rollback|savepoint.*|prepare\"}[$interval]) or irate(mysql_global_status_handlers_total{instance=\"$host\", handler!~\"commit|rollback|savepoint.*|prepare\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "{{ handler }}",
+          "metric": "",
+          "refId": "J",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Handlers",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 81
+      },
+      "id": 28,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_handlers_total{instance=\"$host\", handler=~\"commit|rollback|savepoint.*|prepare\"}[$interval]) or irate(mysql_global_status_handlers_total{instance=\"$host\", handler=~\"commit|rollback|savepoint.*|prepare\"}[5m])",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "{{ handler }}",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Transaction Handlers",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 0,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 88
+      },
+      "id": 40,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideZero": true,
+        "max": true,
+        "min": false,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_info_schema_threads{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "{{ state }}",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Process States",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": true,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 6,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 95
+      },
+      "id": 49,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideZero": true,
+        "max": true,
+        "min": false,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": false,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "topk(5, avg_over_time(mysql_info_schema_threads{instance=\"$host\"}[1h]))",
+          "interval": "1h",
+          "intervalFactor": 1,
+          "legendFormat": "{{ state }}",
+          "metric": "",
+          "refId": "A",
+          "step": 3600
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": "24h",
+      "timeShift": null,
+      "title": "Top Process States Hourly",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 102
+      },
+      "id": 391,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Query Cache",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Query Cache Memory**\n\nThe query cache has huge scalability problems in that only one thread can do an operation in the query cache at the same time. This serialization is true not only for SELECTs, but also for INSERT/UPDATE/DELETE.\n\nThis also means that the larger the `query_cache_size` is set to, the slower those operations become. In concurrent environments, the MySQL Query Cache quickly becomes a contention point, decreasing performance. MariaDB and AWS Aurora have done work to try and eliminate the query cache contention in their flavors of MySQL, while MySQL 8.0 has eliminated the query cache feature.\n\nThe recommended settings for most environments is to set:\n  ``query_cache_type=0``\n  ``query_cache_size=0``\n\nNote that while you can dynamically change these values, to completely remove the contention point you have to restart the database.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 103
+      },
+      "id": 46,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_qcache_free_memory{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Free Memory",
+          "metric": "",
+          "refId": "F",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_variables_query_cache_size{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Query Cache Size",
+          "metric": "",
+          "refId": "E",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Query Cache Memory",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Query Cache Activity**\n\nThe query cache has huge scalability problems in that only one thread can do an operation in the query cache at the same time. This serialization is true not only for SELECTs, but also for INSERT/UPDATE/DELETE.\n\nThis also means that the larger the `query_cache_size` is set to, the slower those operations become. In concurrent environments, the MySQL Query Cache quickly becomes a contention point, decreasing performance. MariaDB and AWS Aurora have done work to try and eliminate the query cache contention in their flavors of MySQL, while MySQL 8.0 has eliminated the query cache feature.\n\nThe recommended settings for most environments is to set:\n``query_cache_type=0``\n``query_cache_size=0``\n\nNote that while you can dynamically change these values, to completely remove the contention point you have to restart the database.",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 103
+      },
+      "height": "",
+      "id": 45,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_qcache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_hits{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Hits",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_qcache_inserts{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_inserts{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Inserts",
+          "metric": "",
+          "refId": "C",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_qcache_not_cached{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_not_cached{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Not Cached",
+          "metric": "",
+          "refId": "D",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_qcache_lowmem_prunes{instance=\"$host\"}[$interval]) or irate(mysql_global_status_qcache_lowmem_prunes{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Prunes",
+          "metric": "",
+          "refId": "F",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_qcache_queries_in_cache{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Queries in Cache",
+          "metric": "",
+          "refId": "E",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Query Cache Activity",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 110
+      },
+      "id": 392,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Files and Tables",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 111
+      },
+      "id": 43,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_opened_files{instance=\"$host\"}[$interval]) or irate(mysql_global_status_opened_files{instance=\"$host\"}[5m])",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Openings",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL File Openings",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 111
+      },
+      "id": 41,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_open_files{instance=\"$host\"}",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Open Files",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_variables_open_files_limit{instance=\"$host\"}",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Open Files Limit",
+          "metric": "",
+          "refId": "D",
+          "step": 20
+        },
+        {
+          "expr": "mysql_global_status_innodb_num_open_files{instance=\"$host\"}",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "InnoDB Open Files",
+          "refId": "B",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Open Files",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 118
+      },
+      "id": 393,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "Table Openings",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Table Open Cache Status**\n\nThe recommendation is to set the `table_open_cache_instances` to a loose correlation to virtual CPUs, keeping in mind that more instances means the cache is split more times. If you have a cache set to 500 but it has 10 instances, each cache will only have 50 cached.\n\nThe `table_definition_cache` and `table_open_cache` can be left as default as they are auto-sized MySQL 5.6 and above (ie: do not set them to any value).",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 119
+      },
+      "id": 44,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "title": "Server Status Variables (table_open_cache)",
+          "type": "absolute",
+          "url": "http://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_table_open_cache"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Table Open Cache Hit Ratio",
+          "yaxis": 2
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(mysql_global_status_opened_tables{instance=\"$host\"}[$interval]) or irate(mysql_global_status_opened_tables{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Openings",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "expr": "rate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Hits",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "expr": "rate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Misses",
+          "refId": "C",
+          "step": 20
+        },
+        {
+          "expr": "rate(mysql_global_status_table_open_cache_overflows{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_overflows{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Misses due to Overflows",
+          "refId": "D",
+          "step": 20
+        },
+        {
+          "expr": "(rate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[5m]))/((rate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_hits{instance=\"$host\"}[5m]))+(rate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[$interval]) or irate(mysql_global_status_table_open_cache_misses{instance=\"$host\"}[5m])))",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Table Open Cache Hit Ratio",
+          "refId": "E",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Table Open Cache Status",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "percentunit",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Open Tables**\n\nThe recommendation is to set the `table_open_cache_instances` to a loose correlation to virtual CPUs, keeping in mind that more instances means the cache is split more times. If you have a cache set to 500 but it has 10 instances, each cache will only have 50 cached.\n\nThe `table_definition_cache` and `table_open_cache` can be left as default as they are auto-sized MySQL 5.6 and above (ie: do not set them to any value).",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 119
+      },
+      "id": 42,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "title": "Server Status Variables (table_open_cache)",
+          "type": "absolute",
+          "url": "http://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_table_open_cache"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_open_tables{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Open Tables",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_variables_table_open_cache{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Table Open Cache",
+          "metric": "",
+          "refId": "C",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Open Tables",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 126
+      },
+      "id": 394,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "MySQL Table Definition Cache",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "description": "**MySQL Table Definition Cache**\n\nThe recommendation is to set the `table_open_cache_instances` to a loose correlation to virtual CPUs, keeping in mind that more instances means the cache is split more times. If you have a cache set to 500 but it has 10 instances, each cache will only have 50 cached.\n\nThe `table_definition_cache` and `table_open_cache` can be left as default as they are auto-sized MySQL 5.6 and above (ie: do not set them to any value).",
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 24,
+        "x": 0,
+        "y": 127
+      },
+      "id": 54,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+        {
+          "title": "Server Status Variables (table_open_cache)",
+          "type": "absolute",
+          "url": "http://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html#sysvar_table_open_cache"
+        }
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Opened Table Definitions",
+          "yaxis": 2
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_status_open_table_definitions{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Open Table Definitions",
+          "metric": "",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "mysql_global_variables_table_definition_cache{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Table Definitions Cache Size",
+          "metric": "",
+          "refId": "C",
+          "step": 20
+        },
+        {
+          "expr": "rate(mysql_global_status_opened_table_definitions{instance=\"$host\"}[$interval]) or irate(mysql_global_status_opened_table_definitions{instance=\"$host\"}[5m])",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Opened Table Definitions",
+          "refId": "A",
+          "step": 20
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "MySQL Table Definition Cache",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "short",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 134
+      },
+      "id": 395,
+      "panels": [
+
+      ],
+      "repeat": null,
+      "title": "System Charts",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 135
+      },
+      "id": 31,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(node_vmstat_pgpgin{instance=\"$host\"}[$interval]) * 1024 or irate(node_vmstat_pgpgin{instance=\"$host\"}[5m]) * 1024",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Page In",
+          "metric": "",
+          "refId": "A",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(node_vmstat_pgpgout{instance=\"$host\"}[$interval]) * 1024 or irate(node_vmstat_pgpgout{instance=\"$host\"}[5m]) * 1024",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Page Out",
+          "metric": "",
+          "refId": "B",
+          "step": 20,
+          "target": ""
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "I/O Activity",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "transparent": false,
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "Bps",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": null,
+      "editable": true,
+      "error": false,
+      "fill": 6,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 135
+      },
+      "height": "250px",
+      "id": 37,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "max(node_memory_MemTotal{instance=\"$host\"}) without(job) - \n(max(node_memory_MemFree{instance=\"$host\"}) without(job) + \nmax(node_memory_Buffers{instance=\"$host\"}) without(job) + \n(max(node_memory_Cached{instance=\"$host\",job=~\"rds-enhanced|linux\"}) without (job) or \nmax(node_memory_Cached{instance=\"$host\",job=\"rds-basic\"}) without (job)))",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Used",
+          "metric": "",
+          "refId": "A",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "node_memory_MemFree{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Free",
+          "metric": "",
+          "refId": "B",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "node_memory_Buffers{instance=\"$host\"}",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Buffers",
+          "metric": "",
+          "refId": "D",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "max(node_memory_Cached{instance=~\"$host\",job=~\"rds-enhanced|linux\"}) without (job) or \nmax(node_memory_Cached{instance=~\"$host\",job=~\"rds-basic\"}) without (job)",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Cached",
+          "metric": "",
+          "refId": "E",
+          "step": 20,
+          "target": ""
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Memory Distribution",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "transparent": false,
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "bytes",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+        "Load 1m": "#58140C",
+        "Max Core Utilization": "#bf1b00",
+        "iowait": "#e24d42",
+        "nice": "#1f78c1",
+        "softirq": "#806eb7",
+        "system": "#eab839",
+        "user": "#508642"
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": null,
+      "editable": true,
+      "error": false,
+      "fill": 6,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 142
+      },
+      "height": "",
+      "id": 2,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": true,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": true,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Max Core Utilization",
+          "lines": false,
+          "pointradius": 1,
+          "points": true,
+          "stack": false
+        },
+        {
+          "alias": "Load 1m",
+          "color": "#58140C",
+          "fill": 2,
+          "legend": false,
+          "stack": false,
+          "yaxis": 2
+        }
+      ],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "clamp_max(((avg by (mode) ( (clamp_max(rate(node_cpu{instance=\"$host\",mode!=\"idle\"}[$interval]),1)) or (clamp_max(irate(node_cpu{instance=\"$host\",mode!=\"idle\"}[5m]),1)) ))*100 or (avg_over_time(node_cpu_average{instance=~\"$host\", mode!=\"total\", mode!=\"idle\"}[$interval]) or avg_over_time(node_cpu_average{instance=~\"$host\", mode!=\"total\", mode!=\"idle\"}[5m]))),100)",
+          "format": "time_series",
+          "hide": false,
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "{{ mode }}",
+          "metric": "",
+          "refId": "A",
+          "step": 20
+        },
+        {
+          "expr": "clamp_max(max by () (sum  by (cpu) ( (clamp_max(rate(node_cpu{instance=\"$host\",mode!=\"idle\",mode!=\"iowait\"}[$interval]),1)) or (clamp_max(irate(node_cpu{instance=\"$host\",mode!=\"idle\",mode!=\"iowait\"}[5m]),1)) ))*100,100)",
+          "format": "time_series",
+          "hide": true,
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Max Core Utilization",
+          "refId": "B",
+          "step": 20
+        },
+        {
+          "expr": "node_load1{instance=\"$host\"}",
+          "format": "time_series",
+          "hide": false,
+          "intervalFactor": 2,
+          "legendFormat": "Load 1m",
+          "refId": "C"
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "CPU Usage / Load",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "decimals": 1,
+          "format": "percent",
+          "label": "",
+          "logBase": 1,
+          "max": 100,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "none",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": 2,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 142
+      },
+      "height": "250px",
+      "id": 36,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": true,
+        "hideZero": true,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": false,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 1,
+      "points": true,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "sum((rate(node_disk_read_time_ms{device!~\"dm-.+\", instance=\"$host\"}[$interval]) / rate(node_disk_reads_completed{device!~\"dm-.+\", instance=\"$host\"}[$interval])) or (irate(node_disk_read_time_ms{device!~\"dm-.+\", instance=\"$host\"}[5m]) / irate(node_disk_reads_completed{device!~\"dm-.+\", instance=\"$host\"}[5m]))\nor avg_over_time(aws_rds_read_latency_average{instance=\"$host\"}[$interval]) or avg_over_time(aws_rds_read_latency_average{instance=\"$host\"}[5m]))",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Read",
+          "metric": "",
+          "refId": "A",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2m",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "sum((rate(node_disk_write_time_ms{device!~\"dm-.+\", instance=\"$host\"}[$interval]) / rate(node_disk_writes_completed{device!~\"dm-.+\", instance=\"$host\"}[$interval])) or (irate(node_disk_write_time_ms{device!~\"dm-.+\", instance=\"$host\"}[5m]) / irate(node_disk_writes_completed{device!~\"dm-.+\", instance=\"$host\"}[5m])) or \navg_over_time(aws_rds_write_latency_average{instance=\"$host\"}[$interval]) or avg_over_time(aws_rds_write_latency_average{instance=\"$host\"}[5m]))",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Write",
+          "metric": "",
+          "refId": "B",
+          "step": 20,
+          "target": ""
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Disk Latency",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "transparent": false,
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "ms",
+          "label": "",
+          "logBase": 2,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "ms",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": null,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 149
+      },
+      "height": "250px",
+      "id": 21,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "alias": "Outbound",
+          "transform": "negative-Y"
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "sum(rate(node_network_receive_bytes{instance=\"$host\", device!=\"lo\"}[$interval])) or sum(irate(node_network_receive_bytes{instance=\"$host\", device!=\"lo\"}[5m])) or sum(max_over_time(rdsosmetrics_network_rx{instance=\"$host\"}[$interval])) or sum(max_over_time(rdsosmetrics_network_rx{instance=\"$host\"}[5m])) ",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Inbound",
+          "metric": "",
+          "refId": "B",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "sum(rate(node_network_transmit_bytes{instance=\"$host\", device!=\"lo\"}[$interval])) or sum(irate(node_network_transmit_bytes{instance=\"$host\", device!=\"lo\"}[5m])) or\nsum(max_over_time(rdsosmetrics_network_tx{instance=\"$host\"}[$interval])) or sum(max_over_time(rdsosmetrics_network_tx{instance=\"$host\"}[5m]))",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Outbound",
+          "metric": "",
+          "refId": "A",
+          "step": 20,
+          "target": ""
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Network Traffic",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "transparent": false,
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "Bps",
+          "label": "Outbound (-) / Inbound (+)",
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": "Prometheus",
+      "decimals": null,
+      "editable": true,
+      "error": false,
+      "fill": 2,
+      "grid": {
+
+      },
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 149
+      },
+      "id": 38,
+      "legend": {
+        "alignAsTable": true,
+        "avg": true,
+        "current": false,
+        "hideEmpty": false,
+        "max": true,
+        "min": true,
+        "rightSide": false,
+        "show": true,
+        "sort": "avg",
+        "sortDesc": true,
+        "total": false,
+        "values": true
+      },
+      "lines": true,
+      "linewidth": 2,
+      "links": [
+
+      ],
+      "nullPointMode": "null",
+      "percentage": false,
+      "pointradius": 5,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(node_vmstat_pswpin{instance=\"$host\"}[$interval]) * 4096 or irate(node_vmstat_pswpin{instance=\"$host\"}[5m]) * 4096",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Swap In (Reads)",
+          "metric": "",
+          "refId": "A",
+          "step": 20,
+          "target": ""
+        },
+        {
+          "calculatedInterval": "2s",
+          "datasourceErrors": {
+
+          },
+          "errors": {
+
+          },
+          "expr": "rate(node_vmstat_pswpout{instance=\"$host\"}[$interval]) * 4096 or irate(node_vmstat_pswpout{instance=\"$host\"}[5m]) * 4096",
+          "format": "time_series",
+          "interval": "$interval",
+          "intervalFactor": 1,
+          "legendFormat": "Swap Out (Writes)",
+          "metric": "",
+          "refId": "B",
+          "step": 20,
+          "target": ""
+        }
+      ],
+      "thresholds": [
+
+      ],
+      "timeFrom": null,
+      "timeShift": null,
+      "title": "Swap Activity",
+      "tooltip": {
+        "msResolution": false,
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "transparent": false,
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": [
+
+        ]
+      },
+      "yaxes": [
+        {
+          "format": "Bps",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        },
+        {
+          "format": "bytes",
+          "logBase": 1,
+          "max": null,
+          "min": 0,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    }
+  ],
+  "refresh": "1m",
+  "schemaVersion": 16,
+  "style": "dark",
+  "tags": [
+    "Percona",
+    "MySQL"
+  ],
+  "templating": {
+    "list": [
+      {
+        "allFormat": "glob",
+        "auto": true,
+        "auto_count": 200,
+        "auto_min": "1s",
+        "current": {
+          "text": "auto",
+          "value": "$__auto_interval_interval"
+        },
+        "datasource": "Prometheus",
+        "hide": 0,
+        "includeAll": false,
+        "label": "Interval",
+        "multi": false,
+        "multiFormat": "glob",
+        "name": "interval",
+        "options": [
+          {
+            "selected": true,
+            "text": "auto",
+            "value": "$__auto_interval_interval"
+          },
+          {
+            "selected": false,
+            "text": "1s",
+            "value": "1s"
+          },
+          {
+            "selected": false,
+            "text": "5s",
+            "value": "5s"
+          },
+          {
+            "selected": false,
+            "text": "1m",
+            "value": "1m"
+          },
+          {
+            "selected": false,
+            "text": "5m",
+            "value": "5m"
+          },
+          {
+            "selected": false,
+            "text": "1h",
+            "value": "1h"
+          },
+          {
+            "selected": false,
+            "text": "6h",
+            "value": "6h"
+          },
+          {
+            "selected": false,
+            "text": "1d",
+            "value": "1d"
+          }
+        ],
+        "query": "1s,5s,1m,5m,1h,6h,1d",
+        "refresh": 2,
+        "type": "interval"
+      },
+      {
+        "allFormat": "glob",
+        "allValue": null,
+        "current": {
+
+        },
+        "datasource": "Prometheus",
+        "hide": 0,
+        "includeAll": false,
+        "label": "Host",
+        "multi": false,
+        "multiFormat": "regex values",
+        "name": "host",
+        "options": [
+
+        ],
+        "query": "label_values(mysql_up, instance)",
+        "refresh": 1,
+        "refresh_on_load": false,
+        "regex": "",
+        "sort": 1,
+        "tagValuesQuery": null,
+        "tags": [
+
+        ],
+        "tagsQuery": null,
+        "type": "query",
+        "useTags": false
+      }
+    ]
+  },
+  "time": {
+    "from": "now-30m",
+    "to": "now"
+  },
+  "timepicker": {
+    "collapse": false,
+    "enable": true,
+    "hidden": false,
+    "notice": false,
+    "now": true,
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ],
+    "status": "Stable",
+    "time_options": [
+      "5m",
+      "15m",
+      "1h",
+      "6h",
+      "12h",
+      "24h",
+      "2d",
+      "7d",
+      "30d"
+    ],
+    "type": "timepicker"
+  },
+  "timezone": "browser",
+  "title": "MySQL Overview",
+  "uid": "MQWgroiiz",
+  "version": 1
+}

+ 7 - 7
devenv/docker/ha_test/prometheus/prometheus.yml

@@ -30,10 +30,10 @@ scrape_configs:
         port: 3000
         refresh_interval: 10s
 
-  # - job_name: 'mysql'
-  #   dns_sd_configs:
-  #     - names:
-  #       - 'mysqld-exporter'
-  #       type: 'A'
-  #       port: 9104
-  #       refresh_interval: 10s
+  - job_name: 'mysql'
+    dns_sd_configs:
+      - names:
+        - 'mysqld-exporter'
+        type: 'A'
+        port: 9104
+        refresh_interval: 10s

+ 13 - 1
devenv/docker/loadtest/README.md

@@ -8,7 +8,7 @@ Docker
 
 ## Run
 
-Run load test for 15 minutes:
+Run load test for 15 minutes using 2 virtual users and targeting http://localhost:3000.
 
 ```bash
 $ ./run.sh
@@ -20,6 +20,18 @@ Run load test for custom duration:
 $ ./run.sh -d 10s
 ```
 
+Run load test for custom target url:
+
+```bash
+$ ./run.sh -u http://grafana.loc
+```
+
+Run load test for 10 virtual users:
+
+```bash
+$ ./run.sh -v 10
+```
+
 Example output:
 
 ```bash

+ 1 - 1
devenv/docker/loadtest/auth_token_test.js

@@ -65,7 +65,7 @@ export default (data) => {
     }
   });
 
-  sleep(1)
+  sleep(5)
 }
 
 export const teardown = (data) => {}

+ 6 - 2
devenv/docker/loadtest/run.sh

@@ -5,8 +5,9 @@ PWD=$(pwd)
 run() {
   duration='15m'
   url='http://localhost:3000'
+  vus='2'
 
-  while getopts ":d:u:" o; do
+  while getopts ":d:u:v:" o; do
     case "${o}" in
 				d)
             duration=${OPTARG}
@@ -14,11 +15,14 @@ run() {
         u)
             url=${OPTARG}
             ;;
+        v)
+            vus=${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
+  docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus $vus --duration $duration src/auth_token_test.js
 }
 
 run "$@"

+ 29 - 0
docs/sources/auth/overview.md

@@ -36,6 +36,35 @@ Grafana of course has a built in user authentication system with password authen
 disable authentication by enabling anonymous access. You can also hide login form and only allow login through an auth
 provider (listed above). There is also options for allowing self sign up.
 
+### Login and short-lived tokens
+
+> The followung applies when using Grafana's built in user authentication, LDAP (without Auth proxy) or OAuth integration.
+
+Grafana are using short-lived tokens as a mechanism for verifying authenticated users.
+These short-lived tokens are rotated each `token_rotation_interval_minutes` for an active authenticated user.
+
+An active authenticated user that gets it token rotated will extend the `login_maximum_inactive_lifetime_days` time from "now" that Grafana will remember the user.
+This means that a user can close its browser and come back before `now + login_maximum_inactive_lifetime_days` and still being authenticated.
+ This is true as long as the time since user login is less than `login_maximum_lifetime_days`.
+
+Example:
+
+```bash
+[auth]
+
+# Login cookie name
+login_cookie_name = grafana_session
+
+# The lifetime (days) an authenticated user can be inactive before being required to login at next visit. Default is 7 days.
+login_maximum_inactive_lifetime_days = 7
+
+# The maximum lifetime (days) an authenticated user can be logged in since login time before being required to login. Default is 30 days.
+login_maximum_lifetime_days = 30
+
+# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
+token_rotation_interval_minutes = 10
+```
+
 ### Anonymous authentication
 
 You can make Grafana accessible without any login required by enabling anonymous access in the configuration file.

+ 8 - 0
docs/sources/installation/configuration.md

@@ -287,6 +287,14 @@ Default is `false`.
 
 Define a white list of allowed ips/domains to use in data sources. Format: `ip_or_domain:port` separated by spaces
 
+### cookie_secure
+
+Set to `true` if you host Grafana behind HTTPS. Default is `false`.
+
+### cookie_samesite
+
+Sets the `SameSite` cookie attribute and prevents the browser from sending this cookie along with cross-site requests. The main goal is mitigate the risk of cross-origin information leakage. It also provides some protection against cross-site request forgery attacks (CSRF),  [read more here](https://www.owasp.org/index.php/SameSite). Valid values are `lax`, `strict` and `none`. Default is `lax`.
+
 <hr />
 
 ## [users]

+ 1 - 0
package.json

@@ -86,6 +86,7 @@
     "prettier": "1.9.2",
     "react-hot-loader": "^4.3.6",
     "react-test-renderer": "^16.5.0",
+    "redux-mock-store": "^1.5.3",
     "regexp-replace-loader": "^1.0.1",
     "sass-lint": "^1.10.2",
     "sass-loader": "^7.0.1",

+ 8 - 32
pkg/api/common_test.go

@@ -94,14 +94,13 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
 }
 
 type scenarioContext struct {
-	m                    *macaron.Macaron
-	context              *m.ReqContext
-	resp                 *httptest.ResponseRecorder
-	handlerFunc          handlerFunc
-	defaultHandler       macaron.Handler
-	req                  *http.Request
-	url                  string
-	userAuthTokenService *fakeUserAuthTokenService
+	m              *macaron.Macaron
+	context        *m.ReqContext
+	resp           *httptest.ResponseRecorder
+	handlerFunc    handlerFunc
+	defaultHandler macaron.Handler
+	req            *http.Request
+	url            string
 }
 
 func (sc *scenarioContext) exec() {
@@ -123,30 +122,7 @@ func setupScenarioContext(url string) *scenarioContext {
 		Delims:    macaron.Delims{Left: "[[", Right: "]]"},
 	}))
 
-	sc.userAuthTokenService = newFakeUserAuthTokenService()
-	sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
+	sc.m.Use(middleware.GetContextHandler(nil))
 
 	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) SignOutUser(c *m.ReqContext) error { return nil }

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

@@ -5,6 +5,7 @@ type PlaylistDashboard struct {
 	Slug  string `json:"slug"`
 	Title string `json:"title"`
 	Uri   string `json:"uri"`
+	Url   string `json:"url"`
 	Order int    `json:"order"`
 }
 

+ 8 - 9
pkg/api/http_server.go

@@ -21,7 +21,6 @@ import (
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"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/datasources"
 	"github.com/grafana/grafana/pkg/services/hooks"
@@ -48,14 +47,14 @@ type HTTPServer struct {
 	streamManager *live.StreamManager
 	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:""`
-	AuthTokenService auth.UserAuthTokenService `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 models.UserTokenService  `inject:""`
 }
 
 func (hs *HTTPServer) Init() error {

+ 13 - 5
pkg/api/login.go

@@ -10,6 +10,7 @@ import (
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/metrics"
+	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
@@ -126,17 +127,23 @@ func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response
 
 func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
 	if user == nil {
-		hs.log.Error("User login with nil user")
+		hs.log.Error("user login with nil user")
 	}
 
-	err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
+	userToken, err := hs.AuthTokenService.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
 	if err != nil {
-		hs.log.Error("User auth hook failed", "error", err)
+		hs.log.Error("failed to create auth token", "error", err)
 	}
+
+	middleware.WriteSessionCookie(c, userToken.UnhashedToken, hs.Cfg.LoginMaxLifetimeDays)
 }
 
 func (hs *HTTPServer) Logout(c *m.ReqContext) {
-	hs.AuthTokenService.SignOutUser(c)
+	if err := hs.AuthTokenService.RevokeToken(c.UserToken); err != nil && err != m.ErrUserTokenNotFound {
+		hs.log.Error("failed to revoke auth token", "error", err)
+	}
+
+	middleware.WriteSessionCookie(c, "", -1)
 
 	if setting.SignoutRedirectUrl != "" {
 		c.Redirect(setting.SignoutRedirectUrl)
@@ -176,7 +183,8 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string
 		Value:    hex.EncodeToString(encryptedError),
 		HttpOnly: true,
 		Path:     setting.AppSubUrl + "/",
-		Secure:   hs.Cfg.SecurityHTTPSCookies,
+		Secure:   hs.Cfg.CookieSecure,
+		SameSite: hs.Cfg.CookieSameSite,
 	})
 
 	return nil

+ 2 - 1
pkg/api/login_oauth.go

@@ -214,7 +214,8 @@ func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value stri
 		Value:    value,
 		HttpOnly: true,
 		Path:     setting.AppSubUrl + "/",
-		Secure:   hs.Cfg.SecurityHTTPSCookies,
+		Secure:   hs.Cfg.CookieSecure,
+		SameSite: hs.Cfg.CookieSameSite,
 	})
 }
 

+ 1 - 0
pkg/api/playlist_play.go

@@ -26,6 +26,7 @@ func populateDashboardsByID(dashboardByIDs []int64, dashboardIDOrder map[int64]i
 				Slug:  item.Slug,
 				Title: item.Title,
 				Uri:   "db/" + item.Slug,
+				Url:   m.GetDashboardUrl(item.Uid, item.Slug),
 				Order: dashboardIDOrder[item.Id],
 			})
 		}

+ 1 - 0
pkg/cmd/grafana-server/server.go

@@ -32,6 +32,7 @@ import (
 	_ "github.com/grafana/grafana/pkg/metrics"
 	_ "github.com/grafana/grafana/pkg/plugins"
 	_ "github.com/grafana/grafana/pkg/services/alerting"
+	_ "github.com/grafana/grafana/pkg/services/auth"
 	_ "github.com/grafana/grafana/pkg/services/cleanup"
 	_ "github.com/grafana/grafana/pkg/services/notifications"
 	_ "github.com/grafana/grafana/pkg/services/provisioning"

+ 20 - 8
pkg/login/ldap.go

@@ -273,23 +273,35 @@ func (a *ldapAuther) initialBind(username, userPassword string) error {
 	return nil
 }
 
+func appendIfNotEmpty(slice []string, values ...string) []string {
+	for _, v := range values {
+		if v != "" {
+			slice = append(slice, v)
+		}
+	}
+	return slice
+}
+
 func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
 	var searchResult *ldap.SearchResult
 	var err error
 
 	for _, searchBase := range a.server.SearchBaseDNs {
+		attributes := make([]string, 0)
+		inputs := a.server.Attr
+		attributes = appendIfNotEmpty(attributes,
+			inputs.Username,
+			inputs.Surname,
+			inputs.Email,
+			inputs.Name,
+			inputs.MemberOf)
+
 		searchReq := ldap.SearchRequest{
 			BaseDN:       searchBase,
 			Scope:        ldap.ScopeWholeSubtree,
 			DerefAliases: ldap.NeverDerefAliases,
-			Attributes: []string{
-				a.server.Attr.Username,
-				a.server.Attr.Surname,
-				a.server.Attr.Email,
-				a.server.Attr.Name,
-				a.server.Attr.MemberOf,
-			},
-			Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
+			Attributes:   attributes,
+			Filter:       strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
 		}
 
 		a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))

+ 45 - 3
pkg/login/ldap_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/log"
 	m "github.com/grafana/grafana/pkg/models"
 	. "github.com/smartystreets/goconvey/convey"
 	"gopkg.in/ldap.v3"
@@ -322,11 +323,51 @@ func TestLdapAuther(t *testing.T) {
 			So(sc.addOrgUserCmd.Role, ShouldEqual, "Admin")
 		})
 	})
+
+	Convey("When searching for a user and not all five attributes are mapped", t, func() {
+		mockLdapConnection := &mockLdapConn{}
+		entry := ldap.Entry{
+			DN: "dn", Attributes: []*ldap.EntryAttribute{
+				{Name: "username", Values: []string{"roelgerrits"}},
+				{Name: "surname", Values: []string{"Gerrits"}},
+				{Name: "email", Values: []string{"roel@test.com"}},
+				{Name: "name", Values: []string{"Roel"}},
+				{Name: "memberof", Values: []string{"admins"}},
+			}}
+		result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}}
+		mockLdapConnection.setSearchResult(&result)
+
+		// Set up attribute map without surname and email
+		ldapAuther := &ldapAuther{
+			server: &LdapServerConf{
+				Attr: LdapAttributeMap{
+					Username: "username",
+					Name:     "name",
+					MemberOf: "memberof",
+				},
+				SearchBaseDNs: []string{"BaseDNHere"},
+			},
+			conn: mockLdapConnection,
+			log:  log.New("test-logger"),
+		}
+
+		searchResult, err := ldapAuther.searchForUser("roelgerrits")
+
+		So(err, ShouldBeNil)
+		So(searchResult, ShouldNotBeNil)
+
+		// User should be searched in ldap
+		So(mockLdapConnection.searchCalled, ShouldBeTrue)
+
+		// No empty attributes should be added to the search request
+		So(len(mockLdapConnection.searchAttributes), ShouldEqual, 3)
+	})
 }
 
 type mockLdapConn struct {
-	result       *ldap.SearchResult
-	searchCalled bool
+	result           *ldap.SearchResult
+	searchCalled     bool
+	searchAttributes []string
 }
 
 func (c *mockLdapConn) Bind(username, password string) error {
@@ -339,8 +380,9 @@ func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) {
 	c.result = result
 }
 
-func (c *mockLdapConn) Search(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+func (c *mockLdapConn) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) {
 	c.searchCalled = true
+	c.searchAttributes = sr.Attributes
 	return c.result, nil
 }
 

+ 68 - 3
pkg/middleware/middleware.go

@@ -1,13 +1,15 @@
 package middleware
 
 import (
+	"net/http"
+	"net/url"
 	"strconv"
+	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/apikeygen"
 	"github.com/grafana/grafana/pkg/log"
 	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/setting"
 	"github.com/grafana/grafana/pkg/util"
@@ -21,7 +23,7 @@ var (
 	ReqOrgAdmin     = RoleAuth(m.ROLE_ADMIN)
 )
 
-func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
+func GetContextHandler(ats m.UserTokenService) macaron.Handler {
 	return func(c *macaron.Context) {
 		ctx := &m.ReqContext{
 			Context:        c,
@@ -49,7 +51,7 @@ func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
 		case initContextWithApiKey(ctx):
 		case initContextWithBasicAuth(ctx, orgId):
 		case initContextWithAuthProxy(ctx, orgId):
-		case ats.InitContextWithToken(ctx, orgId):
+		case initContextWithToken(ats, ctx, orgId):
 		case initContextWithAnonymousUser(ctx):
 		}
 
@@ -166,6 +168,69 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
 	return true
 }
 
+func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext, orgID int64) bool {
+	rawToken := ctx.GetCookie(setting.LoginCookieName)
+	if rawToken == "" {
+		return false
+	}
+
+	token, err := authTokenService.LookupToken(rawToken)
+	if err != nil {
+		ctx.Logger.Error("failed to look up user based on cookie", "error", err)
+		WriteSessionCookie(ctx, "", -1)
+		return false
+	}
+
+	query := m.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID}
+	if err := bus.Dispatch(&query); err != nil {
+		ctx.Logger.Error("failed to get user with id", "userId", token.UserId, "error", err)
+		return false
+	}
+
+	ctx.SignedInUser = query.Result
+	ctx.IsSignedIn = true
+	ctx.UserToken = token
+
+	rotated, err := authTokenService.TryRotateToken(token, ctx.RemoteAddr(), ctx.Req.UserAgent())
+	if err != nil {
+		ctx.Logger.Error("failed to rotate token", "error", err)
+		return true
+	}
+
+	if rotated {
+		WriteSessionCookie(ctx, token.UnhashedToken, setting.LoginMaxLifetimeDays)
+	}
+
+	return true
+}
+
+func WriteSessionCookie(ctx *m.ReqContext, value string, maxLifetimeDays int) {
+	if setting.Env == setting.DEV {
+		ctx.Logger.Info("new token", "unhashed token", value)
+	}
+
+	var maxAge int
+	if maxLifetimeDays <= 0 {
+		maxAge = -1
+	} else {
+		maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour) + time.Hour
+		maxAge = int(maxAgeHours.Seconds())
+	}
+
+	ctx.Resp.Header().Del("Set-Cookie")
+	cookie := http.Cookie{
+		Name:     setting.LoginCookieName,
+		Value:    url.QueryEscape(value),
+		HttpOnly: true,
+		Path:     setting.AppSubUrl + "/",
+		Secure:   setting.CookieSecure,
+		MaxAge:   maxAge,
+		SameSite: setting.CookieSameSite,
+	}
+
+	http.SetCookie(ctx.Resp, &cookie)
+}
+
 func AddDefaultResponseHeaders() macaron.Handler {
 	return func(ctx *m.ReqContext) {
 		if ctx.IsApiRequest() && ctx.Req.Method == "GET" {

+ 134 - 15
pkg/middleware/middleware_test.go

@@ -6,6 +6,7 @@ import (
 	"net/http/httptest"
 	"path/filepath"
 	"testing"
+	"time"
 
 	msession "github.com/go-macaron/session"
 	"github.com/grafana/grafana/pkg/bus"
@@ -146,17 +147,95 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 		})
 
-		middlewareScenario("Auth token service", func(sc *scenarioContext) {
-			var wasCalled bool
-			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
-				wasCalled = true
-				return false
+		middlewareScenario("Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) {
+			sc.withTokenSessionCookie("token")
+
+			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+				query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
+				return nil
+			})
+
+			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+				return &m.UserToken{
+					UserId:        12,
+					UnhashedToken: unhashedToken,
+				}, nil
+			}
+
+			sc.fakeReq("GET", "/").exec()
+
+			Convey("should init context with user info", func() {
+				So(sc.context.IsSignedIn, ShouldBeTrue)
+				So(sc.context.UserId, ShouldEqual, 12)
+				So(sc.context.UserToken.UserId, ShouldEqual, 12)
+				So(sc.context.UserToken.UnhashedToken, ShouldEqual, "token")
+			})
+
+			Convey("should not set cookie", func() {
+				So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, "")
+			})
+		})
+
+		middlewareScenario("Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) {
+			sc.withTokenSessionCookie("token")
+
+			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+				query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
+				return nil
+			})
+
+			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+				return &m.UserToken{
+					UserId:        12,
+					UnhashedToken: "",
+				}, nil
+			}
+
+			sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
+				userToken.UnhashedToken = "rotated"
+				return true, nil
+			}
+
+			maxAgeHours := (time.Duration(setting.LoginMaxLifetimeDays) * 24 * time.Hour)
+			maxAge := (maxAgeHours + time.Hour).Seconds()
+
+			expectedCookie := &http.Cookie{
+				Name:     setting.LoginCookieName,
+				Value:    "rotated",
+				Path:     setting.AppSubUrl + "/",
+				HttpOnly: true,
+				MaxAge:   int(maxAge),
+				Secure:   setting.CookieSecure,
+				SameSite: setting.CookieSameSite,
 			}
 
 			sc.fakeReq("GET", "/").exec()
 
-			Convey("should call middleware", func() {
-				So(wasCalled, ShouldBeTrue)
+			Convey("should init context with user info", func() {
+				So(sc.context.IsSignedIn, ShouldBeTrue)
+				So(sc.context.UserId, ShouldEqual, 12)
+				So(sc.context.UserToken.UserId, ShouldEqual, 12)
+				So(sc.context.UserToken.UnhashedToken, ShouldEqual, "rotated")
+			})
+
+			Convey("should set cookie", func() {
+				So(sc.resp.Header().Get("Set-Cookie"), ShouldEqual, expectedCookie.String())
+			})
+		})
+
+		middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
+			sc.withTokenSessionCookie("token")
+
+			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+				return nil, m.ErrUserTokenNotFound
+			}
+
+			sc.fakeReq("GET", "/").exec()
+
+			Convey("should not init context with user info", func() {
+				So(sc.context.IsSignedIn, ShouldBeFalse)
+				So(sc.context.UserId, ShouldEqual, 0)
+				So(sc.context.UserToken, ShouldBeNil)
 			})
 		})
 
@@ -469,6 +548,9 @@ func middlewareScenario(desc string, fn scenarioFunc) {
 	Convey(desc, func() {
 		defer bus.ClearBusHandlers()
 
+		setting.LoginCookieName = "grafana_session"
+		setting.LoginMaxLifetimeDays = 30
+
 		sc := &scenarioContext{}
 
 		viewsPath, _ := filepath.Abs("../../public/views")
@@ -508,6 +590,7 @@ type scenarioContext struct {
 	resp                 *httptest.ResponseRecorder
 	apiKey               string
 	authHeader           string
+	tokenSessionCookie   string
 	respJson             map[string]interface{}
 	handlerFunc          handlerFunc
 	defaultHandler       macaron.Handler
@@ -522,6 +605,11 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext {
 	return sc
 }
 
+func (sc *scenarioContext) withTokenSessionCookie(unhashedToken string) *scenarioContext {
+	sc.tokenSessionCookie = unhashedToken
+	return sc
+}
+
 func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
 	sc.authHeader = authHeader
 	return sc
@@ -571,6 +659,13 @@ func (sc *scenarioContext) exec() {
 		sc.req.Header.Add("Authorization", sc.authHeader)
 	}
 
+	if sc.tokenSessionCookie != "" {
+		sc.req.AddCookie(&http.Cookie{
+			Name:  setting.LoginCookieName,
+			Value: sc.tokenSessionCookie,
+		})
+	}
+
 	sc.m.ServeHTTP(sc.resp, sc.req)
 
 	if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" {
@@ -583,23 +678,47 @@ type scenarioFunc func(c *scenarioContext)
 type handlerFunc func(c *m.ReqContext)
 
 type fakeUserAuthTokenService struct {
-	initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
+	createTokenProvider    func(userId int64, clientIP, userAgent string) (*m.UserToken, error)
+	tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error)
+	lookupTokenProvider    func(unhashedToken string) (*m.UserToken, error)
+	revokeTokenProvider    func(token *m.UserToken) error
 }
 
 func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
 	return &fakeUserAuthTokenService{
-		initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
-			return false
+		createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
+			return &m.UserToken{
+				UserId:        0,
+				UnhashedToken: "",
+			}, nil
+		},
+		tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) {
+			return false, nil
+		},
+		lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) {
+			return &m.UserToken{
+				UserId:        0,
+				UnhashedToken: "",
+			}, nil
+		},
+		revokeTokenProvider: func(token *m.UserToken) error {
+			return nil
 		},
 	}
 }
 
-func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
-	return s.initContextWithTokenProvider(ctx, orgID)
+func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
+	return s.createTokenProvider(userId, clientIP, userAgent)
 }
 
-func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
-	return nil
+func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) {
+	return s.lookupTokenProvider(unhashedToken)
 }
 
-func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }
+func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) {
+	return s.tryRotateTokenProvider(token, clientIP, userAgent)
+}
+
+func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error {
+	return s.revokeTokenProvider(token)
+}

+ 19 - 10
pkg/middleware/org_redirect_test.go

@@ -14,14 +14,21 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 
 	Convey("Can redirect to correct org", t, func() {
 		middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
+			sc.withTokenSessionCookie("token")
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 				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
+			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+				query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
+				return nil
+			})
+
+			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+				return &m.UserToken{
+					UserId:        0,
+					UnhashedToken: "",
+				}, nil
 			}
 
 			sc.m.Get("/", sc.defaultHandler)
@@ -33,21 +40,23 @@ func TestOrgRedirectMiddleware(t *testing.T) {
 		})
 
 		middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
+			sc.withTokenSessionCookie("token")
 			bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
 				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 {
 				query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
 				return nil
 			})
 
+			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+				return &m.UserToken{
+					UserId:        12,
+					UnhashedToken: "",
+				}, nil
+			}
+
 			sc.m.Get("/", sc.defaultHandler)
 			sc.fakeReq("GET", "/?orgId=3").exec()
 

+ 11 - 4
pkg/middleware/quota_test.go

@@ -74,10 +74,17 @@ func TestMiddlewareQuota(t *testing.T) {
 		})
 
 		middlewareScenario("with user logged in", func(sc *scenarioContext) {
-			sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
-				ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
-				ctx.IsSignedIn = true
-				return true
+			sc.withTokenSessionCookie("token")
+			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+				query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
+				return nil
+			})
+
+			sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+				return &m.UserToken{
+					UserId:        12,
+					UnhashedToken: "",
+				}, nil
 			}
 
 			bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {

+ 1 - 0
pkg/models/context.go

@@ -13,6 +13,7 @@ import (
 type ReqContext struct {
 	*macaron.Context
 	*SignedInUser
+	UserToken *UserToken
 
 	// This should only be used by the auth_proxy
 	Session session.SessionStore

+ 32 - 0
pkg/models/user_token.go

@@ -0,0 +1,32 @@
+package models
+
+import "errors"
+
+// Typed errors
+var (
+	ErrUserTokenNotFound = errors.New("user token not found")
+)
+
+// UserToken represents a user token
+type UserToken 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
+}
+
+// UserTokenService are used for generating and validating user tokens
+type UserTokenService interface {
+	CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error)
+	LookupToken(unhashedToken string) (*UserToken, error)
+	TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error)
+	RevokeToken(token *UserToken) error
+}

+ 82 - 136
pkg/services/auth/auth_token.go

@@ -3,13 +3,10 @@ package auth
 import (
 	"crypto/sha256"
 	"encoding/hex"
-	"errors"
-	"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"
@@ -19,116 +16,26 @@ import (
 )
 
 func init() {
-	registry.RegisterService(&UserAuthTokenServiceImpl{})
+	registry.RegisterService(&UserAuthTokenService{})
 }
 
-var (
-	getTime          = time.Now
-	UrgentRotateTime = 1 * time.Minute
-	oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
-)
+var getTime = time.Now
 
-// 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
-	SignOutUser(c *models.ReqContext) error
-}
+const urgentRotateTime = 1 * time.Minute
 
-type UserAuthTokenServiceImpl struct {
+type UserAuthTokenService struct {
 	SQLStore          *sqlstore.SqlStore            `inject:""`
 	ServerLockService *serverlock.ServerLockService `inject:""`
 	Cfg               *setting.Cfg                  `inject:""`
 	log               log.Logger
 }
 
-// Init this service
-func (s *UserAuthTokenServiceImpl) Init() error {
+func (s *UserAuthTokenService) 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.Debug("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,
-		SameSite: s.Cfg.LoginCookieSameSite,
-	}
-
-	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) SignOutUser(c *models.ReqContext) error {
-	unhashedToken := c.GetCookie(s.Cfg.LoginCookieName)
-	if unhashedToken == "" {
-		return errors.New("cannot logout without session token")
-	}
-
-	hashedToken := hashToken(unhashedToken)
-
-	sql := `DELETE FROM user_auth_token WHERE auth_token = ?`
-	_, err := s.SQLStore.NewSession().Exec(sql, hashedToken)
-
-	s.writeSessionCookie(c, "", -1)
-	return err
-}
-
-func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
+func (s *UserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
 	clientIP = util.ParseIPAddress(clientIP)
 	token, err := util.RandomHex(16)
 	if err != nil {
@@ -139,7 +46,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
 
 	now := getTime().Unix()
 
-	userToken := userAuthToken{
+	userAuthToken := userAuthToken{
 		UserId:        userId,
 		AuthToken:     hashedToken,
 		PrevAuthToken: hashedToken,
@@ -151,98 +58,114 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
 		SeenAt:        0,
 		AuthTokenSeen: false,
 	}
-	_, err = s.SQLStore.NewSession().Insert(&userToken)
+	_, err = s.SQLStore.NewSession().Insert(&userAuthToken)
 	if err != nil {
 		return nil, err
 	}
 
-	userToken.UnhashedToken = token
+	userAuthToken.UnhashedToken = token
+
+	s.log.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken)
 
-	return &userToken, nil
+	var userToken models.UserToken
+	err = userAuthToken.toUserToken(&userToken)
+
+	return &userToken, err
 }
 
-func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
+func (s *UserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) {
 	hashedToken := hashToken(unhashedToken)
 	if setting.Env == setting.DEV {
 		s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
 	}
 
-	expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
+	tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
+	tokenMaxInactiveLifetime := time.Duration(s.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
+	createdAfter := getTime().Add(-tokenMaxLifetime).Unix()
+	rotatedAfter := getTime().Add(-tokenMaxInactiveLifetime).Unix()
 
-	var userToken userAuthToken
-	exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
+	var model userAuthToken
+	exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ? AND rotated_at > ?", hashedToken, hashedToken, createdAfter, rotatedAfter).Get(&model)
 	if err != nil {
 		return nil, err
 	}
 
 	if !exists {
-		return nil, ErrAuthTokenNotFound
+		return nil, models.ErrUserTokenNotFound
 	}
 
-	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 model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen {
+		modelCopy := model
+		modelCopy.AuthTokenSeen = false
+		expireBefore := getTime().Add(-urgentRotateTime).Unix()
+		affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", modelCopy.Id, modelCopy.PrevAuthToken, expireBefore).AllCols().Update(&modelCopy)
 		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)
+			s.log.Debug("prev seen token unchanged", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
 		} else {
-			s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+			s.log.Debug("prev seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
 		}
 	}
 
-	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 !model.AuthTokenSeen && model.AuthToken == hashedToken {
+		modelCopy := model
+		modelCopy.AuthTokenSeen = true
+		modelCopy.SeenAt = getTime().Unix()
+		affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", modelCopy.Id, modelCopy.AuthToken).AllCols().Update(&modelCopy)
 		if err != nil {
 			return nil, err
 		}
 
 		if affectedRows == 1 {
-			userToken = userTokenCopy
+			model = modelCopy
 		}
 
 		if affectedRows == 0 {
-			s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+			s.log.Debug("seen wrong token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
 		} else {
-			s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+			s.log.Debug("seen token", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "authToken", model.AuthToken)
 		}
 	}
 
-	userToken.UnhashedToken = unhashedToken
+	model.UnhashedToken = unhashedToken
+
+	var userToken models.UserToken
+	err = model.toUserToken(&userToken)
 
-	return &userToken, nil
+	return &userToken, err
 }
 
-func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) {
+func (s *UserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) {
 	if token == nil {
 		return false, nil
 	}
 
+	model := userAuthTokenFromUserToken(token)
+
 	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))
+	rotatedAt := time.Unix(model.RotatedAt, 0)
+	if model.AuthTokenSeen {
+		needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.TokenRotationIntervalMinutes) * time.Minute))
 	} else {
-		needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime))
+		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)
+	s.log.Debug("token needs rotation", "tokenId", model.Id, "authTokenSeen", model.AuthTokenSeen, "rotatedAt", rotatedAt)
 
 	clientIP = util.ParseIPAddress(clientIP)
-	newToken, _ := util.RandomHex(16)
+	newToken, err := util.RandomHex(16)
+	if err != nil {
+		return false, err
+	}
 	hashedToken := hashToken(newToken)
 
 	// very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
@@ -258,21 +181,44 @@ func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP,
 			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())
+	res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), model.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)
+	s.log.Debug("auth token rotated", "affected", affected, "auth_token_id", model.Id, "userId", model.UserId)
 	if affected > 0 {
-		token.UnhashedToken = newToken
+		model.UnhashedToken = newToken
+		model.toUserToken(token)
 		return true, nil
 	}
 
 	return false, nil
 }
 
+func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error {
+	if token == nil {
+		return models.ErrUserTokenNotFound
+	}
+
+	model := userAuthTokenFromUserToken(token)
+
+	rowsAffected, err := s.SQLStore.NewSession().Delete(model)
+	if err != nil {
+		return err
+	}
+
+	if rowsAffected == 0 {
+		s.log.Debug("user auth token not found/revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
+		return models.ErrUserTokenNotFound
+	}
+
+	s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
+
+	return nil
+}
+
 func hashToken(token string) string {
 	hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
 	return hex.EncodeToString(hashBytes[:])

+ 243 - 140
pkg/services/auth/auth_token_test.go

@@ -1,17 +1,15 @@
 package auth
 
 import (
-	"fmt"
-	"net/http"
-	"net/http/httptest"
+	"encoding/json"
 	"testing"
 	"time"
 
-	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/setting"
-	macaron "gopkg.in/macaron.v1"
 
 	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/sqlstore"
 	. "github.com/smartystreets/goconvey/convey"
 )
@@ -28,236 +26,265 @@ func TestUserAuthToken(t *testing.T) {
 		}
 
 		Convey("When creating token", func() {
-			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
 			So(err, ShouldBeNil)
-			So(token, ShouldNotBeNil)
-			So(token.AuthTokenSeen, ShouldBeFalse)
+			So(userToken, ShouldNotBeNil)
+			So(userToken.AuthTokenSeen, ShouldBeFalse)
 
 			Convey("When lookup unhashed token should return user auth token", func() {
-				LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+				userToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
 				So(err, ShouldBeNil)
-				So(LookupToken, ShouldNotBeNil)
-				So(LookupToken.UserId, ShouldEqual, userID)
-				So(LookupToken.AuthTokenSeen, ShouldBeTrue)
+				So(userToken, ShouldNotBeNil)
+				So(userToken.UserId, ShouldEqual, userID)
+				So(userToken.AuthTokenSeen, ShouldBeTrue)
 
-				storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id)
+				storedAuthToken, err := ctx.getAuthTokenByID(userToken.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)
+				userToken, err := userAuthTokenService.LookupToken(userToken.AuthToken)
+				So(err, ShouldEqual, models.ErrUserTokenNotFound)
+				So(userToken, ShouldBeNil)
 			})
 
-			Convey("signing out should delete token and cookie if present", func() {
-				httpreq := &http.Request{Header: make(http.Header)}
-				httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: token.UnhashedToken})
-
-				ctx := &models.ReqContext{Context: &macaron.Context{
-					Req:  macaron.Request{Request: httpreq},
-					Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
-				},
-					Logger: log.New("fakelogger"),
-				}
-
-				err = userAuthTokenService.SignOutUser(ctx)
+			Convey("revoking existing token should delete token", func() {
+				err = userAuthTokenService.RevokeToken(userToken)
 				So(err, ShouldBeNil)
 
-				// makes sure we tell the browser to overwrite the cookie
-				cookieHeader := fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly", userAuthTokenService.Cfg.LoginCookieName)
-				So(ctx.Resp.Header().Get("Set-Cookie"), ShouldEqual, cookieHeader)
+				model, err := ctx.getAuthTokenByID(userToken.Id)
+				So(err, ShouldBeNil)
+				So(model, ShouldBeNil)
 			})
 
-			Convey("signing out an none existing session should return an error", func() {
-				httpreq := &http.Request{Header: make(http.Header)}
-				httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: ""})
-
-				ctx := &models.ReqContext{Context: &macaron.Context{
-					Req:  macaron.Request{Request: httpreq},
-					Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
-				},
-					Logger: log.New("fakelogger"),
-				}
+			Convey("revoking nil token should return error", func() {
+				err = userAuthTokenService.RevokeToken(nil)
+				So(err, ShouldEqual, models.ErrUserTokenNotFound)
+			})
 
-				err = userAuthTokenService.SignOutUser(ctx)
-				So(err, ShouldNotBeNil)
+			Convey("revoking non-existing token should return error", func() {
+				userToken.Id = 1000
+				err = userAuthTokenService.RevokeToken(userToken)
+				So(err, ShouldEqual, models.ErrUserTokenNotFound)
 			})
 		})
 
 		Convey("expires correctly", func() {
-			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			userToken, 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)
+			userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
 			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")
+			rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.11:1234", "some user agent")
 			So(err, ShouldBeNil)
-			So(refreshed, ShouldBeTrue)
+			So(rotated, ShouldBeTrue)
 
-			_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
 			So(err, ShouldBeNil)
 
-			stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			stillGood, err := userAuthTokenService.LookupToken(userToken.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)
+			model, err := ctx.getAuthTokenByID(userToken.Id)
+			So(err, ShouldBeNil)
+
+			Convey("when rotated_at is 6:59:59 ago should find token", func() {
+				getTime = func() time.Time {
+					return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour).Add(-time.Second)
+				}
+
+				stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken)
+				So(err, ShouldBeNil)
+				So(stillGood, ShouldNotBeNil)
+			})
+
+			Convey("when rotated_at is 7:00:00 ago should not find token", func() {
+				getTime = func() time.Time {
+					return time.Unix(model.RotatedAt, 0).Add(24 * 7 * time.Hour)
+				}
+
+				notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
+				So(err, ShouldEqual, models.ErrUserTokenNotFound)
+				So(notGood, ShouldBeNil)
+			})
+
+			Convey("when rotated_at is 5 days ago and created_at is 29 days and 23:59:59 ago should not find token", func() {
+				updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix())
+				So(err, ShouldBeNil)
+				So(updated, ShouldBeTrue)
+
+				getTime = func() time.Time {
+					return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour).Add(-time.Second)
+				}
+
+				stillGood, err = userAuthTokenService.LookupToken(stillGood.UnhashedToken)
+				So(err, ShouldBeNil)
+				So(stillGood, ShouldNotBeNil)
+			})
+
+			Convey("when rotated_at is 5 days ago and created_at is 30 days ago should not find token", func() {
+				updated, err := ctx.updateRotatedAt(model.Id, time.Unix(model.CreatedAt, 0).Add(24*25*time.Hour).Unix())
+				So(err, ShouldBeNil)
+				So(updated, ShouldBeTrue)
+
+				getTime = func() time.Time {
+					return time.Unix(model.CreatedAt, 0).Add(24 * 30 * time.Hour)
+				}
+
+				notGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
+				So(err, ShouldEqual, models.ErrUserTokenNotFound)
+				So(notGood, ShouldBeNil)
+			})
 		})
 
 		Convey("can properly rotate tokens", func() {
-			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
 			So(err, ShouldBeNil)
-			So(token, ShouldNotBeNil)
 
-			prevToken := token.AuthToken
-			unhashedPrev := token.UnhashedToken
+			prevToken := userToken.AuthToken
+			unhashedPrev := userToken.UnhashedToken
 
-			refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			rotated, err := userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent")
 			So(err, ShouldBeNil)
-			So(refreshed, ShouldBeFalse)
+			So(rotated, ShouldBeFalse)
 
-			updated, err := ctx.markAuthTokenAsSeen(token.Id)
+			updated, err := ctx.markAuthTokenAsSeen(userToken.Id)
 			So(err, ShouldBeNil)
 			So(updated, ShouldBeTrue)
 
-			token, err = ctx.getAuthTokenByID(token.Id)
+			model, err := ctx.getAuthTokenByID(userToken.Id)
+			So(err, ShouldBeNil)
+
+			var tok models.UserToken
+			err = model.toUserToken(&tok)
 			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")
+			rotated, err = userAuthTokenService.TryRotateToken(&tok, "192.168.10.12:1234", "a new user agent")
 			So(err, ShouldBeNil)
-			So(refreshed, ShouldBeTrue)
+			So(rotated, ShouldBeTrue)
 
-			unhashedToken := token.UnhashedToken
+			unhashedToken := tok.UnhashedToken
 
-			token, err = ctx.getAuthTokenByID(token.Id)
+			model, err = ctx.getAuthTokenByID(tok.Id)
 			So(err, ShouldBeNil)
-			token.UnhashedToken = unhashedToken
+			model.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)
+			So(model.RotatedAt, ShouldEqual, getTime().Unix())
+			So(model.ClientIp, ShouldEqual, "192.168.10.12")
+			So(model.UserAgent, ShouldEqual, "a new user agent")
+			So(model.AuthTokenSeen, ShouldBeFalse)
+			So(model.SeenAt, ShouldEqual, 0)
+			So(model.PrevAuthToken, ShouldEqual, prevToken)
 
 			// ability to auth using an old token
 
-			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
-			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
-			So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
+			So(lookedUpUserToken, ShouldNotBeNil)
+			So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
+			So(lookedUpUserToken.SeenAt, ShouldEqual, getTime().Unix())
 
-			lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+			lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
-			So(lookedUp.Id, ShouldEqual, token.Id)
-			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+			So(lookedUpUserToken, ShouldNotBeNil)
+			So(lookedUpUserToken.Id, ShouldEqual, model.Id)
+			So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
 
 			getTime = func() time.Time {
 				return t.Add(time.Hour + (2 * time.Minute))
 			}
 
-			lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+			lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
-			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+			So(lookedUpUserToken, ShouldNotBeNil)
+			So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
 
-			lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+			lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
-			So(lookedUp.AuthTokenSeen, ShouldBeFalse)
+			So(lookedUpModel, ShouldNotBeNil)
+			So(lookedUpModel.AuthTokenSeen, ShouldBeFalse)
 
-			refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+			rotated, err = userAuthTokenService.TryRotateToken(userToken, "192.168.10.12:1234", "a new user agent")
 			So(err, ShouldBeNil)
-			So(refreshed, ShouldBeTrue)
+			So(rotated, ShouldBeTrue)
 
-			token, err = ctx.getAuthTokenByID(token.Id)
+			model, err = ctx.getAuthTokenByID(userToken.Id)
 			So(err, ShouldBeNil)
-			So(token, ShouldNotBeNil)
-			So(token.SeenAt, ShouldEqual, 0)
+			So(model, ShouldNotBeNil)
+			So(model.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")
+			userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
 			So(err, ShouldBeNil)
-			So(token, ShouldNotBeNil)
+			So(userToken, ShouldNotBeNil)
 
-			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
+			So(lookedUpUserToken, ShouldNotBeNil)
 
 			getTime = func() time.Time {
 				return t.Add(10 * time.Minute)
 			}
 
-			prevToken := token.UnhashedToken
-			refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+			prevToken := userToken.UnhashedToken
+			rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
 			So(err, ShouldBeNil)
-			So(refreshed, ShouldBeTrue)
+			So(rotated, ShouldBeTrue)
 
 			getTime = func() time.Time {
 				return t.Add(20 * time.Minute)
 			}
 
-			current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			currentUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
 			So(err, ShouldBeNil)
-			So(current, ShouldNotBeNil)
+			So(currentUserToken, ShouldNotBeNil)
 
-			prev, err := userAuthTokenService.LookupToken(prevToken)
+			prevUserToken, err := userAuthTokenService.LookupToken(prevToken)
 			So(err, ShouldBeNil)
-			So(prev, ShouldNotBeNil)
+			So(prevUserToken, 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")
+			userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
 			So(err, ShouldBeNil)
-			So(token, ShouldNotBeNil)
+			So(userToken, ShouldNotBeNil)
 
-			lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+			lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
+			So(lookedUpUserToken, ShouldNotBeNil)
 
-			lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+			lookedUpUserToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
+			So(lookedUpUserToken, ShouldNotBeNil)
 
-			lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+			lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
 			So(err, ShouldBeNil)
-			So(lookedUp, ShouldNotBeNil)
-			So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+			So(lookedUpModel, ShouldNotBeNil)
+			So(lookedUpModel.AuthTokenSeen, ShouldBeTrue)
 		})
 
 		Convey("Rotate token", func() {
-			token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+			userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
 			So(err, ShouldBeNil)
-			So(token, ShouldNotBeNil)
+			So(userToken, ShouldNotBeNil)
 
-			prevToken := token.AuthToken
+			prevToken := userToken.AuthToken
 
 			Convey("Should rotate current token and previous token when auth token seen", func() {
-				updated, err := ctx.markAuthTokenAsSeen(token.Id)
+				updated, err := ctx.markAuthTokenAsSeen(userToken.Id)
 				So(err, ShouldBeNil)
 				So(updated, ShouldBeTrue)
 
@@ -265,11 +292,11 @@ func TestUserAuthToken(t *testing.T) {
 					return t.Add(10 * time.Minute)
 				}
 
-				refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
 				So(err, ShouldBeNil)
-				So(refreshed, ShouldBeTrue)
+				So(rotated, ShouldBeTrue)
 
-				storedToken, err := ctx.getAuthTokenByID(token.Id)
+				storedToken, err := ctx.getAuthTokenByID(userToken.Id)
 				So(err, ShouldBeNil)
 				So(storedToken, ShouldNotBeNil)
 				So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -278,7 +305,7 @@ func TestUserAuthToken(t *testing.T) {
 
 				prevToken = storedToken.AuthToken
 
-				updated, err = ctx.markAuthTokenAsSeen(token.Id)
+				updated, err = ctx.markAuthTokenAsSeen(userToken.Id)
 				So(err, ShouldBeNil)
 				So(updated, ShouldBeTrue)
 
@@ -286,11 +313,11 @@ func TestUserAuthToken(t *testing.T) {
 					return t.Add(20 * time.Minute)
 				}
 
-				refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+				rotated, err = userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
 				So(err, ShouldBeNil)
-				So(refreshed, ShouldBeTrue)
+				So(rotated, ShouldBeTrue)
 
-				storedToken, err = ctx.getAuthTokenByID(token.Id)
+				storedToken, err = ctx.getAuthTokenByID(userToken.Id)
 				So(err, ShouldBeNil)
 				So(storedToken, ShouldNotBeNil)
 				So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -299,17 +326,17 @@ func TestUserAuthToken(t *testing.T) {
 			})
 
 			Convey("Should rotate current token, but keep previous token when auth token not seen", func() {
-				token.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
+				userToken.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")
+				rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
 				So(err, ShouldBeNil)
-				So(refreshed, ShouldBeTrue)
+				So(rotated, ShouldBeTrue)
 
-				storedToken, err := ctx.getAuthTokenByID(token.Id)
+				storedToken, err := ctx.getAuthTokenByID(userToken.Id)
 				So(err, ShouldBeNil)
 				So(storedToken, ShouldNotBeNil)
 				So(storedToken.AuthTokenSeen, ShouldBeFalse)
@@ -318,6 +345,71 @@ func TestUserAuthToken(t *testing.T) {
 			})
 		})
 
+		Convey("When populating userAuthToken from UserToken should copy all properties", func() {
+			ut := models.UserToken{
+				Id:            1,
+				UserId:        2,
+				AuthToken:     "a",
+				PrevAuthToken: "b",
+				UserAgent:     "c",
+				ClientIp:      "d",
+				AuthTokenSeen: true,
+				SeenAt:        3,
+				RotatedAt:     4,
+				CreatedAt:     5,
+				UpdatedAt:     6,
+				UnhashedToken: "e",
+			}
+			utBytes, err := json.Marshal(ut)
+			So(err, ShouldBeNil)
+			utJSON, err := simplejson.NewJson(utBytes)
+			So(err, ShouldBeNil)
+			utMap := utJSON.MustMap()
+
+			var uat userAuthToken
+			uat.fromUserToken(&ut)
+			uatBytes, err := json.Marshal(uat)
+			So(err, ShouldBeNil)
+			uatJSON, err := simplejson.NewJson(uatBytes)
+			So(err, ShouldBeNil)
+			uatMap := uatJSON.MustMap()
+
+			So(uatMap, ShouldResemble, utMap)
+		})
+
+		Convey("When populating userToken from userAuthToken should copy all properties", func() {
+			uat := userAuthToken{
+				Id:            1,
+				UserId:        2,
+				AuthToken:     "a",
+				PrevAuthToken: "b",
+				UserAgent:     "c",
+				ClientIp:      "d",
+				AuthTokenSeen: true,
+				SeenAt:        3,
+				RotatedAt:     4,
+				CreatedAt:     5,
+				UpdatedAt:     6,
+				UnhashedToken: "e",
+			}
+			uatBytes, err := json.Marshal(uat)
+			So(err, ShouldBeNil)
+			uatJSON, err := simplejson.NewJson(uatBytes)
+			So(err, ShouldBeNil)
+			uatMap := uatJSON.MustMap()
+
+			var ut models.UserToken
+			err = uat.toUserToken(&ut)
+			So(err, ShouldBeNil)
+			utBytes, err := json.Marshal(ut)
+			So(err, ShouldBeNil)
+			utJSON, err := simplejson.NewJson(utBytes)
+			So(err, ShouldBeNil)
+			utMap := utJSON.MustMap()
+
+			So(utMap, ShouldResemble, uatMap)
+		})
+
 		Reset(func() {
 			getTime = time.Now
 		})
@@ -328,19 +420,16 @@ func createTestContext(t *testing.T) *testContext {
 	t.Helper()
 
 	sqlstore := sqlstore.InitTestDB(t)
-	tokenService := &UserAuthTokenServiceImpl{
+	tokenService := &UserAuthTokenService{
 		SQLStore: sqlstore,
 		Cfg: &setting.Cfg{
-			LoginCookieName:                   "grafana_session",
-			LoginCookieMaxDays:                7,
-			LoginDeleteExpiredTokensAfterDays: 30,
-			LoginCookieRotation:               10,
+			LoginMaxInactiveLifetimeDays: 7,
+			LoginMaxLifetimeDays:         30,
+			TokenRotationIntervalMinutes: 10,
 		},
 		log: log.New("test-logger"),
 	}
 
-	UrgentRotateTime = time.Minute
-
 	return &testContext{
 		sqlstore:     sqlstore,
 		tokenService: tokenService,
@@ -349,7 +438,7 @@ func createTestContext(t *testing.T) *testContext {
 
 type testContext struct {
 	sqlstore     *sqlstore.SqlStore
-	tokenService *UserAuthTokenServiceImpl
+	tokenService *UserAuthTokenService
 }
 
 func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
@@ -376,3 +465,17 @@ func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
 	}
 	return rowsAffected == 1, nil
 }
+
+func (c *testContext) updateRotatedAt(id, rotatedAt int64) (bool, error) {
+	sess := c.sqlstore.NewSession()
+	res, err := sess.Exec("UPDATE user_auth_token SET rotated_at = ? WHERE id = ?", rotatedAt, id)
+	if err != nil {
+		return false, err
+	}
+
+	rowsAffected, err := res.RowsAffected()
+	if err != nil {
+		return false, err
+	}
+	return rowsAffected == 1, nil
+}

+ 50 - 5
pkg/services/auth/model.go

@@ -1,12 +1,9 @@
 package auth
 
 import (
-	"errors"
-)
+	"fmt"
 
-// Typed errors
-var (
-	ErrAuthTokenNotFound = errors.New("User auth token not found")
+	"github.com/grafana/grafana/pkg/models"
 )
 
 type userAuthToken struct {
@@ -23,3 +20,51 @@ type userAuthToken struct {
 	UpdatedAt     int64
 	UnhashedToken string `xorm:"-"`
 }
+
+func userAuthTokenFromUserToken(ut *models.UserToken) *userAuthToken {
+	var uat userAuthToken
+	uat.fromUserToken(ut)
+	return &uat
+}
+
+func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error {
+	if uat == nil {
+		return fmt.Errorf("needs pointer to userAuthToken struct")
+	}
+
+	uat.Id = ut.Id
+	uat.UserId = ut.UserId
+	uat.AuthToken = ut.AuthToken
+	uat.PrevAuthToken = ut.PrevAuthToken
+	uat.UserAgent = ut.UserAgent
+	uat.ClientIp = ut.ClientIp
+	uat.AuthTokenSeen = ut.AuthTokenSeen
+	uat.SeenAt = ut.SeenAt
+	uat.RotatedAt = ut.RotatedAt
+	uat.CreatedAt = ut.CreatedAt
+	uat.UpdatedAt = ut.UpdatedAt
+	uat.UnhashedToken = ut.UnhashedToken
+
+	return nil
+}
+
+func (uat *userAuthToken) toUserToken(ut *models.UserToken) error {
+	if uat == nil {
+		return fmt.Errorf("needs pointer to userAuthToken struct")
+	}
+
+	ut.Id = uat.Id
+	ut.UserId = uat.UserId
+	ut.AuthToken = uat.AuthToken
+	ut.PrevAuthToken = uat.PrevAuthToken
+	ut.UserAgent = uat.UserAgent
+	ut.ClientIp = uat.ClientIp
+	ut.AuthTokenSeen = uat.AuthTokenSeen
+	ut.SeenAt = uat.SeenAt
+	ut.RotatedAt = uat.RotatedAt
+	ut.CreatedAt = uat.CreatedAt
+	ut.UpdatedAt = uat.UpdatedAt
+	ut.UnhashedToken = uat.UnhashedToken
+
+	return nil
+}

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

@@ -1,38 +0,0 @@
-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
-}

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

@@ -1,36 +0,0 @@
-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)
-	})
-}

+ 57 - 0
pkg/services/auth/token_cleanup.go

@@ -0,0 +1,57 @@
+package auth
+
+import (
+	"context"
+	"time"
+)
+
+func (srv *UserAuthTokenService) Run(ctx context.Context) error {
+	ticker := time.NewTicker(time.Hour)
+	maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
+	maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
+
+	err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
+		srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
+	})
+	if err != nil {
+		srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
+	}
+
+	for {
+		select {
+		case <-ticker.C:
+			err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
+				srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
+			})
+
+			if err != nil {
+				srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
+			}
+
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+}
+
+func (srv *UserAuthTokenService) deleteExpiredTokens(maxInactiveLifetime, maxLifetime time.Duration) (int64, error) {
+	createdBefore := getTime().Add(-maxLifetime)
+	rotatedBefore := getTime().Add(-maxInactiveLifetime)
+
+	srv.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore)
+
+	sql := `DELETE from user_auth_token WHERE created_at <= ? OR rotated_at <= ?`
+	res, err := srv.SQLStore.NewSession().Exec(sql, createdBefore.Unix(), rotatedBefore.Unix())
+	if err != nil {
+		return 0, err
+	}
+
+	affected, err := res.RowsAffected()
+	if err != nil {
+		srv.log.Error("failed to cleanup expired auth tokens", "error", err)
+		return 0, nil
+	}
+
+	srv.log.Info("cleanup of expired auth tokens done", "count", affected)
+	return affected, err
+}

+ 68 - 0
pkg/services/auth/token_cleanup_test.go

@@ -0,0 +1,68 @@
+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)
+		ctx.tokenService.Cfg.LoginMaxInactiveLifetimeDays = 7
+		ctx.tokenService.Cfg.LoginMaxLifetimeDays = 30
+
+		insertToken := func(token string, prev string, createdAt, rotatedAt int64) {
+			ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, CreatedAt: createdAt, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
+			_, err := ctx.sqlstore.NewSession().Insert(&ut)
+			So(err, ShouldBeNil)
+		}
+
+		t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC)
+		getTime = func() time.Time {
+			return t
+		}
+
+		Convey("should delete tokens where token rotation age is older than or equal 7 days", func() {
+			from := t.Add(-7 * 24 * time.Hour)
+
+			// 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), from.Unix(), from.Unix())
+			}
+
+			// insert three active tokens that should not be deleted
+			for i := 0; i < 3; i++ {
+				from = from.Add(time.Second)
+				insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), from.Unix())
+			}
+
+			affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour)
+			So(err, ShouldBeNil)
+			So(affected, ShouldEqual, 3)
+		})
+
+		Convey("should delete tokens where token age is older than or equal 30 days", func() {
+			from := t.Add(-30 * 24 * time.Hour)
+			fromRotate := t.Add(-time.Second)
+
+			// 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), from.Unix(), fromRotate.Unix())
+			}
+
+			// insert three active tokens that should not be deleted
+			for i := 0; i < 3; i++ {
+				from = from.Add(time.Second)
+				insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), from.Unix(), fromRotate.Unix())
+			}
+
+			affected, err := ctx.tokenService.deleteExpiredTokens(7*24*time.Hour, 30*24*time.Hour)
+			So(err, ShouldBeNil)
+			So(affected, ShouldEqual, 3)
+		})
+	})
+}

+ 46 - 34
pkg/setting/setting.go

@@ -89,6 +89,8 @@ var (
 	EmailCodeValidMinutes            int
 	DataProxyWhiteList               map[string]bool
 	DisableBruteForceLoginProtection bool
+	CookieSecure                     bool
+	CookieSameSite                   http.SameSite
 
 	// Snapshots
 	ExternalSnapshotUrl   string
@@ -118,8 +120,10 @@ var (
 	ViewersCanEdit          bool
 
 	// Http auth
-	AdminUser     string
-	AdminPassword string
+	AdminUser            string
+	AdminPassword        string
+	LoginCookieName      string
+	LoginMaxLifetimeDays int
 
 	AnonymousEnabled bool
 	AnonymousOrgName string
@@ -215,7 +219,11 @@ type Cfg struct {
 	RendererLimit         int
 	RendererLimitAlerting int
 
+	// Security
 	DisableBruteForceLoginProtection bool
+	CookieSecure                     bool
+	CookieSameSite                   http.SameSite
+
 	TempDataLifetime                 time.Duration
 	MetricsEndpointEnabled           bool
 	MetricsEndpointBasicAuthUsername string
@@ -224,13 +232,11 @@ type Cfg struct {
 	DisableSanitizeHtml              bool
 	EnterpriseLicensePath            string
 
-	LoginCookieName                   string
-	LoginCookieMaxDays                int
-	LoginCookieRotation               int
-	LoginDeleteExpiredTokensAfterDays int
-	LoginCookieSameSite               http.SameSite
-
-	SecurityHTTPSCookies bool
+	// Auth
+	LoginCookieName              string
+	LoginMaxInactiveLifetimeDays int
+	LoginMaxLifetimeDays         int
+	TokenRotationIntervalMinutes int
 }
 
 type CommandLineArgs struct {
@@ -554,30 +560,6 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 		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)
-
-	samesiteString := login.Key("cookie_samesite").MustString("lax")
-	validSameSiteValues := map[string]http.SameSite{
-		"lax":    http.SameSiteLaxMode,
-		"strict": http.SameSiteStrictMode,
-		"none":   http.SameSiteDefaultMode,
-	}
-
-	if samesite, ok := validSameSiteValues[samesiteString]; ok {
-		cfg.LoginCookieSameSite = samesite
-	} else {
-		cfg.LoginCookieSameSite = http.SameSiteLaxMode
-	}
-
-	cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
-	if cfg.LoginCookieRotation < 2 {
-		cfg.LoginCookieRotation = 2
-	}
-
 	Env = iniFile.Section("").Key("app_mode").MustString("development")
 	InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
 	PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
@@ -621,9 +603,26 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	SecretKey = security.Key("secret_key").String()
 	DisableGravatar = security.Key("disable_gravatar").MustBool(true)
 	cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
-	cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
 	DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
 
+	CookieSecure = security.Key("cookie_secure").MustBool(false)
+	cfg.CookieSecure = CookieSecure
+
+	samesiteString := security.Key("cookie_samesite").MustString("lax")
+	validSameSiteValues := map[string]http.SameSite{
+		"lax":    http.SameSiteLaxMode,
+		"strict": http.SameSiteStrictMode,
+		"none":   http.SameSiteDefaultMode,
+	}
+
+	if samesite, ok := validSameSiteValues[samesiteString]; ok {
+		CookieSameSite = samesite
+		cfg.CookieSameSite = CookieSameSite
+	} else {
+		CookieSameSite = http.SameSiteLaxMode
+		cfg.CookieSameSite = CookieSameSite
+	}
+
 	// read snapshots settings
 	snapshots := iniFile.Section("snapshots")
 	ExternalSnapshotUrl = snapshots.Key("external_snapshot_url").String()
@@ -661,6 +660,19 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 
 	// auth
 	auth := iniFile.Section("auth")
+
+	LoginCookieName = auth.Key("login_cookie_name").MustString("grafana_session")
+	cfg.LoginCookieName = LoginCookieName
+	cfg.LoginMaxInactiveLifetimeDays = auth.Key("login_maximum_inactive_lifetime_days").MustInt(7)
+
+	LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30)
+	cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays
+
+	cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10)
+	if cfg.TokenRotationIntervalMinutes < 2 {
+		cfg.TokenRotationIntervalMinutes = 2
+	}
+
 	DisableLoginForm = auth.Key("disable_login_form").MustBool(false)
 	DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false)
 	OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false)

+ 2 - 1
public/app/core/app_events.ts

@@ -1,4 +1,5 @@
 import { Emitter } from './utils/emitter';
 
-const appEvents = new Emitter();
+export const appEvents = new Emitter();
+
 export default appEvents;

+ 42 - 0
public/app/core/components/AlertBox/AlertBox.tsx

@@ -0,0 +1,42 @@
+import React, { FunctionComponent } from 'react';
+import { AppNotificationSeverity } from 'app/types';
+
+interface Props {
+  title: string;
+  icon?: string;
+  text?: string;
+  severity: AppNotificationSeverity;
+  onClose?: () => void;
+}
+
+function getIconFromSeverity(severity: AppNotificationSeverity): string {
+  switch (severity) {
+    case AppNotificationSeverity.Error: {
+      return 'fa fa-exclamation-triangle';
+    }
+    case AppNotificationSeverity.Success: {
+      return 'fa fa-check';
+    }
+    default:
+      return null;
+  }
+}
+
+export const AlertBox: FunctionComponent<Props> = ({ title, icon, text, severity, onClose }) => {
+  return (
+    <div className={`alert alert-${severity}`}>
+      <div className="alert-icon">
+        <i className={icon || getIconFromSeverity(severity)} />
+      </div>
+      <div className="alert-body">
+        <div className="alert-title">{title}</div>
+        {text && <div className="alert-text">{text}</div>}
+      </div>
+      {onClose && (
+        <button type="button" className="alert-close" onClick={onClose}>
+          <i className="fa fa fa-remove" />
+        </button>
+      )}
+    </div>
+  );
+};

+ 8 - 12
public/app/core/components/AppNotifications/AppNotificationItem.tsx

@@ -1,5 +1,6 @@
 import React, { Component } from 'react';
 import { AppNotification } from 'app/types';
+import { AlertBox } from '../AlertBox/AlertBox';
 
 interface Props {
   appNotification: AppNotification;
@@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component<Props> {
     const { appNotification, onClearNotification } = this.props;
 
     return (
-      <div className={`alert-${appNotification.severity} alert`}>
-        <div className="alert-icon">
-          <i className={appNotification.icon} />
-        </div>
-        <div className="alert-body">
-          <div className="alert-title">{appNotification.title}</div>
-          <div className="alert-text">{appNotification.text}</div>
-        </div>
-        <button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
-          <i className="fa fa fa-remove" />
-        </button>
-      </div>
+      <AlertBox
+        severity={appNotification.severity}
+        title={appNotification.title}
+        text={appNotification.text}
+        icon={appNotification.icon}
+        onClose={() => onClearNotification(appNotification.id)}
+      />
     );
   }
 }

+ 0 - 7
public/app/core/components/Page/Page.tsx

@@ -17,13 +17,10 @@ interface Props {
 }
 
 class Page extends Component<Props> {
-  private bodyClass = 'is-react';
-  private body = document.body;
   static Header = PageHeader;
   static Contents = PageContents;
 
   componentDidMount() {
-    this.body.classList.add(this.bodyClass);
     this.updateTitle();
   }
 
@@ -33,10 +30,6 @@ class Page extends Component<Props> {
     }
   }
 
-  componentWillUnmount() {
-    this.body.classList.remove(this.bodyClass);
-  }
-
   updateTitle = () => {
     const title = this.getPageTitle;
     document.title = title ? title + ' - Grafana' : 'Grafana';

+ 0 - 40
public/app/core/components/gf_page.ts

@@ -1,40 +0,0 @@
-import coreModule from 'app/core/core_module';
-
-const template = `
-<div class="scroll-canvas">
-  <navbar model="model"></navbar>
-   <div class="page-container">
-		<div class="page-header">
-      <h1>
-         <i class="{{::model.node.icon}}" ng-if="::model.node.icon"></i>
-         <img ng-src="{{::model.node.img}}" ng-if="::model.node.img"></i>
-         {{::model.node.text}}
-       </h1>
-
-      <div class="page-header__actions" ng-transclude="header"></div>
-		</div>
-
-    <div class="page-body" ng-transclude="body">
-    </div>
-  </div>
-</div>
-`;
-
-export function gfPageDirective() {
-  return {
-    restrict: 'E',
-    template: template,
-    scope: {
-      model: '=',
-    },
-    transclude: {
-      header: '?gfPageHeader',
-      body: 'gfPageBody',
-    },
-    link: (scope, elem, attrs) => {
-      console.log(scope);
-    },
-  };
-}
-
-coreModule.directive('gfPage', gfPageDirective);

+ 0 - 43
public/app/core/components/scroll/page_scroll.ts

@@ -1,43 +0,0 @@
-import coreModule from 'app/core/core_module';
-import appEvents from 'app/core/app_events';
-
-export function pageScrollbar() {
-  return {
-    restrict: 'A',
-    link: (scope, elem, attrs) => {
-      let lastPos = 0;
-
-      appEvents.on(
-        'dash-scroll',
-        evt => {
-          if (evt.restore) {
-            elem[0].scrollTop = lastPos;
-            return;
-          }
-
-          lastPos = elem[0].scrollTop;
-
-          if (evt.animate) {
-            elem.animate({ scrollTop: evt.pos }, 500);
-          } else {
-            elem[0].scrollTop = evt.pos;
-          }
-        },
-        scope
-      );
-
-      scope.$on('$routeChangeSuccess', () => {
-        lastPos = 0;
-        elem[0].scrollTop = 0;
-        // Focus page to enable scrolling by keyboard
-        elem[0].focus({ preventScroll: true });
-      });
-
-      elem[0].tabIndex = -1;
-      // Focus page to enable scrolling by keyboard
-      elem[0].focus({ preventScroll: true });
-    },
-  };
-}
-
-coreModule.directive('pageScrollbar', pageScrollbar);

+ 1 - 1
public/app/core/config.ts

@@ -68,5 +68,5 @@ const bootData = (window as any).grafanaBootData || {
 const options = bootData.settings;
 options.bootData = bootData;
 
-const config = new Settings(options);
+export const config = new Settings(options);
 export default config;

+ 9 - 6
public/app/core/copy/appNotification.ts

@@ -1,4 +1,5 @@
 import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
+import { getMessageFromError } from 'app/core/utils/errors';
 
 const defaultSuccessNotification: AppNotification = {
   title: '',
@@ -31,12 +32,14 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti
   id: Date.now(),
 });
 
-export const createErrorNotification = (title: string, text?: string): AppNotification => ({
-  ...defaultErrorNotification,
-  title: title,
-  text: text,
-  id: Date.now(),
-});
+export const createErrorNotification = (title: string, text?: any): AppNotification => {
+  return {
+    ...defaultErrorNotification,
+    title: title,
+    text: getMessageFromError(text),
+    id: Date.now(),
+  };
+};
 
 export const createWarningNotification = (title: string, text?: string): AppNotification => ({
   ...defaultWarningNotification,

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

@@ -43,8 +43,6 @@ import { helpModal } from './components/help/help';
 import { JsonExplorer } from './components/json_explorer/json_explorer';
 import { NavModelSrv, NavModel } from './nav_model_srv';
 import { geminiScrollbar } from './components/scroll/scroll';
-import { pageScrollbar } from './components/scroll/page_scroll';
-import { gfPageDirective } from './components/gf_page';
 import { orgSwitcher } from './components/org_switcher';
 import { profiler } from './profiler';
 import { registerAngularDirectives } from './angular_wrappers';
@@ -79,8 +77,6 @@ export {
   NavModelSrv,
   NavModel,
   geminiScrollbar,
-  pageScrollbar,
-  gfPageDirective,
   orgSwitcher,
   manageDashboardsDirective,
   TimeSeries,

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

@@ -8,12 +8,13 @@ export const initialState: LocationState = {
   path: '',
   query: {},
   routeParams: {},
+  replace: false,
 };
 
 export const locationReducer = (state = initialState, action: Action): LocationState => {
   switch (action.type) {
     case CoreActionTypes.UpdateLocation: {
-      const { path, routeParams } = action.payload;
+      const { path, routeParams, replace } = action.payload;
       let query = action.payload.query || state.query;
 
       if (action.payload.partial) {
@@ -26,6 +27,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
         path: path || state.path,
         query: { ...query },
         routeParams: routeParams || state.routeParams,
+        replace: replace === true,
       };
     }
   }

+ 15 - 0
public/app/core/redux/actionCreatorFactory.ts

@@ -53,5 +53,20 @@ export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCrea
   return { create };
 };
 
+export interface NoPayloadActionCreatorMock extends NoPayloadActionCreator {
+  calls: number;
+}
+
+export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): NoPayloadActionCreatorMock => {
+  const mock: NoPayloadActionCreatorMock = Object.assign(
+    (): ActionOf<undefined> => {
+      mock.calls++;
+      return { type: creator.type, payload: undefined };
+    },
+    { type: creator.type, calls: 0 }
+  );
+  return mock;
+};
+
 // Should only be used by tests
 export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);

+ 2 - 4
public/app/core/redux/index.ts

@@ -1,4 +1,2 @@
-import { actionCreatorFactory } from './actionCreatorFactory';
-import { reducerFactory } from './reducerFactory';
-
-export { actionCreatorFactory, reducerFactory };
+export * from './actionCreatorFactory';
+export * from './reducerFactory';

+ 14 - 0
public/app/core/services/__mocks__/backend_srv.ts

@@ -0,0 +1,14 @@
+
+const backendSrv = {
+  get: jest.fn(),
+  getDashboard: jest.fn(),
+  getDashboardByUid: jest.fn(),
+  getFolderByUid: jest.fn(),
+  post: jest.fn(),
+};
+
+export function getBackendSrv() {
+  return backendSrv;
+}
+
+

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

@@ -46,6 +46,10 @@ export class BridgeSrv {
       if (angularUrl !== url) {
         this.$timeout(() => {
           this.$location.url(url);
+          // some state changes should not trigger new browser history
+          if (state.location.replace) {
+            this.$location.replace();
+          }
         });
         console.log('store updating angular $location.url', url);
       }

+ 4 - 6
public/app/core/services/keybindingSrv.ts

@@ -104,7 +104,7 @@ export class KeybindingSrv {
     }
 
     if (search.fullscreen) {
-      this.$rootScope.appEvent('panel-change-view', { fullscreen: false, edit: false });
+      appEvents.emit('panel-change-view', { fullscreen: false, edit: false });
       return;
     }
 
@@ -174,7 +174,7 @@ export class KeybindingSrv {
     // edit panel
     this.bind('e', () => {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
-        this.$rootScope.appEvent('panel-change-view', {
+        appEvents.emit('panel-change-view', {
           fullscreen: true,
           edit: true,
           panelId: dashboard.meta.focusPanelId,
@@ -186,7 +186,7 @@ export class KeybindingSrv {
     // view panel
     this.bind('v', () => {
       if (dashboard.meta.focusPanelId) {
-        this.$rootScope.appEvent('panel-change-view', {
+        appEvents.emit('panel-change-view', {
           fullscreen: true,
           edit: null,
           panelId: dashboard.meta.focusPanelId,
@@ -212,9 +212,7 @@ export class KeybindingSrv {
     // delete panel
     this.bind('p r', () => {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
-        this.$rootScope.appEvent('panel-remove', {
-          panelId: dashboard.meta.focusPanelId,
-        });
+        appEvents.emit('remove-panel', dashboard.meta.focusPanelId);
         dashboard.meta.focusPanelId = 0;
       }
     });

+ 17 - 0
public/app/core/utils/errors.ts

@@ -0,0 +1,17 @@
+import _ from 'lodash';
+
+export function getMessageFromError(err: any): string | null {
+  if (err && !_.isString(err)) {
+    if (err.message) {
+      return err.message;
+    } else if (err.data && err.data.message) {
+      return err.data.message;
+    } else if (err.statusText) {
+      return err.statusText;
+    } else {
+      return JSON.stringify(err);
+    }
+  }
+
+  return null;
+}

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

@@ -1,6 +1,6 @@
 import config from 'app/core/config';
 
-export const stripBaseFromUrl = url => {
+export const stripBaseFromUrl = (url: string): string => {
   const appSubUrl = config.appSubUrl;
   const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
   const urlWithoutBase =

+ 14 - 1
public/app/core/utils/version.ts

@@ -20,12 +20,25 @@ export class SemVersion {
 
   isGtOrEq(version: string): boolean {
     const compared = new SemVersion(version);
-    return !(this.major < compared.major || this.minor < compared.minor || this.patch < compared.patch);
+
+    for (let i = 0; i < this.comparable.length; ++i) {
+      if (this.comparable[i] > compared.comparable[i]) {
+        return true;
+      }
+      if (this.comparable[i] < compared.comparable[i]) {
+        return false;
+      }
+    }
+    return true;
   }
 
   isValid(): boolean {
     return _.isNumber(this.major);
   }
+
+  get comparable() {
+    return [this.major, this.minor, this.patch];
+  }
 }
 
 export function isVersionGtOrEq(a: string, b: string): boolean {

+ 6 - 1
public/app/features/annotations/editor_ctrl.ts

@@ -2,6 +2,7 @@ import angular from 'angular';
 import _ from 'lodash';
 import $ from 'jquery';
 import coreModule from 'app/core/core_module';
+import { DashboardModel } from 'app/features/dashboard/state';
 
 export class AnnotationsEditorCtrl {
   mode: any;
@@ -10,6 +11,7 @@ export class AnnotationsEditorCtrl {
   currentAnnotation: any;
   currentDatasource: any;
   currentIsNew: any;
+  dashboard: DashboardModel;
 
   annotationDefaults: any = {
     name: '',
@@ -26,9 +28,10 @@ export class AnnotationsEditorCtrl {
   constructor($scope, private datasourceSrv) {
     $scope.ctrl = this;
 
+    this.dashboard = $scope.dashboard;
     this.mode = 'list';
     this.datasources = datasourceSrv.getAnnotationSources();
-    this.annotations = $scope.dashboard.annotations.list;
+    this.annotations = this.dashboard.annotations.list;
     this.reset();
 
     this.onColorChange = this.onColorChange.bind(this);
@@ -78,11 +81,13 @@ export class AnnotationsEditorCtrl {
     this.annotations.push(this.currentAnnotation);
     this.reset();
     this.mode = 'list';
+    this.dashboard.updateSubmenuVisibility();
   }
 
   removeAnnotation(annotation) {
     const index = _.indexOf(this.annotations, annotation);
     this.annotations.splice(index, 1);
+    this.dashboard.updateSubmenuVisibility();
   }
 
   onColorChange(newColor) {

+ 4 - 2
public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts

@@ -1,10 +1,12 @@
 import _ from 'lodash';
 import angular from 'angular';
 import coreModule from 'app/core/core_module';
+import { DashboardModel } from 'app/features/dashboard/state';
 
 export class AdHocFiltersCtrl {
   segments: any;
   variable: any;
+  dashboard: DashboardModel;
   removeTagFilterSegment: any;
 
   /** @ngInject */
@@ -14,14 +16,13 @@ export class AdHocFiltersCtrl {
     private $q,
     private variableSrv,
     $scope,
-    private $rootScope
   ) {
     this.removeTagFilterSegment = uiSegmentSrv.newSegment({
       fake: true,
       value: '-- remove filter --',
     });
     this.buildSegmentModel();
-    this.$rootScope.onAppEvent('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
+    this.dashboard.events.on('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
   }
 
   buildSegmentModel() {
@@ -171,6 +172,7 @@ export function adHocFiltersComponent() {
     controllerAs: 'ctrl',
     scope: {
       variable: '=',
+      dashboard: '=',
     },
   };
 }

+ 3 - 1
public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts

@@ -1,5 +1,6 @@
 import angular from 'angular';
 import _ from 'lodash';
+import { DashboardModel } from 'app/features/dashboard/state';
 
 export let iconMap = {
   'external link': 'fa-external-link',
@@ -12,7 +13,7 @@ export let iconMap = {
 };
 
 export class DashLinksEditorCtrl {
-  dashboard: any;
+  dashboard: DashboardModel;
   iconMap: any;
   mode: any;
   link: any;
@@ -40,6 +41,7 @@ export class DashLinksEditorCtrl {
   addLink() {
     this.dashboard.links.push(this.link);
     this.mode = 'list';
+    this.dashboard.updateSubmenuVisibility();
   }
 
   editLink(link) {

+ 253 - 0
public/app/features/dashboard/components/DashNav/DashNav.tsx

@@ -0,0 +1,253 @@
+// Libaries
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+
+// Utils & Services
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import { appEvents } from 'app/core/app_events';
+import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
+
+// Components
+import { DashNavButton } from './DashNavButton';
+
+// State
+import { updateLocation } from 'app/core/actions';
+
+// Types
+import { DashboardModel } from '../../state/DashboardModel';
+
+export interface Props {
+  dashboard: DashboardModel;
+  editview: string;
+  isEditing: boolean;
+  isFullscreen: boolean;
+  $injector: any;
+  updateLocation: typeof updateLocation;
+  onAddPanel: () => void;
+}
+
+export class DashNav extends PureComponent<Props> {
+  timePickerEl: HTMLElement;
+  timepickerCmp: AngularComponent;
+  playlistSrv: PlaylistSrv;
+
+  constructor(props: Props) {
+    super(props);
+
+    this.playlistSrv = this.props.$injector.get('playlistSrv');
+  }
+
+  componentDidMount() {
+    const loader = getAngularLoader();
+
+    const template =
+      '<gf-time-picker class="gf-timepicker-nav" dashboard="dashboard" ng-if="!dashboard.timepicker.hidden" />';
+    const scopeProps = { dashboard: this.props.dashboard };
+
+    this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template);
+  }
+
+  componentWillUnmount() {
+    if (this.timepickerCmp) {
+      this.timepickerCmp.destroy();
+    }
+  }
+
+  onOpenSearch = () => {
+    appEvents.emit('show-dash-search');
+  };
+
+  onClose = () => {
+    if (this.props.editview) {
+      this.props.updateLocation({
+        query: { editview: null },
+        partial: true,
+      });
+    } else {
+      this.props.updateLocation({
+        query: { panelId: null, edit: null, fullscreen: null },
+        partial: true,
+      });
+    }
+  };
+
+  onToggleTVMode = () => {
+    appEvents.emit('toggle-kiosk-mode');
+  };
+
+  onSave = () => {
+    const { $injector } = this.props;
+    const dashboardSrv = $injector.get('dashboardSrv');
+    dashboardSrv.saveDashboard();
+  };
+
+  onOpenSettings = () => {
+    this.props.updateLocation({
+      query: { editview: 'settings' },
+      partial: true,
+    });
+  };
+
+  onStarDashboard = () => {
+    const { dashboard, $injector } = this.props;
+    const dashboardSrv = $injector.get('dashboardSrv');
+
+    dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => {
+      dashboard.meta.isStarred = newState;
+      this.forceUpdate();
+    });
+  };
+
+  onPlaylistPrev = () => {
+    this.playlistSrv.prev();
+  };
+
+  onPlaylistNext = () => {
+    this.playlistSrv.next();
+  };
+
+  onPlaylistStop = () => {
+    this.playlistSrv.stop();
+    this.forceUpdate();
+  };
+
+  onOpenShare = () => {
+    const $rootScope = this.props.$injector.get('$rootScope');
+    const modalScope = $rootScope.$new();
+    modalScope.tabIndex = 0;
+    modalScope.dashboard = this.props.dashboard;
+
+    appEvents.emit('show-modal', {
+      src: 'public/app/features/dashboard/components/ShareModal/template.html',
+      scope: modalScope,
+    });
+  };
+
+  render() {
+    const { dashboard, isFullscreen, editview, onAddPanel } = this.props;
+    const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta;
+    const { snapshot } = dashboard;
+
+    const haveFolder = dashboard.meta.folderId > 0;
+    const snapshotUrl = snapshot && snapshot.originalUrl;
+
+    return (
+      <div className="navbar">
+        <div>
+          <a className="navbar-page-btn" onClick={this.onOpenSearch}>
+            <i className="gicon gicon-dashboard" />
+            {haveFolder && <span className="navbar-page-btn--folder">{folderTitle} / </span>}
+            {dashboard.title}
+            <i className="fa fa-caret-down" />
+          </a>
+        </div>
+
+        <div className="navbar__spacer" />
+
+        {this.playlistSrv.isPlaying && (
+          <div className="navbar-buttons navbar-buttons--playlist">
+            <DashNavButton
+              tooltip="Go to previous dashboard"
+              classSuffix="tight"
+              icon="fa fa-step-backward"
+              onClick={this.onPlaylistPrev}
+            />
+            <DashNavButton
+              tooltip="Stop playlist"
+              classSuffix="tight"
+              icon="fa fa-stop"
+              onClick={this.onPlaylistStop}
+            />
+            <DashNavButton
+              tooltip="Go to next dashboard"
+              classSuffix="tight"
+              icon="fa fa-forward"
+              onClick={this.onPlaylistNext}
+            />
+          </div>
+        )}
+
+        <div className="navbar-buttons navbar-buttons--actions">
+          {canSave && (
+            <DashNavButton
+              tooltip="Add panel"
+              classSuffix="add-panel"
+              icon="gicon gicon-add-panel"
+              onClick={onAddPanel}
+            />
+          )}
+
+          {canStar && (
+            <DashNavButton
+              tooltip="Mark as favorite"
+              classSuffix="star"
+              icon={`${isStarred ? 'fa fa-star' : 'fa fa-star-o'}`}
+              onClick={this.onStarDashboard}
+            />
+          )}
+
+          {canShare && (
+            <DashNavButton
+              tooltip="Share dashboard"
+              classSuffix="share"
+              icon="fa fa-share-square-o"
+              onClick={this.onOpenShare}
+            />
+          )}
+
+          {canSave && (
+            <DashNavButton tooltip="Save dashboard" classSuffix="save" icon="fa fa-save" onClick={this.onSave} />
+          )}
+
+          {snapshotUrl && (
+            <DashNavButton
+              tooltip="Open original dashboard"
+              classSuffix="snapshot-origin"
+              icon="fa fa-link"
+              href={snapshotUrl}
+            />
+          )}
+
+          {showSettings && (
+            <DashNavButton
+              tooltip="Dashboard settings"
+              classSuffix="settings"
+              icon="fa fa-cog"
+              onClick={this.onOpenSettings}
+            />
+          )}
+        </div>
+
+        <div className="navbar-buttons navbar-buttons--tv">
+          <DashNavButton
+            tooltip="Cycke view mode"
+            classSuffix="tv"
+            icon="fa fa-desktop"
+            onClick={this.onToggleTVMode}
+          />
+        </div>
+
+        <div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
+
+        {(isFullscreen || editview) && (
+          <div className="navbar-buttons navbar-buttons--close">
+            <DashNavButton
+              tooltip="Back to dashboard"
+              classSuffix="primary"
+              icon="fa fa-reply"
+              onClick={this.onClose}
+            />
+          </div>
+        )}
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = () => ({});
+
+const mapDispatchToProps = {
+  updateLocation,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashNav);

+ 33 - 0
public/app/features/dashboard/components/DashNav/DashNavButton.tsx

@@ -0,0 +1,33 @@
+// Libraries
+import React, { FunctionComponent } from 'react';
+
+// Components
+import { Tooltip } from '@grafana/ui';
+
+interface Props {
+  icon: string;
+  tooltip: string;
+  classSuffix: string;
+  onClick?: () => void;
+  href?: string;
+}
+
+export const DashNavButton: FunctionComponent<Props> = ({ icon, tooltip, classSuffix, onClick, href }) => {
+  if (onClick) {
+    return (
+      <Tooltip content={tooltip}>
+        <button className={`btn navbar-button navbar-button--${classSuffix}`} onClick={onClick}>
+          <i className={icon} />
+        </button>
+      </Tooltip>
+    );
+  }
+
+  return (
+    <Tooltip content={tooltip}>
+      <a className={`btn navbar-button navbar-button--${classSuffix}`} href={href}>
+        <i className={icon} />
+      </a>
+    </Tooltip>
+  );
+};

+ 0 - 119
public/app/features/dashboard/components/DashNav/DashNavCtrl.ts

@@ -1,119 +0,0 @@
-import moment from 'moment';
-import angular from 'angular';
-import { appEvents, NavModel } from 'app/core/core';
-import { DashboardModel } from '../../state/DashboardModel';
-
-export class DashNavCtrl {
-  dashboard: DashboardModel;
-  navModel: NavModel;
-  titleTooltip: string;
-
-  /** @ngInject */
-  constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) {
-    appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope);
-
-    if (this.dashboard.meta.isSnapshot) {
-      const meta = this.dashboard.meta;
-      this.titleTooltip = 'Created: &nbsp;' + moment(meta.created).calendar();
-      if (meta.expires) {
-        this.titleTooltip += '<br>Expires: &nbsp;' + moment(meta.expires).fromNow() + '<br>';
-      }
-    }
-  }
-
-  toggleSettings() {
-    const search = this.$location.search();
-    if (search.editview) {
-      delete search.editview;
-    } else {
-      search.editview = 'settings';
-    }
-    this.$location.search(search);
-  }
-
-  toggleViewMode() {
-    appEvents.emit('toggle-kiosk-mode');
-  }
-
-  close() {
-    const search = this.$location.search();
-    if (search.editview) {
-      delete search.editview;
-    } else if (search.fullscreen) {
-      delete search.fullscreen;
-      delete search.edit;
-      delete search.tab;
-      delete search.panelId;
-    }
-    this.$location.search(search);
-  }
-
-  starDashboard() {
-    this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => {
-      this.dashboard.meta.isStarred = newState;
-    });
-  }
-
-  shareDashboard(tabIndex) {
-    const modalScope = this.$scope.$new();
-    modalScope.tabIndex = tabIndex;
-    modalScope.dashboard = this.dashboard;
-
-    appEvents.emit('show-modal', {
-      src: 'public/app/features/dashboard/components/ShareModal/template.html',
-      scope: modalScope,
-    });
-  }
-
-  hideTooltip(evt) {
-    angular.element(evt.currentTarget).tooltip('hide');
-  }
-
-  saveDashboard() {
-    return this.dashboardSrv.saveDashboard();
-  }
-
-  showSearch() {
-    if (this.dashboard.meta.fullscreen) {
-      this.close();
-      return;
-    }
-
-    appEvents.emit('show-dash-search');
-  }
-
-  addPanel() {
-    appEvents.emit('dash-scroll', { animate: true, evt: 0 });
-
-    if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') {
-      return; // Return if the "Add panel" exists already
-    }
-
-    this.dashboard.addPanel({
-      type: 'add-panel',
-      gridPos: { x: 0, y: 0, w: 12, h: 8 },
-      title: 'Panel Title',
-    });
-  }
-
-  navItemClicked(navItem, evt) {
-    if (navItem.clickHandler) {
-      navItem.clickHandler();
-      evt.preventDefault();
-    }
-  }
-}
-
-export function dashNavDirective() {
-  return {
-    restrict: 'E',
-    templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
-    controller: DashNavCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    transclude: true,
-    scope: { dashboard: '=' },
-  };
-}
-
-angular.module('grafana.directives').directive('dashnav', dashNavDirective);

+ 2 - 1
public/app/features/dashboard/components/DashNav/index.ts

@@ -1 +1,2 @@
-export { DashNavCtrl } from './DashNavCtrl';
+import DashNav from './DashNav';
+export { DashNav };

+ 0 - 61
public/app/features/dashboard/components/DashNav/template.html

@@ -1,61 +0,0 @@
-<div class="navbar">
-
-	<div>
-		<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
-			<i class="gicon gicon-dashboard"></i>
-			<span ng-if="ctrl.dashboard.meta.folderId > 0" class="navbar-page-btn--folder">{{ctrl.dashboard.meta.folderTitle}} / </span>{{ctrl.dashboard.title}}
-			<i class="fa fa-caret-down"></i>
-		</a>
-	</div>
-
-	<div class="navbar__spacer"></div>
-
-	<div class="navbar-buttons navbar-buttons--playlist" ng-if="ctrl.playlistSrv.isPlaying">
-		<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.prev()"><i class="fa fa-step-backward"></i></a>
-		<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.stop()"><i class="fa fa-stop"></i></a>
-		<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.next()"><i class="fa fa-step-forward"></i></a>
-	</div>
-
-	<div class="navbar-buttons navbar-buttons--actions">
-		<button class="btn navbar-button navbar-button--add-panel" ng-show="::ctrl.dashboard.meta.canSave" bs-tooltip="'Add panel'" data-placement="bottom" ng-click="ctrl.addPanel()">
-			<i class="gicon gicon-add-panel"></i>
-		</button>
-
-		<button class="btn navbar-button navbar-button--star" ng-show="::ctrl.dashboard.meta.canStar" ng-click="ctrl.starDashboard()" bs-tooltip="'Mark as favorite'" data-placement="bottom">
-			<i class="fa" ng-class="{'fa-star-o': !ctrl.dashboard.meta.isStarred, 'fa-star': ctrl.dashboard.meta.isStarred}"></i>
-		</button>
-
-    <button class="btn navbar-button navbar-button--share" ng-show="::ctrl.dashboard.meta.canShare" ng-click="ctrl.shareDashboard(0)" bs-tooltip="'Share dashboard'" data-placement="bottom">
-			<i class="fa fa-share-square-o"></i></a>
-		</button>
-
-    <button class="btn navbar-button navbar-button--save" ng-show="ctrl.dashboard.meta.canSave" ng-click="ctrl.saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom">
-			<i class="fa fa-save"></i>
-		</button>
-
-		<a class="btn navbar-button navbar-button--snapshot-origin" ng-if="::ctrl.dashboard.snapshot.originalUrl" href="{{ctrl.dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom">
-			<i class="fa fa-link"></i>
-		</a>
-
-		<button class="btn navbar-button navbar-button--settings" ng-click="ctrl.toggleSettings()" bs-tooltip="'Dashboard Settings'" data-placement="bottom" ng-show="ctrl.dashboard.meta.showSettings">
-			<i class="fa fa-cog"></i>
-		</button>
-	</div>
-
-	<div class="navbar-buttons navbar-buttons--tv">
-    <button class="btn navbar-button navbar-button--tv" ng-click="ctrl.toggleViewMode()" bs-tooltip="'Cycle view mode'" data-placement="bottom">
-      <i class="fa fa-desktop"></i>
-    </button>
-  </div>
-
-	<gf-time-picker class="gf-timepicker-nav" dashboard="ctrl.dashboard" ng-if="!ctrl.dashboard.timepicker.hidden"></gf-time-picker>
-
-	<div class="navbar-buttons navbar-buttons--close">
-		<button class="btn navbar-button navbar-button--primary" ng-click="ctrl.close()" bs-tooltip="'Back to dashboard'" data-placement="bottom">
-			<i class="fa fa-reply"></i>
-		</button>
-	</div>
-
-</div>
-
-<dashboard-search></dashboard-search>

+ 1 - 0
public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx

@@ -9,6 +9,7 @@ describe('DashboardRow', () => {
   beforeEach(() => {
     dashboardMock = {
       toggleRow: jest.fn(),
+      on: jest.fn(),
       meta: {
         canEdit: true,
       },

+ 2 - 2
public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx

@@ -18,11 +18,11 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
       collapsed: this.props.panel.collapsed,
     };
 
-    appEvents.on('template-variable-value-updated', this.onVariableUpdated);
+    this.props.dashboard.on('template-variable-value-updated', this.onVariableUpdated);
   }
 
   componentWillUnmount() {
-    appEvents.off('template-variable-value-updated', this.onVariableUpdated);
+    this.props.dashboard.off('template-variable-value-updated', this.onVariableUpdated);
   }
 
   onVariableUpdated = () => {

+ 36 - 0
public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx

@@ -0,0 +1,36 @@
+// Libaries
+import React, { PureComponent } from 'react';
+
+// Utils & Services
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+
+// Types
+import { DashboardModel } from '../../state/DashboardModel';
+
+export interface Props {
+  dashboard: DashboardModel | null;
+}
+
+export class DashboardSettings extends PureComponent<Props> {
+  element: HTMLElement;
+  angularCmp: AngularComponent;
+
+  componentDidMount() {
+    const loader = getAngularLoader();
+
+    const template = '<dashboard-settings dashboard="dashboard" class="dashboard-settings" />';
+    const scopeProps = { dashboard: this.props.dashboard };
+
+    this.angularCmp = loader.load(this.element, scopeProps, template);
+  }
+
+  componentWillUnmount() {
+    if (this.angularCmp) {
+      this.angularCmp.destroy();
+    }
+  }
+
+  render() {
+    return <div className="panel-height-helper" ref={element => this.element = element} />;
+  }
+}

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

@@ -1 +1,2 @@
 export { SettingsCtrl } from './SettingsCtrl';
+export { DashboardSettings } from './DashboardSettings';

+ 36 - 0
public/app/features/dashboard/components/SubMenu/SubMenu.tsx

@@ -0,0 +1,36 @@
+// Libaries
+import React, { PureComponent } from 'react';
+
+// Utils & Services
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+
+// Types
+import { DashboardModel } from '../../state/DashboardModel';
+
+export interface Props {
+  dashboard: DashboardModel | null;
+}
+
+export class SubMenu extends PureComponent<Props> {
+  element: HTMLElement;
+  angularCmp: AngularComponent;
+
+  componentDidMount() {
+    const loader = getAngularLoader();
+
+    const template = '<dashboard-submenu dashboard="dashboard" />';
+    const scopeProps = { dashboard: this.props.dashboard };
+
+    this.angularCmp = loader.load(this.element, scopeProps, template);
+  }
+
+  componentWillUnmount() {
+    if (this.angularCmp) {
+      this.angularCmp.destroy();
+    }
+  }
+
+  render() {
+    return <div ref={element => this.element = element} />;
+  }
+}

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

@@ -1 +1,2 @@
 export { SubMenuCtrl } from './SubMenuCtrl';
+export { SubMenu } from './SubMenu';

+ 1 - 1
public/app/features/dashboard/components/SubMenu/template.html

@@ -7,7 +7,7 @@
       <value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
       <input type="text" ng-if="variable.type === 'textbox'" ng-model="variable.query" class="gf-form-input width-12"  ng-blur="variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ng-keydown="$event.keyCode === 13 && variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ></input>
     </div>
-    <ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable"></ad-hoc-filters>
+    <ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable" dashboard="ctrl.dashboard"></ad-hoc-filters>
   </div>
 
   <div ng-if="ctrl.dashboard.annotations.list.length > 0">

+ 0 - 156
public/app/features/dashboard/containers/DashboardCtrl.ts

@@ -1,156 +0,0 @@
-// Utils
-import config from 'app/core/config';
-import appEvents from 'app/core/app_events';
-import coreModule from 'app/core/core_module';
-import { removePanel } from 'app/features/dashboard/utils/panel';
-
-// Services
-import { AnnotationsSrv } from '../../annotations/annotations_srv';
-
-// Types
-import { DashboardModel } from '../state/DashboardModel';
-
-export class DashboardCtrl {
-  dashboard: DashboardModel;
-  dashboardViewState: any;
-  loadedFallbackDashboard: boolean;
-  editTab: number;
-
-  /** @ngInject */
-  constructor(
-    private $scope,
-    private keybindingSrv,
-    private timeSrv,
-    private variableSrv,
-    private dashboardSrv,
-    private unsavedChangesSrv,
-    private dashboardViewStateSrv,
-    private annotationsSrv: AnnotationsSrv,
-    public playlistSrv
-  ) {
-    // temp hack due to way dashboards are loaded
-    // can't use controllerAs on route yet
-    $scope.ctrl = this;
-
-    // TODO: break out settings view to separate view & controller
-    this.editTab = 0;
-
-    // funcs called from React component bindings and needs this binding
-    this.getPanelContainer = this.getPanelContainer.bind(this);
-  }
-
-  setupDashboard(data) {
-    try {
-      this.setupDashboardInternal(data);
-    } catch (err) {
-      this.onInitFailed(err, 'Dashboard init failed', true);
-    }
-  }
-
-  setupDashboardInternal(data) {
-    const dashboard = this.dashboardSrv.create(data.dashboard, data.meta);
-    this.dashboardSrv.setCurrent(dashboard);
-
-    // init services
-    this.timeSrv.init(dashboard);
-    this.annotationsSrv.init(dashboard);
-
-    // template values service needs to initialize completely before
-    // the rest of the dashboard can load
-    this.variableSrv
-      .init(dashboard)
-      // template values failes are non fatal
-      .catch(this.onInitFailed.bind(this, 'Templating init failed', false))
-      // continue
-      .finally(() => {
-        this.dashboard = dashboard;
-        this.dashboard.processRepeats();
-        this.dashboard.updateSubmenuVisibility();
-        this.dashboard.autoFitPanels(window.innerHeight);
-
-        this.unsavedChangesSrv.init(dashboard, this.$scope);
-
-        // TODO refactor ViewStateSrv
-        this.$scope.dashboard = dashboard;
-        this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope);
-
-        this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
-        this.setWindowTitleAndTheme();
-
-        appEvents.emit('dashboard-initialized', dashboard);
-      })
-      .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
-  }
-
-  onInitFailed(msg, fatal, err) {
-    console.log(msg, err);
-
-    if (err.data && err.data.message) {
-      err.message = err.data.message;
-    } else if (!err.message) {
-      err = { message: err.toString() };
-    }
-
-    this.$scope.appEvent('alert-error', [msg, err.message]);
-
-    // protect against  recursive fallbacks
-    if (fatal && !this.loadedFallbackDashboard) {
-      this.loadedFallbackDashboard = true;
-      this.setupDashboard({ dashboard: { title: 'Dashboard Init failed' } });
-    }
-  }
-
-  templateVariableUpdated() {
-    this.dashboard.processRepeats();
-  }
-
-  setWindowTitleAndTheme() {
-    window.document.title = config.windowTitlePrefix + this.dashboard.title;
-  }
-
-  showJsonEditor(evt, options) {
-    const model = {
-      object: options.object,
-      updateHandler: options.updateHandler,
-    };
-
-    this.$scope.appEvent('show-dash-editor', {
-      src: 'public/app/partials/edit_json.html',
-      model: model,
-    });
-  }
-
-  getDashboard() {
-    return this.dashboard;
-  }
-
-  getPanelContainer() {
-    return this;
-  }
-
-  onRemovingPanel(evt, options) {
-    options = options || {};
-    if (!options.panelId) {
-      return;
-    }
-
-    const panelInfo = this.dashboard.getPanelInfoById(options.panelId);
-    removePanel(this.dashboard, panelInfo.panel, true);
-  }
-
-  onDestroy() {
-    if (this.dashboard) {
-      this.dashboard.destroy();
-    }
-  }
-
-  init(dashboard) {
-    this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
-    this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
-    this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
-    this.$scope.$on('$destroy', this.onDestroy.bind(this));
-    this.setupDashboard(dashboard);
-  }
-}
-
-coreModule.controller('DashboardCtrl', DashboardCtrl);

+ 251 - 0
public/app/features/dashboard/containers/DashboardPage.test.tsx

@@ -0,0 +1,251 @@
+import React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import { DashboardPage, Props, State } from './DashboardPage';
+import { DashboardModel } from '../state';
+import { cleanUpDashboard } from '../state/actions';
+import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock  } from 'app/core/redux';
+import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
+
+jest.mock('sass/_variables.scss', () => ({
+  panelhorizontalpadding: 10,
+  panelVerticalPadding: 10,
+}));
+
+jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
+
+interface ScenarioContext {
+  cleanUpDashboardMock: NoPayloadActionCreatorMock;
+  dashboard?: DashboardModel;
+  setDashboardProp: (overrides?: any, metaOverrides?: any) => void;
+  wrapper?: ShallowWrapper<Props, State, DashboardPage>;
+  mount: (propOverrides?: Partial<Props>) => void;
+  setup?: (fn: () => void) => void;
+}
+
+function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel {
+  const data = Object.assign({
+    title: 'My dashboard',
+    panels: [
+      {
+        id: 1,
+        type: 'graph',
+        title: 'My graph',
+        gridPos: { x: 0, y: 0, w: 1, h: 1 },
+      },
+    ],
+  }, overrides);
+
+  const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides);
+  return new DashboardModel(data, meta);
+}
+
+function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) => void) {
+  describe(description, () => {
+    let setupFn: () => void;
+
+    const ctx: ScenarioContext = {
+      cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard),
+      setup: fn => {
+        setupFn = fn;
+      },
+      setDashboardProp: (overrides?: any, metaOverrides?: any) => {
+        ctx.dashboard = getTestDashboard(overrides, metaOverrides);
+        ctx.wrapper.setProps({ dashboard: ctx.dashboard });
+      },
+      mount: (propOverrides?: Partial<Props>) => {
+        const props: Props = {
+          urlSlug: 'my-dash',
+          $scope: {},
+          urlUid: '11',
+          $injector: {},
+          routeInfo: DashboardRouteInfo.Normal,
+          urlEdit: false,
+          urlFullscreen: false,
+          initPhase: DashboardInitPhase.NotStarted,
+          isInitSlow: false,
+          initDashboard: jest.fn(),
+          updateLocation: jest.fn(),
+          notifyApp: jest.fn(),
+          cleanUpDashboard: ctx.cleanUpDashboardMock,
+          dashboard: null,
+        };
+
+        Object.assign(props, propOverrides);
+
+        ctx.dashboard = props.dashboard;
+        ctx.wrapper = shallow(<DashboardPage {...props} />);
+      }
+    };
+
+    beforeEach(() => {
+      setupFn();
+    });
+
+    scenarioFn(ctx);
+  });
+}
+
+describe('DashboardPage', () => {
+
+  dashboardPageScenario("Given initial state", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+    });
+
+    it('Should render nothing', () => {
+      expect(ctx.wrapper).toMatchSnapshot();
+    });
+  });
+
+  dashboardPageScenario("Dashboard is fetching slowly", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.wrapper.setProps({
+        isInitSlow: true,
+        initPhase: DashboardInitPhase.Fetching,
+      });
+    });
+
+    it('Should render slow init state', () => {
+      expect(ctx.wrapper).toMatchSnapshot();
+    });
+  });
+
+  dashboardPageScenario("Dashboard init completed ", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.setDashboardProp();
+    });
+
+    it('Should update title', () => {
+      expect(document.title).toBe('My dashboard - Grafana');
+    });
+
+    it('Should render dashboard grid', () => {
+      expect(ctx.wrapper).toMatchSnapshot();
+    });
+  });
+
+  dashboardPageScenario("When user goes into panel edit", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.setDashboardProp();
+      ctx.wrapper.setProps({
+        urlFullscreen: true,
+        urlEdit: true,
+        urlPanelId: '1',
+      });
+    });
+
+    it('Should update model state to fullscreen & edit', () => {
+      expect(ctx.dashboard.meta.fullscreen).toBe(true);
+      expect(ctx.dashboard.meta.isEditing).toBe(true);
+    });
+
+    it('Should update component state to fullscreen and edit', () => {
+      const state = ctx.wrapper.state();
+      expect(state.isEditing).toBe(true);
+      expect(state.isFullscreen).toBe(true);
+    });
+  });
+
+  dashboardPageScenario("When user goes back to dashboard from panel edit", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.setDashboardProp();
+      ctx.wrapper.setState({ scrollTop: 100 });
+      ctx.wrapper.setProps({
+        urlFullscreen: true,
+        urlEdit: true,
+        urlPanelId: '1',
+      });
+      ctx.wrapper.setProps({
+        urlFullscreen: false,
+        urlEdit: false,
+        urlPanelId: null,
+      });
+    });
+
+    it('Should update model state normal state', () => {
+      expect(ctx.dashboard.meta.fullscreen).toBe(false);
+      expect(ctx.dashboard.meta.isEditing).toBe(false);
+    });
+
+    it('Should update component state to normal and restore scrollTop', () => {
+      const state = ctx.wrapper.state();
+      expect(state.isEditing).toBe(false);
+      expect(state.isFullscreen).toBe(false);
+      expect(state.scrollTop).toBe(100);
+    });
+  });
+
+  dashboardPageScenario("When dashboard has editview url state", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.setDashboardProp();
+      ctx.wrapper.setProps({
+        editview: 'settings',
+      });
+    });
+
+    it('should render settings view', () => {
+      expect(ctx.wrapper).toMatchSnapshot();
+    });
+
+    it('should set animation state', () => {
+      expect(ctx.wrapper.state().isSettingsOpening).toBe(true);
+    });
+  });
+
+  dashboardPageScenario("When adding panel", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.setDashboardProp();
+      ctx.wrapper.setState({ scrollTop: 100 });
+      ctx.wrapper.instance().onAddPanel();
+    });
+
+    it('should set scrollTop to 0', () => {
+      expect(ctx.wrapper.state().scrollTop).toBe(0);
+    });
+
+    it('should add panel widget to dashboard panels', () => {
+      expect(ctx.dashboard.panels[0].type).toBe('add-panel');
+    });
+  });
+
+  dashboardPageScenario("Given panel with id 0", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.setDashboardProp({
+        panels: [{ id: 0, type: 'graph'}],
+        schemaVersion: 17,
+      });
+      ctx.wrapper.setProps({
+        urlEdit: true,
+        urlFullscreen: true,
+        urlPanelId: '0'
+      });
+    });
+
+    it('Should go into edit mode' , () => {
+      expect(ctx.wrapper.state().isEditing).toBe(true);
+      expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0);
+    });
+  });
+
+  dashboardPageScenario("When dashboard unmounts", (ctx) => {
+    ctx.setup(() => {
+      ctx.mount();
+      ctx.setDashboardProp({
+        panels: [{ id: 0, type: 'graph'}],
+        schemaVersion: 17,
+      });
+      ctx.wrapper.unmount();
+    });
+
+    it('Should call clean up action' , () => {
+      expect(ctx.cleanUpDashboardMock.calls).toBe(1);
+    });
+  });
+});

+ 309 - 0
public/app/features/dashboard/containers/DashboardPage.tsx

@@ -0,0 +1,309 @@
+// Libraries
+import $ from 'jquery';
+import React, { PureComponent, MouseEvent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+
+// Services & Utils
+import { createErrorNotification } from 'app/core/copy/appNotification';
+import { getMessageFromError } from 'app/core/utils/errors';
+
+// Components
+import { DashboardGrid } from '../dashgrid/DashboardGrid';
+import { DashNav } from '../components/DashNav';
+import { SubMenu } from '../components/SubMenu';
+import { DashboardSettings } from '../components/DashboardSettings';
+import { CustomScrollbar } from '@grafana/ui';
+import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
+
+// Redux
+import { initDashboard } from '../state/initDashboard';
+import { cleanUpDashboard } from '../state/actions';
+import { updateLocation } from 'app/core/actions';
+import { notifyApp } from 'app/core/actions';
+
+// Types
+import {
+  StoreState,
+  DashboardInitPhase,
+  DashboardRouteInfo,
+  DashboardInitError,
+  AppNotificationSeverity,
+} from 'app/types';
+import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
+
+export interface Props {
+  urlUid?: string;
+  urlSlug?: string;
+  urlType?: string;
+  editview?: string;
+  urlPanelId?: string;
+  urlFolderId?: string;
+  $scope: any;
+  $injector: any;
+  routeInfo: DashboardRouteInfo;
+  urlEdit: boolean;
+  urlFullscreen: boolean;
+  initPhase: DashboardInitPhase;
+  isInitSlow: boolean;
+  dashboard: DashboardModel | null;
+  initError?: DashboardInitError;
+  initDashboard: typeof initDashboard;
+  cleanUpDashboard: typeof cleanUpDashboard;
+  notifyApp: typeof notifyApp;
+  updateLocation: typeof updateLocation;
+}
+
+export interface State {
+  isSettingsOpening: boolean;
+  isEditing: boolean;
+  isFullscreen: boolean;
+  fullscreenPanel: PanelModel | null;
+  scrollTop: number;
+  rememberScrollTop: number;
+  showLoadingState: boolean;
+}
+
+export class DashboardPage extends PureComponent<Props, State> {
+  state: State = {
+    isSettingsOpening: false,
+    isEditing: false,
+    isFullscreen: false,
+    showLoadingState: false,
+    fullscreenPanel: null,
+    scrollTop: 0,
+    rememberScrollTop: 0,
+  };
+
+  async componentDidMount() {
+    this.props.initDashboard({
+      $injector: this.props.$injector,
+      $scope: this.props.$scope,
+      urlSlug: this.props.urlSlug,
+      urlUid: this.props.urlUid,
+      urlType: this.props.urlType,
+      urlFolderId: this.props.urlFolderId,
+      routeInfo: this.props.routeInfo,
+      fixUrl: true,
+    });
+  }
+
+  componentWillUnmount() {
+    if (this.props.dashboard) {
+      this.props.cleanUpDashboard();
+    }
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props;
+
+    if (!dashboard) {
+      return;
+    }
+
+    // if we just got dashboard update title
+    if (!prevProps.dashboard) {
+      document.title = dashboard.title + ' - Grafana';
+    }
+
+    // handle animation states when opening dashboard settings
+    if (!prevProps.editview && editview) {
+      this.setState({ isSettingsOpening: true });
+      setTimeout(() => {
+        this.setState({ isSettingsOpening: false });
+      }, 10);
+    }
+
+    // Sync url state with model
+    if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) {
+      if (!isNaN(parseInt(urlPanelId, 10))) {
+        this.onEnterFullscreen();
+      } else {
+        this.onLeaveFullscreen();
+      }
+    }
+  }
+
+  onEnterFullscreen() {
+    const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props;
+
+    const panelId = parseInt(urlPanelId, 10);
+
+    // need to expand parent row if this panel is inside a row
+    dashboard.expandParentRowFor(panelId);
+
+    const panel = dashboard.getPanelById(panelId);
+
+    if (panel) {
+      dashboard.setViewMode(panel, urlFullscreen, urlEdit);
+      this.setState({
+        isEditing: urlEdit && dashboard.meta.canEdit,
+        isFullscreen: urlFullscreen,
+        fullscreenPanel: panel,
+        rememberScrollTop: this.state.scrollTop,
+      });
+      this.setPanelFullscreenClass(urlFullscreen);
+    } else {
+      this.handleFullscreenPanelNotFound(urlPanelId);
+    }
+  }
+
+  onLeaveFullscreen() {
+    const { dashboard } = this.props;
+
+    if (this.state.fullscreenPanel) {
+      dashboard.setViewMode(this.state.fullscreenPanel, false, false);
+    }
+
+    this.setState(
+      {
+        isEditing: false,
+        isFullscreen: false,
+        fullscreenPanel: null,
+        scrollTop: this.state.rememberScrollTop,
+      },
+      () => {
+        dashboard.render();
+      }
+    );
+
+    this.setPanelFullscreenClass(false);
+  }
+
+  handleFullscreenPanelNotFound(urlPanelId: string) {
+    // Panel not found
+    this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));
+    // Clear url state
+    this.props.updateLocation({
+      query: {
+        edit: null,
+        fullscreen: null,
+        panelId: null,
+      },
+      partial: true,
+    });
+  }
+
+  setPanelFullscreenClass(isFullscreen: boolean) {
+    $('body').toggleClass('panel-in-fullscreen', isFullscreen);
+  }
+
+  setScrollTop = (e: MouseEvent<HTMLElement>): void => {
+    const target = e.target as HTMLElement;
+    this.setState({ scrollTop: target.scrollTop });
+  };
+
+  onAddPanel = () => {
+    const { dashboard } = this.props;
+
+    // Return if the "Add panel" exists already
+    if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') {
+      return;
+    }
+
+    dashboard.addPanel({
+      type: 'add-panel',
+      gridPos: { x: 0, y: 0, w: 12, h: 8 },
+      title: 'Panel Title',
+    });
+
+    // scroll to top after adding panel
+    this.setState({ scrollTop: 0 });
+  };
+
+  renderSlowInitState() {
+    return (
+      <div className="dashboard-loading">
+        <div className="dashboard-loading__text">
+          <i className="fa fa-spinner fa-spin" /> {this.props.initPhase}
+        </div>
+      </div>
+    );
+  }
+
+  renderInitFailedState() {
+    const { initError } = this.props;
+
+    return (
+      <div className="dashboard-loading">
+        <AlertBox
+          severity={AppNotificationSeverity.Error}
+          title={initError.message}
+          text={getMessageFromError(initError.error)}
+        />
+      </div>
+    );
+  }
+
+  render() {
+    const { dashboard, editview, $injector, isInitSlow, initError } = this.props;
+    const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state;
+
+    if (!dashboard) {
+      if (isInitSlow) {
+        return this.renderSlowInitState();
+      }
+      return null;
+    }
+
+    const classes = classNames({
+      'dashboard-page--settings-opening': isSettingsOpening,
+      'dashboard-page--settings-open': !isSettingsOpening && editview,
+    });
+
+    const gridWrapperClasses = classNames({
+      'dashboard-container': true,
+      'dashboard-container--has-submenu': dashboard.meta.submenuEnabled,
+    });
+
+    return (
+      <div className={classes}>
+        <DashNav
+          dashboard={dashboard}
+          isEditing={isEditing}
+          isFullscreen={isFullscreen}
+          editview={editview}
+          $injector={$injector}
+          onAddPanel={this.onAddPanel}
+        />
+        <div className="scroll-canvas scroll-canvas--dashboard">
+          <CustomScrollbar autoHeightMin={'100%'} setScrollTop={this.setScrollTop} scrollTop={scrollTop}>
+            {editview && <DashboardSettings dashboard={dashboard} />}
+
+            {initError && this.renderInitFailedState()}
+
+            <div className={gridWrapperClasses}>
+              {dashboard.meta.submenuEnabled && <SubMenu dashboard={dashboard} />}
+              <DashboardGrid dashboard={dashboard} isEditing={isEditing} isFullscreen={isFullscreen} />
+            </div>
+          </CustomScrollbar>
+        </div>
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = (state: StoreState) => ({
+  urlUid: state.location.routeParams.uid,
+  urlSlug: state.location.routeParams.slug,
+  urlType: state.location.routeParams.type,
+  editview: state.location.query.editview,
+  urlPanelId: state.location.query.panelId,
+  urlFolderId: state.location.query.folderId,
+  urlFullscreen: state.location.query.fullscreen === true,
+  urlEdit: state.location.query.edit === true,
+  initPhase: state.dashboard.initPhase,
+  isInitSlow: state.dashboard.isInitSlow,
+  initError: state.dashboard.initError,
+  dashboard: state.dashboard.model as DashboardModel,
+});
+
+const mapDispatchToProps = {
+  initDashboard,
+  cleanUpDashboard,
+  notifyApp,
+  updateLocation,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage));

+ 39 - 52
public/app/features/dashboard/containers/SoloPanelPage.tsx

@@ -3,98 +3,84 @@ import React, { Component } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 
-// Utils & Services
-import appEvents from 'app/core/app_events';
-import locationUtil from 'app/core/utils/location_util';
-import { getBackendSrv } from 'app/core/services/backend_srv';
-
 // Components
 import { DashboardPanel } from '../dashgrid/DashboardPanel';
 
 // Redux
-import { updateLocation } from 'app/core/actions';
+import { initDashboard } from '../state/initDashboard';
 
 // Types
-import { StoreState } from 'app/types';
+import { StoreState, DashboardRouteInfo } from 'app/types';
 import { PanelModel, DashboardModel } from 'app/features/dashboard/state';
 
 interface Props {
-  panelId: string;
+  urlPanelId: string;
   urlUid?: string;
   urlSlug?: string;
   urlType?: string;
   $scope: any;
   $injector: any;
-  updateLocation: typeof updateLocation;
+  routeInfo: DashboardRouteInfo;
+  initDashboard: typeof initDashboard;
+  dashboard: DashboardModel | null;
 }
 
 interface State {
   panel: PanelModel | null;
-  dashboard: DashboardModel | null;
   notFound: boolean;
 }
 
 export class SoloPanelPage extends Component<Props, State> {
-
   state: State = {
     panel: null,
-    dashboard: null,
     notFound: false,
   };
 
   componentDidMount() {
-    const { $injector, $scope, urlUid, urlType, urlSlug } = this.props;
+    const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props;
+
+    this.props.initDashboard({
+      $injector: $injector,
+      $scope: $scope,
+      urlSlug: urlSlug,
+      urlUid: urlUid,
+      urlType: urlType,
+      routeInfo: routeInfo,
+      fixUrl: false,
+    });
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    const { urlPanelId, dashboard } = this.props;
 
-    // handle old urls with no uid
-    if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
-      this.redirectToNewUrl();
+    if (!dashboard) {
       return;
     }
 
-    const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
+    // we just got the dashboard!
+    if (!prevProps.dashboard) {
+      const panelId = parseInt(urlPanelId, 10);
 
-    // subscribe to event to know when dashboard controller is done with inititalization
-    appEvents.on('dashboard-initialized', this.onDashoardInitialized);
+      // need to expand parent row if this panel is inside a row
+      dashboard.expandParentRowFor(panelId);
 
-    dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
-      result.meta.soloMode = true;
-      $scope.initDashboard(result, $scope);
-    });
-  }
+      const panel = dashboard.getPanelById(panelId);
 
-  redirectToNewUrl() {
-    getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => {
-      if (res) {
-        const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
-        this.props.updateLocation(url);
+      if (!panel) {
+        this.setState({ notFound: true });
+        return;
       }
-    });
-  }
-
-  onDashoardInitialized = () => {
-    const { $scope, panelId } = this.props;
 
-    const dashboard: DashboardModel = $scope.dashboard;
-    const panel = dashboard.getPanelById(parseInt(panelId, 10));
-
-    if (!panel) {
-      this.setState({ notFound: true });
-      return;
+      this.setState({ panel });
     }
-
-    this.setState({ dashboard, panel });
-  };
+  }
 
   render() {
-    const { panelId } = this.props;
-    const { notFound, panel, dashboard } = this.state;
+    const { urlPanelId, dashboard } = this.props;
+    const { notFound, panel } = this.state;
 
     if (notFound) {
-      return (
-        <div className="alert alert-error">
-          Panel with id { panelId } not found
-        </div>
-      );
+      return <div className="alert alert-error">Panel with id {urlPanelId} not found</div>;
     }
 
     if (!panel) {
@@ -113,11 +99,12 @@ const mapStateToProps = (state: StoreState) => ({
   urlUid: state.location.routeParams.uid,
   urlSlug: state.location.routeParams.slug,
   urlType: state.location.routeParams.type,
-  panelId: state.location.query.panelId
+  urlPanelId: state.location.query.panelId,
+  dashboard: state.dashboard.model as DashboardModel,
 });
 
 const mapDispatchToProps = {
-  updateLocation
+  initDashboard,
 };
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));

+ 546 - 0
public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap

@@ -0,0 +1,546 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DashboardPage Dashboard init completed  Should render dashboard grid 1`] = `
+<div
+  className=""
+>
+  <Connect(DashNav)
+    $injector={Object {}}
+    dashboard={
+      DashboardModel {
+        "annotations": Object {
+          "list": Array [
+            Object {
+              "builtIn": 1,
+              "datasource": "-- Grafana --",
+              "enable": true,
+              "hide": true,
+              "iconColor": "rgba(0, 211, 255, 1)",
+              "name": "Annotations & Alerts",
+              "type": "dashboard",
+            },
+          ],
+        },
+        "autoUpdate": undefined,
+        "description": undefined,
+        "editable": true,
+        "events": Emitter {
+          "emitter": EventEmitter {
+            "_events": Object {},
+            "_eventsCount": 0,
+          },
+        },
+        "gnetId": null,
+        "graphTooltip": 0,
+        "id": null,
+        "links": Array [],
+        "meta": Object {
+          "canEdit": true,
+          "canMakeEditable": false,
+          "canSave": true,
+          "canShare": true,
+          "canStar": true,
+          "fullscreen": false,
+          "isEditing": false,
+          "showSettings": true,
+        },
+        "originalTemplating": Array [],
+        "originalTime": Object {
+          "from": "now-6h",
+          "to": "now",
+        },
+        "panels": Array [
+          PanelModel {
+            "cachedPluginOptions": Object {},
+            "datasource": null,
+            "events": Emitter {
+              "emitter": EventEmitter {
+                "_events": Object {},
+                "_eventsCount": 0,
+              },
+            },
+            "gridPos": Object {
+              "h": 1,
+              "w": 1,
+              "x": 0,
+              "y": 0,
+            },
+            "id": 1,
+            "targets": Array [
+              Object {
+                "refId": "A",
+              },
+            ],
+            "title": "My graph",
+            "transparent": false,
+            "type": "graph",
+          },
+        ],
+        "refresh": undefined,
+        "revision": undefined,
+        "schemaVersion": 17,
+        "snapshot": undefined,
+        "style": "dark",
+        "tags": Array [],
+        "templating": Object {
+          "list": Array [],
+        },
+        "time": Object {
+          "from": "now-6h",
+          "to": "now",
+        },
+        "timepicker": Object {},
+        "timezone": "",
+        "title": "My dashboard",
+        "uid": null,
+        "version": 0,
+      }
+    }
+    isEditing={false}
+    isFullscreen={false}
+    onAddPanel={[Function]}
+  />
+  <div
+    className="scroll-canvas scroll-canvas--dashboard"
+  >
+    <CustomScrollbar
+      autoHeightMax="100%"
+      autoHeightMin="100%"
+      autoHide={false}
+      autoHideDuration={200}
+      autoHideTimeout={200}
+      customClassName="custom-scrollbars"
+      hideTracksWhenNotNeeded={false}
+      scrollTop={0}
+      setScrollTop={[Function]}
+    >
+      <div
+        className="dashboard-container"
+      >
+        <DashboardGrid
+          dashboard={
+            DashboardModel {
+              "annotations": Object {
+                "list": Array [
+                  Object {
+                    "builtIn": 1,
+                    "datasource": "-- Grafana --",
+                    "enable": true,
+                    "hide": true,
+                    "iconColor": "rgba(0, 211, 255, 1)",
+                    "name": "Annotations & Alerts",
+                    "type": "dashboard",
+                  },
+                ],
+              },
+              "autoUpdate": undefined,
+              "description": undefined,
+              "editable": true,
+              "events": Emitter {
+                "emitter": EventEmitter {
+                  "_events": Object {},
+                  "_eventsCount": 0,
+                },
+              },
+              "gnetId": null,
+              "graphTooltip": 0,
+              "id": null,
+              "links": Array [],
+              "meta": Object {
+                "canEdit": true,
+                "canMakeEditable": false,
+                "canSave": true,
+                "canShare": true,
+                "canStar": true,
+                "fullscreen": false,
+                "isEditing": false,
+                "showSettings": true,
+              },
+              "originalTemplating": Array [],
+              "originalTime": Object {
+                "from": "now-6h",
+                "to": "now",
+              },
+              "panels": Array [
+                PanelModel {
+                  "cachedPluginOptions": Object {},
+                  "datasource": null,
+                  "events": Emitter {
+                    "emitter": EventEmitter {
+                      "_events": Object {},
+                      "_eventsCount": 0,
+                    },
+                  },
+                  "gridPos": Object {
+                    "h": 1,
+                    "w": 1,
+                    "x": 0,
+                    "y": 0,
+                  },
+                  "id": 1,
+                  "targets": Array [
+                    Object {
+                      "refId": "A",
+                    },
+                  ],
+                  "title": "My graph",
+                  "transparent": false,
+                  "type": "graph",
+                },
+              ],
+              "refresh": undefined,
+              "revision": undefined,
+              "schemaVersion": 17,
+              "snapshot": undefined,
+              "style": "dark",
+              "tags": Array [],
+              "templating": Object {
+                "list": Array [],
+              },
+              "time": Object {
+                "from": "now-6h",
+                "to": "now",
+              },
+              "timepicker": Object {},
+              "timezone": "",
+              "title": "My dashboard",
+              "uid": null,
+              "version": 0,
+            }
+          }
+          isEditing={false}
+          isFullscreen={false}
+        />
+      </div>
+    </CustomScrollbar>
+  </div>
+</div>
+`;
+
+exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = `
+<div
+  className="dashboard-loading"
+>
+  <div
+    className="dashboard-loading__text"
+  >
+    <i
+      className="fa fa-spinner fa-spin"
+    />
+     
+    Fetching
+  </div>
+</div>
+`;
+
+exports[`DashboardPage Given initial state Should render nothing 1`] = `""`;
+
+exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = `
+<div
+  className="dashboard-page--settings-opening"
+>
+  <Connect(DashNav)
+    $injector={Object {}}
+    dashboard={
+      DashboardModel {
+        "annotations": Object {
+          "list": Array [
+            Object {
+              "builtIn": 1,
+              "datasource": "-- Grafana --",
+              "enable": true,
+              "hide": true,
+              "iconColor": "rgba(0, 211, 255, 1)",
+              "name": "Annotations & Alerts",
+              "type": "dashboard",
+            },
+          ],
+        },
+        "autoUpdate": undefined,
+        "description": undefined,
+        "editable": true,
+        "events": Emitter {
+          "emitter": EventEmitter {
+            "_events": Object {},
+            "_eventsCount": 0,
+          },
+        },
+        "gnetId": null,
+        "graphTooltip": 0,
+        "id": null,
+        "links": Array [],
+        "meta": Object {
+          "canEdit": true,
+          "canMakeEditable": false,
+          "canSave": true,
+          "canShare": true,
+          "canStar": true,
+          "fullscreen": false,
+          "isEditing": false,
+          "showSettings": true,
+        },
+        "originalTemplating": Array [],
+        "originalTime": Object {
+          "from": "now-6h",
+          "to": "now",
+        },
+        "panels": Array [
+          PanelModel {
+            "cachedPluginOptions": Object {},
+            "datasource": null,
+            "events": Emitter {
+              "emitter": EventEmitter {
+                "_events": Object {},
+                "_eventsCount": 0,
+              },
+            },
+            "gridPos": Object {
+              "h": 1,
+              "w": 1,
+              "x": 0,
+              "y": 0,
+            },
+            "id": 1,
+            "targets": Array [
+              Object {
+                "refId": "A",
+              },
+            ],
+            "title": "My graph",
+            "transparent": false,
+            "type": "graph",
+          },
+        ],
+        "refresh": undefined,
+        "revision": undefined,
+        "schemaVersion": 17,
+        "snapshot": undefined,
+        "style": "dark",
+        "tags": Array [],
+        "templating": Object {
+          "list": Array [],
+        },
+        "time": Object {
+          "from": "now-6h",
+          "to": "now",
+        },
+        "timepicker": Object {},
+        "timezone": "",
+        "title": "My dashboard",
+        "uid": null,
+        "version": 0,
+      }
+    }
+    editview="settings"
+    isEditing={false}
+    isFullscreen={false}
+    onAddPanel={[Function]}
+  />
+  <div
+    className="scroll-canvas scroll-canvas--dashboard"
+  >
+    <CustomScrollbar
+      autoHeightMax="100%"
+      autoHeightMin="100%"
+      autoHide={false}
+      autoHideDuration={200}
+      autoHideTimeout={200}
+      customClassName="custom-scrollbars"
+      hideTracksWhenNotNeeded={false}
+      scrollTop={0}
+      setScrollTop={[Function]}
+    >
+      <DashboardSettings
+        dashboard={
+          DashboardModel {
+            "annotations": Object {
+              "list": Array [
+                Object {
+                  "builtIn": 1,
+                  "datasource": "-- Grafana --",
+                  "enable": true,
+                  "hide": true,
+                  "iconColor": "rgba(0, 211, 255, 1)",
+                  "name": "Annotations & Alerts",
+                  "type": "dashboard",
+                },
+              ],
+            },
+            "autoUpdate": undefined,
+            "description": undefined,
+            "editable": true,
+            "events": Emitter {
+              "emitter": EventEmitter {
+                "_events": Object {},
+                "_eventsCount": 0,
+              },
+            },
+            "gnetId": null,
+            "graphTooltip": 0,
+            "id": null,
+            "links": Array [],
+            "meta": Object {
+              "canEdit": true,
+              "canMakeEditable": false,
+              "canSave": true,
+              "canShare": true,
+              "canStar": true,
+              "fullscreen": false,
+              "isEditing": false,
+              "showSettings": true,
+            },
+            "originalTemplating": Array [],
+            "originalTime": Object {
+              "from": "now-6h",
+              "to": "now",
+            },
+            "panels": Array [
+              PanelModel {
+                "cachedPluginOptions": Object {},
+                "datasource": null,
+                "events": Emitter {
+                  "emitter": EventEmitter {
+                    "_events": Object {},
+                    "_eventsCount": 0,
+                  },
+                },
+                "gridPos": Object {
+                  "h": 1,
+                  "w": 1,
+                  "x": 0,
+                  "y": 0,
+                },
+                "id": 1,
+                "targets": Array [
+                  Object {
+                    "refId": "A",
+                  },
+                ],
+                "title": "My graph",
+                "transparent": false,
+                "type": "graph",
+              },
+            ],
+            "refresh": undefined,
+            "revision": undefined,
+            "schemaVersion": 17,
+            "snapshot": undefined,
+            "style": "dark",
+            "tags": Array [],
+            "templating": Object {
+              "list": Array [],
+            },
+            "time": Object {
+              "from": "now-6h",
+              "to": "now",
+            },
+            "timepicker": Object {},
+            "timezone": "",
+            "title": "My dashboard",
+            "uid": null,
+            "version": 0,
+          }
+        }
+      />
+      <div
+        className="dashboard-container"
+      >
+        <DashboardGrid
+          dashboard={
+            DashboardModel {
+              "annotations": Object {
+                "list": Array [
+                  Object {
+                    "builtIn": 1,
+                    "datasource": "-- Grafana --",
+                    "enable": true,
+                    "hide": true,
+                    "iconColor": "rgba(0, 211, 255, 1)",
+                    "name": "Annotations & Alerts",
+                    "type": "dashboard",
+                  },
+                ],
+              },
+              "autoUpdate": undefined,
+              "description": undefined,
+              "editable": true,
+              "events": Emitter {
+                "emitter": EventEmitter {
+                  "_events": Object {},
+                  "_eventsCount": 0,
+                },
+              },
+              "gnetId": null,
+              "graphTooltip": 0,
+              "id": null,
+              "links": Array [],
+              "meta": Object {
+                "canEdit": true,
+                "canMakeEditable": false,
+                "canSave": true,
+                "canShare": true,
+                "canStar": true,
+                "fullscreen": false,
+                "isEditing": false,
+                "showSettings": true,
+              },
+              "originalTemplating": Array [],
+              "originalTime": Object {
+                "from": "now-6h",
+                "to": "now",
+              },
+              "panels": Array [
+                PanelModel {
+                  "cachedPluginOptions": Object {},
+                  "datasource": null,
+                  "events": Emitter {
+                    "emitter": EventEmitter {
+                      "_events": Object {},
+                      "_eventsCount": 0,
+                    },
+                  },
+                  "gridPos": Object {
+                    "h": 1,
+                    "w": 1,
+                    "x": 0,
+                    "y": 0,
+                  },
+                  "id": 1,
+                  "targets": Array [
+                    Object {
+                      "refId": "A",
+                    },
+                  ],
+                  "title": "My graph",
+                  "transparent": false,
+                  "type": "graph",
+                },
+              ],
+              "refresh": undefined,
+              "revision": undefined,
+              "schemaVersion": 17,
+              "snapshot": undefined,
+              "style": "dark",
+              "tags": Array [],
+              "templating": Object {
+                "list": Array [],
+              },
+              "time": Object {
+                "from": "now-6h",
+                "to": "now",
+              },
+              "timepicker": Object {},
+              "timezone": "",
+              "title": "My dashboard",
+              "uid": null,
+              "version": 0,
+            }
+          }
+          isEditing={false}
+          isFullscreen={false}
+        />
+      </div>
+    </CustomScrollbar>
+  </div>
+</div>
+`;

+ 27 - 14
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -1,11 +1,14 @@
-import React from 'react';
+// Libaries
+import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import ReactGridLayout, { ItemCallback } from 'react-grid-layout';
+import classNames from 'classnames';
+import sizeMe from 'react-sizeme';
+
+// Types
 import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
 import { DashboardPanel } from './DashboardPanel';
 import { DashboardModel, PanelModel } from '../state';
-import classNames from 'classnames';
-import sizeMe from 'react-sizeme';
 
 let lastGridWidth = 1200;
 let ignoreNextWidthChange = false;
@@ -76,19 +79,18 @@ function GridWrapper({
 
 const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
 
-export interface DashboardGridProps {
+export interface Props {
   dashboard: DashboardModel;
+  isEditing: boolean;
+  isFullscreen: boolean;
 }
 
-export class DashboardGrid extends React.Component<DashboardGridProps> {
+export class DashboardGrid extends PureComponent<Props> {
   gridToPanelMap: any;
   panelMap: { [id: string]: PanelModel };
 
-  constructor(props: DashboardGridProps) {
-    super(props);
-
-    // subscribe to dashboard events
-    const dashboard = this.props.dashboard;
+  componentDidMount() {
+    const { dashboard } = this.props;
     dashboard.on('panel-added', this.triggerForceUpdate);
     dashboard.on('panel-removed', this.triggerForceUpdate);
     dashboard.on('repeats-processed', this.triggerForceUpdate);
@@ -97,6 +99,16 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
     dashboard.on('row-expanded', this.triggerForceUpdate);
   }
 
+  componentWillUnmount() {
+    const { dashboard } = this.props;
+    dashboard.off('panel-added', this.triggerForceUpdate);
+    dashboard.off('panel-removed', this.triggerForceUpdate);
+    dashboard.off('repeats-processed', this.triggerForceUpdate);
+    dashboard.off('view-mode-changed', this.onViewModeChanged);
+    dashboard.off('row-collapsed', this.triggerForceUpdate);
+    dashboard.off('row-expanded', this.triggerForceUpdate);
+  }
+
   buildLayout() {
     const layout = [];
     this.panelMap = {};
@@ -151,7 +163,6 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
 
   onViewModeChanged = () => {
     ignoreNextWidthChange = true;
-    this.forceUpdate();
   }
 
   updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => {
@@ -197,18 +208,20 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
   }
 
   render() {
+    const { dashboard, isFullscreen } = this.props;
+
     return (
       <SizedReactLayoutGrid
         className={classNames({ layout: true })}
         layout={this.buildLayout()}
-        isResizable={this.props.dashboard.meta.canEdit}
-        isDraggable={this.props.dashboard.meta.canEdit}
+        isResizable={dashboard.meta.canEdit}
+        isDraggable={dashboard.meta.canEdit}
         onLayoutChange={this.onLayoutChange}
         onWidthChange={this.onWidthChange}
         onDragStop={this.onDragStop}
         onResize={this.onResize}
         onResizeStop={this.onResizeStop}
-        isFullscreen={this.props.dashboard.meta.fullscreen}
+        isFullscreen={isFullscreen}
       >
         {this.renderPanels()}
       </SizedReactLayoutGrid>

+ 0 - 2
public/app/features/dashboard/index.ts

@@ -1,8 +1,6 @@
-import './containers/DashboardCtrl';
 import './dashgrid/DashboardGridDirective';
 
 // Services
-import './services/DashboardViewStateSrv';
 import './services/UnsavedChangesSrv';
 import './services/DashboardLoaderSrv';
 import './services/DashboardSrv';

+ 68 - 19
public/app/features/dashboard/services/DashboardSrv.ts

@@ -1,25 +1,74 @@
 import coreModule from 'app/core/core_module';
-import { DashboardModel } from '../state/DashboardModel';
+import { appEvents } from 'app/core/app_events';
 import locationUtil from 'app/core/utils/location_util';
+import { DashboardModel } from '../state/DashboardModel';
+import { removePanel } from '../utils/panel';
 
 export class DashboardSrv {
-  dash: any;
+  dashboard: DashboardModel;
 
   /** @ngInject */
-  constructor(private backendSrv, private $rootScope, private $location) {}
+  constructor(private backendSrv, private $rootScope, private $location) {
+    appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope);
+    appEvents.on('panel-change-view', this.onPanelChangeView);
+    appEvents.on('remove-panel', this.onRemovePanel);
+  }
 
   create(dashboard, meta) {
     return new DashboardModel(dashboard, meta);
   }
 
-  setCurrent(dashboard) {
-    this.dash = dashboard;
+  setCurrent(dashboard: DashboardModel) {
+    this.dashboard = dashboard;
   }
 
-  getCurrent() {
-    return this.dash;
+  getCurrent(): DashboardModel {
+    return this.dashboard;
   }
 
+  onRemovePanel = (panelId: number) => {
+    const dashboard = this.getCurrent();
+    removePanel(dashboard, dashboard.getPanelById(panelId), true);
+  };
+
+  onPanelChangeView = (options) => {
+    const urlParams = this.$location.search();
+
+    // handle toggle logic
+    if (options.fullscreen === urlParams.fullscreen) {
+      // I hate using these truthy converters (!!) but in this case
+      // I think it's appropriate. edit can be null/false/undefined and
+      // here i want all of those to compare the same
+      if (!!options.edit === !!urlParams.edit) {
+        delete urlParams.fullscreen;
+        delete urlParams.edit;
+        delete urlParams.panelId;
+        this.$location.search(urlParams);
+        return;
+      }
+    }
+
+    if (options.fullscreen) {
+      urlParams.fullscreen = true;
+    } else {
+      delete urlParams.fullscreen;
+    }
+
+    if (options.edit) {
+      urlParams.edit = true;
+    } else {
+      delete urlParams.edit;
+    }
+
+    if (options.panelId || options.panelId === 0) {
+      urlParams.panelId = options.panelId;
+    } else {
+      delete urlParams.panelId;
+    }
+
+    this.$location.search(urlParams);
+  };
+
   handleSaveDashboardError(clone, options, err) {
     options = options || {};
     options.overwrite = true;
@@ -75,10 +124,10 @@ export class DashboardSrv {
   }
 
   postSave(clone, data) {
-    this.dash.version = data.version;
+    this.dashboard.version = data.version;
 
     // important that these happens before location redirect below
-    this.$rootScope.appEvent('dashboard-saved', this.dash);
+    this.$rootScope.appEvent('dashboard-saved', this.dashboard);
     this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
 
     const newUrl = locationUtil.stripBaseFromUrl(data.url);
@@ -88,12 +137,12 @@ export class DashboardSrv {
       this.$location.url(newUrl).replace();
     }
 
-    return this.dash;
+    return this.dashboard;
   }
 
   save(clone, options) {
     options = options || {};
-    options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
+    options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
 
     return this.backendSrv
       .saveDashboard(clone, options)
@@ -103,26 +152,26 @@ export class DashboardSrv {
 
   saveDashboard(options?, clone?) {
     if (clone) {
-      this.setCurrent(this.create(clone, this.dash.meta));
+      this.setCurrent(this.create(clone, this.dashboard.meta));
     }
 
-    if (this.dash.meta.provisioned) {
+    if (this.dashboard.meta.provisioned) {
       return this.showDashboardProvisionedModal();
     }
 
-    if (!this.dash.meta.canSave && options.makeEditable !== true) {
+    if (!this.dashboard.meta.canSave && options.makeEditable !== true) {
       return Promise.resolve();
     }
 
-    if (this.dash.title === 'New dashboard') {
+    if (this.dashboard.title === 'New dashboard') {
       return this.showSaveAsModal();
     }
 
-    if (this.dash.version > 0) {
+    if (this.dashboard.version > 0) {
       return this.showSaveModal();
     }
 
-    return this.save(this.dash.getSaveModelClone(), options);
+    return this.save(this.dashboard.getSaveModelClone(), options);
   }
 
   saveJSONDashboard(json: string) {
@@ -163,8 +212,8 @@ export class DashboardSrv {
     }
 
     return promise.then(res => {
-      if (this.dash && this.dash.id === dashboardId) {
-        this.dash.meta.isStarred = res;
+      if (this.dashboard && this.dashboard.id === dashboardId) {
+        this.dashboard.meta.isStarred = res;
       }
       return res;
     });

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

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

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

@@ -1,185 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-import config from 'app/core/config';
-import appEvents from 'app/core/app_events';
-import { DashboardModel } from '../state/DashboardModel';
-
-// represents the transient view state
-// like fullscreen panel & edit
-export class DashboardViewStateSrv {
-  state: any;
-  panelScopes: any;
-  $scope: any;
-  dashboard: DashboardModel;
-  fullscreenPanel: any;
-  oldTimeRange: any;
-
-  /** @ngInject */
-  constructor($scope, private $location, private $timeout) {
-    const self = this;
-    self.state = {};
-    self.panelScopes = [];
-    self.$scope = $scope;
-    self.dashboard = $scope.dashboard;
-
-    $scope.onAppEvent('$routeUpdate', () => {
-      const urlState = self.getQueryStringState();
-      if (self.needsSync(urlState)) {
-        self.update(urlState, true);
-      }
-    });
-
-    $scope.onAppEvent('panel-change-view', (evt, payload) => {
-      self.update(payload);
-    });
-
-    // this marks changes to location during this digest cycle as not to add history item
-    // don't want url changes like adding orgId to add browser history
-    $location.replace();
-    this.update(this.getQueryStringState());
-  }
-
-  needsSync(urlState) {
-    return _.isEqual(this.state, urlState) === false;
-  }
-
-  getQueryStringState() {
-    const state = this.$location.search();
-    state.panelId = parseInt(state.panelId, 10) || null;
-    state.fullscreen = state.fullscreen ? true : null;
-    state.edit = state.edit === 'true' || state.edit === true || null;
-    state.editview = state.editview || null;
-    state.orgId = config.bootData.user.orgId;
-    return state;
-  }
-
-  serializeToUrl() {
-    const urlState = _.clone(this.state);
-    urlState.fullscreen = this.state.fullscreen ? true : null;
-    urlState.edit = this.state.edit ? true : null;
-    return urlState;
-  }
-
-  update(state, fromRouteUpdated?) {
-    // implement toggle logic
-    if (state.toggle) {
-      delete state.toggle;
-      if (this.state.fullscreen && state.fullscreen) {
-        if (this.state.edit === state.edit) {
-          state.fullscreen = !state.fullscreen;
-        }
-      }
-    }
-
-    _.extend(this.state, state);
-
-    if (!this.state.fullscreen) {
-      this.state.fullscreen = null;
-      this.state.edit = null;
-      // clear panel id unless in solo mode
-      if (!this.dashboard.meta.soloMode) {
-        this.state.panelId = null;
-      }
-    }
-
-    if ((this.state.fullscreen || this.dashboard.meta.soloMode) && this.state.panelId) {
-      // Trying to render panel in fullscreen when it's in the collapsed row causes an issue.
-      // So in this case expand collapsed row first.
-      this.toggleCollapsedPanelRow(this.state.panelId);
-    }
-
-    // if no edit state cleanup tab parm
-    if (!this.state.edit) {
-      delete this.state.tab;
-    }
-
-    // do not update url params if we are here
-    // from routeUpdated event
-    if (fromRouteUpdated !== true) {
-      this.$location.search(this.serializeToUrl());
-    }
-
-    this.syncState();
-  }
-
-  toggleCollapsedPanelRow(panelId) {
-    for (const panel of this.dashboard.panels) {
-      if (panel.collapsed) {
-        for (const rowPanel of panel.panels) {
-          if (rowPanel.id === panelId) {
-            this.dashboard.toggleRow(panel);
-            return;
-          }
-        }
-      }
-    }
-  }
-
-  syncState() {
-    if (this.state.fullscreen) {
-      const panel = this.dashboard.getPanelById(this.state.panelId);
-
-      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;
-      }
-
-      if (!panel.fullscreen) {
-        this.enterFullscreen(panel);
-      } else if (this.dashboard.meta.isEditing !== this.state.edit) {
-        this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
-      }
-    } else if (this.fullscreenPanel) {
-      this.leaveFullscreen();
-    }
-  }
-
-  leaveFullscreen() {
-    const panel = this.fullscreenPanel;
-
-    this.dashboard.setViewMode(panel, false, false);
-
-    delete this.fullscreenPanel;
-
-    this.$timeout(() => {
-      appEvents.emit('dash-scroll', { restore: true });
-
-      if (this.oldTimeRange !== this.dashboard.time) {
-        this.dashboard.startRefresh();
-      } else {
-        this.dashboard.render();
-      }
-    });
-  }
-
-  enterFullscreen(panel) {
-    const isEditing = this.state.edit && this.dashboard.meta.canEdit;
-
-    this.oldTimeRange = this.dashboard.time;
-    this.fullscreenPanel = panel;
-
-    // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
-    this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
-    this.dashboard.setViewMode(panel, true, isEditing);
-  }
-}
-
-/** @ngInject */
-export function dashboardViewStateSrv($location, $timeout) {
-  return {
-    create: $scope => {
-      return new DashboardViewStateSrv($scope, $location, $timeout);
-    },
-  };
-}
-
-angular.module('grafana.services').factory('dashboardViewStateSrv', dashboardViewStateSrv);

+ 34 - 11
public/app/features/dashboard/state/DashboardModel.ts

@@ -1,20 +1,26 @@
+// Libaries
 import moment from 'moment';
 import _ from 'lodash';
-import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
 
+// Constants
+import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
 import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
+
+// Utils & Services
 import { Emitter } from 'app/core/utils/emitter';
 import { contextSrv } from 'app/core/services/context_srv';
 import sortByKeys from 'app/core/utils/sort_by_keys';
 
+// Types
 import { PanelModel } from './PanelModel';
 import { DashboardMigrator } from './DashboardMigrator';
 import { TimeRange } from '@grafana/ui/src';
+import { UrlQueryValue, KIOSK_MODE_TV, DashboardMeta } from 'app/types';
 
 export class DashboardModel {
   id: any;
-  uid: any;
-  title: any;
+  uid: string;
+  title: string;
   autoUpdate: any;
   description: any;
   tags: any;
@@ -43,7 +49,7 @@ export class DashboardModel {
 
   // repeat process cycles
   iteration: number;
-  meta: any;
+  meta: DashboardMeta;
   events: Emitter;
 
   static nonPersistedProperties: { [str: string]: boolean } = {
@@ -127,6 +133,8 @@ export class DashboardModel {
     meta.canEdit = meta.canEdit !== false;
     meta.showSettings = meta.canEdit;
     meta.canMakeEditable = meta.canSave && !this.editable;
+    meta.fullscreen = false;
+    meta.isEditing = false;
 
     if (!this.editable) {
       meta.canEdit = false;
@@ -860,11 +868,7 @@ export class DashboardModel {
     return !_.isEqual(updated, this.originalTemplating);
   }
 
-  autoFitPanels(viewHeight: number) {
-    if (!this.meta.autofitpanels) {
-      return;
-    }
-
+  autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) {
     const currentGridHeight = Math.max(
       ...this.panels.map(panel => {
         return panel.gridPos.h + panel.gridPos.y;
@@ -878,12 +882,12 @@ export class DashboardModel {
     let visibleHeight = viewHeight - navbarHeight - margin;
 
     // Remove submenu height if visible
-    if (this.meta.submenuEnabled && !this.meta.kiosk) {
+    if (this.meta.submenuEnabled && !kioskMode) {
       visibleHeight -= submenuHeight;
     }
 
     // add back navbar height
-    if (this.meta.kiosk === 'b') {
+    if (kioskMode === KIOSK_MODE_TV) {
       visibleHeight += 55;
     }
 
@@ -895,4 +899,23 @@ export class DashboardModel {
       panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1;
     });
   }
+
+  templateVariableValueUpdated() {
+    this.processRepeats();
+    this.events.emit('template-variable-value-updated');
+  }
+
+  expandParentRowFor(panelId: number) {
+    for (const panel of this.panels) {
+      if (panel.collapsed) {
+        for (const rowPanel of panel.panels) {
+          if (rowPanel.id === panelId) {
+            this.toggleRow(panel);
+            return;
+          }
+        }
+      }
+    }
+  }
+
 }

+ 28 - 23
public/app/features/dashboard/state/actions.ts

@@ -1,39 +1,43 @@
-import { StoreState } from 'app/types';
-import { ThunkAction } from 'redux-thunk';
+// Services & Utils
 import { getBackendSrv } from 'app/core/services/backend_srv';
-import appEvents from 'app/core/app_events';
+import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
+import { createSuccessNotification } from 'app/core/copy/appNotification';
+
+// Actions
 import { loadPluginDashboards } from '../../plugins/state/actions';
+import { notifyApp } from 'app/core/actions';
+
+// Types
 import {
+  ThunkResult,
   DashboardAcl,
   DashboardAclDTO,
   PermissionLevel,
   DashboardAclUpdateDTO,
   NewDashboardAclItem,
-} from 'app/types/acl';
+  MutableDashboard,
+  DashboardInitError,
+} from 'app/types';
 
-export enum ActionTypes {
-  LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS',
-  LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
-}
+export const loadDashboardPermissions = actionCreatorFactory<DashboardAclDTO[]>('LOAD_DASHBOARD_PERMISSIONS').create();
 
-export interface LoadDashboardPermissionsAction {
-  type: ActionTypes.LoadDashboardPermissions;
-  payload: DashboardAcl[];
-}
+export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create();
 
-export interface LoadStarredDashboardsAction {
-  type: ActionTypes.LoadStarredDashboards;
-  payload: DashboardAcl[];
-}
+export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create();
+
+export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create();
 
-export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
+export const dashboardInitCompleted = actionCreatorFactory<MutableDashboard>('DASHBOARD_INIT_COMLETED').create();
 
-type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
+/*
+ * Unrecoverable init failure (fetch or model creation failed)
+ */
+export const dashboardInitFailed = actionCreatorFactory<DashboardInitError>('DASHBOARD_INIT_FAILED').create();
 
-export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({
-  type: ActionTypes.LoadDashboardPermissions,
-  payload: items,
-});
+/*
+ * When leaving dashboard, resets state
+ * */
+export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create();
 
 export function getDashboardPermissions(id: number): ThunkResult<void> {
   return async dispatch => {
@@ -124,7 +128,7 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
 export function importDashboard(data, dashboardTitle: string): ThunkResult<void> {
   return async dispatch => {
     await getBackendSrv().post('/api/dashboards/import', data);
-    appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
+    dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle)));
     dispatch(loadPluginDashboards());
   };
 }
@@ -135,3 +139,4 @@ export function removeDashboard(uri: string): ThunkResult<void> {
     dispatch(loadPluginDashboards());
   };
 }
+

+ 152 - 0
public/app/features/dashboard/state/initDashboard.test.ts

@@ -0,0 +1,152 @@
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { initDashboard, InitDashboardArgs } from './initDashboard';
+import { DashboardRouteInfo } from 'app/types';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import {
+  dashboardInitFetching,
+  dashboardInitCompleted,
+  dashboardInitServices,
+} from './actions';
+
+jest.mock('app/core/services/backend_srv');
+
+const mockStore = configureMockStore([thunk]);
+
+interface ScenarioContext {
+  args: InitDashboardArgs;
+  timeSrv: any;
+  annotationsSrv: any;
+  unsavedChangesSrv: any;
+  variableSrv: any;
+  dashboardSrv: any;
+  keybindingSrv: any;
+  backendSrv: any;
+  setup: (fn: () => void) => void;
+  actions: any[];
+  storeState: any;
+}
+
+type ScenarioFn = (ctx: ScenarioContext) => void;
+
+function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
+  describe(description, () => {
+    const timeSrv = { init: jest.fn() };
+    const annotationsSrv = { init: jest.fn() };
+    const unsavedChangesSrv = { init: jest.fn() };
+    const variableSrv = { init: jest.fn() };
+    const dashboardSrv = { setCurrent: jest.fn() };
+    const keybindingSrv = { setupDashboardBindings: jest.fn() };
+
+    const injectorMock = {
+      get: (name: string) => {
+        switch (name) {
+          case 'timeSrv':
+            return timeSrv;
+          case 'annotationsSrv':
+            return annotationsSrv;
+          case 'unsavedChangesSrv':
+            return unsavedChangesSrv;
+          case 'dashboardSrv':
+            return dashboardSrv;
+          case 'variableSrv':
+            return variableSrv;
+          case 'keybindingSrv':
+            return keybindingSrv;
+          default:
+            throw { message: 'Unknown service ' + name };
+        }
+      },
+    };
+
+    let setupFn = () => {};
+
+    const ctx: ScenarioContext = {
+      args: {
+        $injector: injectorMock,
+        $scope: {},
+        fixUrl: false,
+        routeInfo: DashboardRouteInfo.Normal,
+      },
+      backendSrv: getBackendSrv(),
+      timeSrv,
+      annotationsSrv,
+      unsavedChangesSrv,
+      variableSrv,
+      dashboardSrv,
+      keybindingSrv,
+      actions: [],
+      storeState: {
+        location: {
+          query: {},
+        },
+        user: {},
+      },
+      setup: (fn: () => void) => {
+        setupFn = fn;
+      },
+    };
+
+    beforeEach(async () => {
+      setupFn();
+
+      const store = mockStore(ctx.storeState);
+
+      await store.dispatch(initDashboard(ctx.args));
+
+      ctx.actions = store.getActions();
+    });
+
+    scenarioFn(ctx);
+  });
+}
+
+describeInitScenario('Initializing new dashboard', ctx => {
+  ctx.setup(() => {
+    ctx.storeState.user.orgId = 12;
+    ctx.args.routeInfo = DashboardRouteInfo.New;
+  });
+
+  it('Should send action dashboardInitFetching', () => {
+    expect(ctx.actions[0].type).toBe(dashboardInitFetching.type);
+  });
+
+  it('Should send action dashboardInitServices ', () => {
+    expect(ctx.actions[1].type).toBe(dashboardInitServices.type);
+  });
+
+  it('Should update location with orgId query param', () => {
+    expect(ctx.actions[2].type).toBe('UPDATE_LOCATION');
+    expect(ctx.actions[2].payload.query.orgId).toBe(12);
+  });
+
+  it('Should send action dashboardInitCompleted', () => {
+    expect(ctx.actions[3].type).toBe(dashboardInitCompleted.type);
+    expect(ctx.actions[3].payload.title).toBe('New dashboard');
+  });
+
+  it('Should Initializing services', () => {
+    expect(ctx.timeSrv.init).toBeCalled();
+    expect(ctx.annotationsSrv.init).toBeCalled();
+    expect(ctx.variableSrv.init).toBeCalled();
+    expect(ctx.unsavedChangesSrv.init).toBeCalled();
+    expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled();
+    expect(ctx.dashboardSrv.setCurrent).toBeCalled();
+  });
+});
+
+describeInitScenario('Initializing home dashboard', ctx => {
+  ctx.setup(() => {
+    ctx.args.routeInfo = DashboardRouteInfo.Home;
+    ctx.backendSrv.get.mockReturnValue(Promise.resolve({
+      redirectUri: '/u/123/my-home'
+    }));
+  });
+
+  it('Should redirect to custom home dashboard', () => {
+    expect(ctx.actions[1].type).toBe('UPDATE_LOCATION');
+    expect(ctx.actions[1].payload.path).toBe('/u/123/my-home');
+  });
+});
+
+

+ 233 - 0
public/app/features/dashboard/state/initDashboard.ts

@@ -0,0 +1,233 @@
+// Services & Utils
+import { createErrorNotification } from 'app/core/copy/appNotification';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
+import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
+import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
+import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
+import { VariableSrv } from 'app/features/templating/variable_srv';
+import { KeybindingSrv } from 'app/core/services/keybindingSrv';
+
+// Actions
+import { updateLocation } from 'app/core/actions';
+import { notifyApp } from 'app/core/actions';
+import locationUtil from 'app/core/utils/location_util';
+import {
+  dashboardInitFetching,
+  dashboardInitCompleted,
+  dashboardInitFailed,
+  dashboardInitSlow,
+  dashboardInitServices,
+} from './actions';
+
+// Types
+import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
+import { DashboardModel } from './DashboardModel';
+
+export interface InitDashboardArgs {
+  $injector: any;
+  $scope: any;
+  urlUid?: string;
+  urlSlug?: string;
+  urlType?: string;
+  urlFolderId?: string;
+  routeInfo: DashboardRouteInfo;
+  fixUrl: boolean;
+}
+
+async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) {
+  const res = await getBackendSrv().getDashboardBySlug(slug);
+
+  if (res) {
+    let newUrl = res.meta.url;
+
+    // fix solo route urls
+    if (currentPath.indexOf('dashboard-solo') !== -1) {
+      newUrl = newUrl.replace('/d/', '/d-solo/');
+    }
+
+    const url = locationUtil.stripBaseFromUrl(newUrl);
+    dispatch(updateLocation({ path: url, partial: true, replace: true }));
+  }
+}
+
+async function fetchDashboard(
+  args: InitDashboardArgs,
+  dispatch: ThunkDispatch,
+  getState: () => StoreState
+): Promise<DashboardDTO | null> {
+  try {
+    switch (args.routeInfo) {
+      case DashboardRouteInfo.Home: {
+        // load home dash
+        const dashDTO: DashboardDTO = await getBackendSrv().get('/api/dashboards/home');
+
+        // if user specified a custom home dashboard redirect to that
+        if (dashDTO.redirectUri) {
+          const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri);
+          dispatch(updateLocation({ path: newUrl, replace: true }));
+          return null;
+        }
+
+        // disable some actions on the default home dashboard
+        dashDTO.meta.canSave = false;
+        dashDTO.meta.canShare = false;
+        dashDTO.meta.canStar = false;
+        return dashDTO;
+      }
+      case DashboardRouteInfo.Normal: {
+        // for old db routes we redirect
+        if (args.urlType === 'db') {
+          redirectToNewUrl(args.urlSlug, dispatch, getState().location.path);
+          return null;
+        }
+
+        const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv');
+        const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
+
+        if (args.fixUrl && dashDTO.meta.url) {
+          // check if the current url is correct (might be old slug)
+          const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
+          const currentPath = getState().location.path;
+
+          if (dashboardUrl !== currentPath) {
+            // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times.
+            dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true }));
+            return null;
+          }
+        }
+        return dashDTO;
+      }
+      case DashboardRouteInfo.New: {
+        return getNewDashboardModelData(args.urlFolderId);
+      }
+      default:
+        throw { message: 'Unknown route ' + args.routeInfo };
+    }
+  } catch (err) {
+    dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err }));
+    console.log(err);
+    return null;
+  }
+}
+
+/**
+ * This action (or saga) does everything needed to bootstrap a dashboard & dashboard model.
+ * First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates)
+ *
+ * This is used both for single dashboard & solo panel routes, home & new dashboard routes.
+ *
+ * Then it handles the initializing of the old angular services that the dashboard components & panels still depend on
+ *
+ */
+export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
+  return async (dispatch, getState) => {
+    // set fetching state
+    dispatch(dashboardInitFetching());
+
+    // Detect slow loading / initializing and set state flag
+    // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing
+    setTimeout(() => {
+      if (getState().dashboard.model === null) {
+        dispatch(dashboardInitSlow());
+      }
+    }, 500);
+
+    // fetch dashboard data
+    const dashDTO = await fetchDashboard(args, dispatch, getState);
+
+    // returns null if there was a redirect or error
+    if (!dashDTO) {
+      return;
+    }
+
+    // set initializing state
+    dispatch(dashboardInitServices());
+
+    // create model
+    let dashboard: DashboardModel;
+    try {
+      dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta);
+    } catch (err) {
+      dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err }));
+      console.log(err);
+      return;
+    }
+
+    // add missing orgId query param
+    const storeState = getState();
+    if (!storeState.location.query.orgId) {
+      dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true }));
+    }
+
+    // init services
+    const timeSrv: TimeSrv = args.$injector.get('timeSrv');
+    const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv');
+    const variableSrv: VariableSrv = args.$injector.get('variableSrv');
+    const keybindingSrv: KeybindingSrv = args.$injector.get('keybindingSrv');
+    const unsavedChangesSrv = args.$injector.get('unsavedChangesSrv');
+    const dashboardSrv: DashboardSrv = args.$injector.get('dashboardSrv');
+
+    timeSrv.init(dashboard);
+    annotationsSrv.init(dashboard);
+
+    // template values service needs to initialize completely before
+    // the rest of the dashboard can load
+    try {
+      await variableSrv.init(dashboard);
+    } catch (err) {
+      dispatch(notifyApp(createErrorNotification('Templating init failed', err)));
+      console.log(err);
+    }
+
+    try {
+      dashboard.processRepeats();
+      dashboard.updateSubmenuVisibility();
+
+      // handle auto fix experimental feature
+      const queryParams = getState().location.query;
+      if (queryParams.autofitpanels) {
+        dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
+      }
+
+      // init unsaved changes tracking
+      unsavedChangesSrv.init(dashboard, args.$scope);
+      keybindingSrv.setupDashboardBindings(args.$scope, dashboard);
+    } catch (err) {
+      dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));
+      console.log(err);
+    }
+
+    // legacy srv state
+    dashboardSrv.setCurrent(dashboard);
+    // yay we are done
+    dispatch(dashboardInitCompleted(dashboard));
+  };
+}
+
+function getNewDashboardModelData(urlFolderId?: string): any {
+  const data = {
+    meta: {
+      canStar: false,
+      canShare: false,
+      isNew: true,
+      folderId: 0,
+    },
+    dashboard: {
+      title: 'New dashboard',
+      panels: [
+        {
+          type: 'add-panel',
+          gridPos: { x: 0, y: 0, w: 12, h: 9 },
+          title: 'Panel Title',
+        },
+      ],
+    },
+  };
+
+  if (urlFolderId) {
+    data.meta.folderId = parseInt(urlFolderId, 10);
+  }
+
+  return data;
+}

+ 56 - 9
public/app/features/dashboard/state/reducers.test.ts

@@ -1,19 +1,23 @@
-import { Action, ActionTypes } from './actions';
-import { OrgRole, PermissionLevel, DashboardState } from 'app/types';
+import {
+  loadDashboardPermissions,
+  dashboardInitFetching,
+  dashboardInitCompleted,
+  dashboardInitFailed,
+  dashboardInitSlow,
+} from './actions';
+import { OrgRole, PermissionLevel, DashboardState, DashboardInitPhase } from 'app/types';
 import { initialState, dashboardReducer } from './reducers';
+import { DashboardModel } from './DashboardModel';
 
 describe('dashboard reducer', () => {
   describe('loadDashboardPermissions', () => {
     let state: DashboardState;
 
     beforeEach(() => {
-      const action: Action = {
-        type: ActionTypes.LoadDashboardPermissions,
-        payload: [
-          { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
-          { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
-        ],
-      };
+      const action = loadDashboardPermissions([
+        { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View },
+        { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
+      ]);
       state = dashboardReducer(initialState, action);
     });
 
@@ -21,4 +25,47 @@ describe('dashboard reducer', () => {
       expect(state.permissions.length).toBe(2);
     });
   });
+
+  describe('dashboardInitCompleted', () => {
+    let state: DashboardState;
+
+    beforeEach(() => {
+      state = dashboardReducer(initialState, dashboardInitFetching());
+      state = dashboardReducer(state, dashboardInitSlow());
+      state = dashboardReducer(state, dashboardInitCompleted(new DashboardModel({ title: 'My dashboard' })));
+    });
+
+    it('should set model', async () => {
+      expect(state.model.title).toBe('My dashboard');
+    });
+
+    it('should set reset isInitSlow', async () => {
+      expect(state.isInitSlow).toBe(false);
+    });
+  });
+
+  describe('dashboardInitFailed', () => {
+    let state: DashboardState;
+
+    beforeEach(() => {
+      state = dashboardReducer(initialState, dashboardInitFetching());
+      state = dashboardReducer(state, dashboardInitFailed({message: 'Oh no', error: 'sad'}));
+    });
+
+    it('should set model', async () => {
+      expect(state.model.title).toBe('Dashboard init failed');
+    });
+
+    it('should set reset isInitSlow', async () => {
+      expect(state.isInitSlow).toBe(false);
+    });
+
+    it('should set initError', async () => {
+      expect(state.initError.message).toBe('Oh no');
+    });
+
+    it('should set phase failed', async () => {
+      expect(state.initPhase).toBe(DashboardInitPhase.Failed);
+    });
+  });
 });

+ 78 - 9
public/app/features/dashboard/state/reducers.ts

@@ -1,21 +1,90 @@
-import { DashboardState } from 'app/types';
-import { Action, ActionTypes } from './actions';
+import { DashboardState, DashboardInitPhase } from 'app/types';
+import {
+  loadDashboardPermissions,
+  dashboardInitFetching,
+  dashboardInitSlow,
+  dashboardInitServices,
+  dashboardInitFailed,
+  dashboardInitCompleted,
+  cleanUpDashboard,
+} from './actions';
+import { reducerFactory } from 'app/core/redux';
 import { processAclItems } from 'app/core/utils/acl';
+import { DashboardModel } from './DashboardModel';
 
 export const initialState: DashboardState = {
+  initPhase: DashboardInitPhase.NotStarted,
+  isInitSlow: false,
+  model: null,
   permissions: [],
 };
 
-export const dashboardReducer = (state = initialState, action: Action): DashboardState => {
-  switch (action.type) {
-    case ActionTypes.LoadDashboardPermissions:
+export const dashboardReducer = reducerFactory(initialState)
+  .addMapper({
+    filter: loadDashboardPermissions,
+    mapper: (state, action) => ({
+      ...state,
+      permissions: processAclItems(action.payload),
+    }),
+  })
+  .addMapper({
+    filter: dashboardInitFetching,
+    mapper: state => ({
+      ...state,
+      initPhase: DashboardInitPhase.Fetching,
+    }),
+  })
+  .addMapper({
+    filter: dashboardInitServices,
+    mapper: state => ({
+      ...state,
+      initPhase: DashboardInitPhase.Services,
+    }),
+  })
+  .addMapper({
+    filter: dashboardInitSlow,
+    mapper: state => ({
+      ...state,
+      isInitSlow: true,
+    }),
+  })
+  .addMapper({
+    filter: dashboardInitFailed,
+    mapper: (state, action) => ({
+      ...state,
+      initPhase: DashboardInitPhase.Failed,
+      isInitSlow: false,
+      initError: action.payload,
+      model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }),
+    }),
+  })
+  .addMapper({
+    filter: dashboardInitCompleted,
+    mapper: (state, action) => ({
+      ...state,
+      initPhase: DashboardInitPhase.Completed,
+      model: action.payload,
+      isInitSlow: false,
+    }),
+  })
+  .addMapper({
+    filter: cleanUpDashboard,
+    mapper: (state, action) => {
+
+      // Destroy current DashboardModel
+      // Very important as this removes all dashboard event listeners
+      state.model.destroy();
+
       return {
         ...state,
-        permissions: processAclItems(action.payload),
+        initPhase: DashboardInitPhase.NotStarted,
+        model: null,
+        isInitSlow: false,
+        initError: null,
       };
-  }
-  return state;
-};
+    },
+  })
+  .create();
 
 export default {
   dashboard: dashboardReducer,

+ 28 - 22
public/app/features/explore/Explore.tsx

@@ -205,28 +205,34 @@ export class Explore extends React.PureComponent<ExploreProps> {
             <div className="explore-container">
               <QueryRows exploreEvents={this.exploreEvents} exploreId={exploreId} queryKeys={queryKeys} />
               <AutoSizer onResize={this.onResize} disableHeight>
-                {({ width }) => (
-                  <main className="m-t-2" style={{ width }}>
-                    <ErrorBoundary>
-                      {showingStartPage && <StartPage onClickExample={this.onClickExample} />}
-                      {!showingStartPage && (
-                        <>
-                          {supportsGraph && !supportsLogs && <GraphContainer width={width} exploreId={exploreId} />}
-                          {supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />}
-                          {supportsLogs && (
-                            <LogsContainer
-                              exploreId={exploreId}
-                              onChangeTime={this.onChangeTime}
-                              onClickLabel={this.onClickLabel}
-                              onStartScanning={this.onStartScanning}
-                              onStopScanning={this.onStopScanning}
-                            />
-                          )}
-                        </>
-                      )}
-                    </ErrorBoundary>
-                  </main>
-                )}
+                {({ width }) => {
+                  if (width === 0) {
+                    return null;
+                  }
+
+                  return (
+                    <main className="m-t-2" style={{ width }}>
+                      <ErrorBoundary>
+                        {showingStartPage && <StartPage onClickExample={this.onClickExample} />}
+                        {!showingStartPage && (
+                          <>
+                            {supportsGraph && !supportsLogs && <GraphContainer width={width} exploreId={exploreId} />}
+                            {supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />}
+                            {supportsLogs && (
+                              <LogsContainer
+                                exploreId={exploreId}
+                                onChangeTime={this.onChangeTime}
+                                onClickLabel={this.onClickLabel}
+                                onStartScanning={this.onStartScanning}
+                                onStopScanning={this.onStopScanning}
+                              />
+                            )}
+                          </>
+                        )}
+                      </ErrorBoundary>
+                    </main>
+                  );
+                }}
               </AutoSizer>
             </div>
           )}

+ 2 - 2
public/app/features/explore/ExploreToolbar.tsx

@@ -102,10 +102,10 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
           <div className="explore-toolbar-header">
             <div className="explore-toolbar-header-title">
               {exploreId === 'left' && (
-                <a className="navbar-page-btn">
+                <span className="navbar-page-btn">
                   <i className="fa fa-rocket fa-fw" />
                   Explore
-                </a>
+                </span>
               )}
             </div>
             <div className="explore-toolbar-header-close">

+ 2 - 3
public/app/features/panel/panel_ctrl.ts

@@ -7,6 +7,7 @@ import { Emitter } from 'app/core/core';
 import getFactors from 'app/core/utils/factors';
 import {
   duplicatePanel,
+  removePanel,
   copyPanel as copyPanelUtil,
   editPanelJson as editPanelJsonUtil,
   sharePanel as sharePanelUtil,
@@ -213,9 +214,7 @@ export class PanelCtrl {
   }
 
   removePanel() {
-    this.publishAppEvent('panel-remove', {
-      panelId: this.panel.id,
-    });
+    removePanel(this.dashboard, this.panel, true);
   }
 
   editPanelJson() {

+ 18 - 5
public/app/features/playlist/playlist_srv.ts

@@ -1,12 +1,16 @@
-import coreModule from '../../core/core_module';
-import kbn from 'app/core/utils/kbn';
-import appEvents from 'app/core/app_events';
+// Libraries
 import _ from 'lodash';
+
+// Utils
 import { toUrlParams } from 'app/core/utils/url';
+import coreModule from '../../core/core_module';
+import appEvents from 'app/core/app_events';
+import locationUtil from 'app/core/utils/location_util';
+import kbn from 'app/core/utils/kbn';
 
 export class PlaylistSrv {
   private cancelPromise: any;
-  private dashboards: Array<{ uri: string }>;
+  private dashboards: Array<{ url: string }>;
   private index: number;
   private interval: number;
   private startUrl: string;
@@ -36,7 +40,12 @@ export class PlaylistSrv {
     const queryParams = this.$location.search();
     const filteredParams = _.pickBy(queryParams, value => value !== null);
 
-    this.$location.url('dashboard/' + dash.uri + '?' + toUrlParams(filteredParams));
+    // this is done inside timeout to make sure digest happens after
+    // as this can be called from react
+    this.$timeout(() => {
+      const stripedUrl = locationUtil.stripBaseFromUrl(dash.url);
+      this.$location.url(stripedUrl  + '?' + toUrlParams(filteredParams));
+    });
 
     this.index++;
     this.cancelPromise = this.$timeout(() => this.next(), this.interval);
@@ -54,6 +63,8 @@ export class PlaylistSrv {
     this.index = 0;
     this.isPlaying = true;
 
+    appEvents.emit('playlist-started');
+
     return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
       return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
         this.dashboards = dashboards;
@@ -77,6 +88,8 @@ export class PlaylistSrv {
     if (this.cancelPromise) {
       this.$timeout.cancel(this.cancelPromise);
     }
+
+    appEvents.emit('playlist-stopped');
   }
 }
 

+ 2 - 6
public/app/features/playlist/specs/playlist_srv.test.ts

@@ -1,6 +1,6 @@
 import { PlaylistSrv } from '../playlist_srv';
 
-const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }];
+const dashboards = [{ url: 'dash1' }, { url: 'dash2' }];
 
 const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance<any> }] => {
   const mockBackendSrv = {
@@ -50,13 +50,12 @@ const mockWindowLocation = (): [jest.MockInstance<any>, () => void] => {
 
 describe('PlaylistSrv', () => {
   let srv: PlaylistSrv;
-  let mockLocationService: { url: jest.MockInstance<any> };
   let hrefMock: jest.MockInstance<any>;
   let unmockLocation: () => void;
   const initialUrl = 'http://localhost/playlist';
 
   beforeEach(() => {
-    [srv, mockLocationService] = createPlaylistSrv();
+    [srv] = createPlaylistSrv();
     [hrefMock, unmockLocation] = mockWindowLocation();
 
     // This will be cached in the srv when start() is called
@@ -71,7 +70,6 @@ describe('PlaylistSrv', () => {
     await srv.start(1);
 
     for (let i = 0; i < 6; i++) {
-      expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
       srv.next();
     }
 
@@ -84,7 +82,6 @@ describe('PlaylistSrv', () => {
 
     // 1 complete loop
     for (let i = 0; i < 3; i++) {
-      expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
       srv.next();
     }
 
@@ -93,7 +90,6 @@ describe('PlaylistSrv', () => {
 
     // Another 2 loops
     for (let i = 0; i < 4; i++) {
-      expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
       srv.next();
     }
 

+ 14 - 0
public/app/features/profile/state/reducers.ts

@@ -0,0 +1,14 @@
+import { UserState } from 'app/types';
+import config from 'app/core/config';
+
+export const initialState: UserState = {
+  orgId: config.bootData.user.orgId,
+};
+
+export const userReducer = (state = initialState, action: any): UserState => {
+  return state;
+};
+
+export default {
+  user: userReducer,
+};

+ 0 - 1
public/app/features/templating/specs/variable_srv.test.ts

@@ -48,7 +48,6 @@ describe('VariableSrv', function(this: any) {
         ds.metricFindQuery = () => Promise.resolve(scenario.queryResult);
 
         ctx.variableSrv = new VariableSrv(
-          ctx.$rootScope,
           $q,
           ctx.$location,
           ctx.$injector,

+ 1 - 5
public/app/features/templating/specs/variable_srv_init.test.ts

@@ -25,10 +25,6 @@ describe('VariableSrv init', function(this: any) {
   };
 
   const $injector = {} as any;
-  const $rootscope = {
-    $on: () => {},
-  };
-
   let ctx = {} as any;
 
   function describeInitScenario(desc, fn) {
@@ -54,7 +50,7 @@ describe('VariableSrv init', function(this: any) {
         };
 
         // @ts-ignore
-        ctx.variableSrv = new VariableSrv($rootscope, $q, {}, $injector, templateSrv, timeSrv);
+        ctx.variableSrv = new VariableSrv($q, {}, $injector, templateSrv, timeSrv);
 
         $injector.instantiate = (variable, model) => {
           return getVarMockConstructor(variable, model, ctx);

+ 5 - 5
public/app/features/templating/variable_srv.ts

@@ -18,18 +18,18 @@ export class VariableSrv {
   variables: any[];
 
   /** @ngInject */
-  constructor(private $rootScope,
-              private $q,
+  constructor(private $q,
               private $location,
               private $injector,
               private templateSrv: TemplateSrv,
               private timeSrv: TimeSrv) {
-    $rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope);
+
   }
 
   init(dashboard: DashboardModel) {
     this.dashboard = dashboard;
     this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this));
+    this.dashboard.events.on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this));
 
     // create working class models representing variables
     this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this));
@@ -59,7 +59,7 @@ export class VariableSrv {
 
       return variable.updateOptions().then(() => {
         if (angular.toJson(previousOptions) !== angular.toJson(variable.options)) {
-          this.$rootScope.$emit('template-variable-value-updated');
+          this.dashboard.templateVariableValueUpdated();
         }
       });
     });
@@ -144,7 +144,7 @@ export class VariableSrv {
 
     return this.$q.all(promises).then(() => {
       if (emitChangeEvents) {
-        this.$rootScope.appEvent('template-variable-value-updated');
+        this.dashboard.templateVariableValueUpdated();
         this.dashboard.startRefresh();
       }
     });

+ 0 - 17
public/app/partials/dashboard.html

@@ -1,17 +0,0 @@
-<div dash-class ng-if="ctrl.dashboard">
-	<dashnav dashboard="ctrl.dashboard"></dashnav>
-
-	<div class="scroll-canvas scroll-canvas--dashboard" page-scrollbar>
-    <dashboard-settings dashboard="ctrl.dashboard"
-                        ng-if="ctrl.dashboardViewState.state.editview"
-                        class="dashboard-settings">
-    </dashboard-settings>
-
-		<div class="dashboard-container" ng-class="{'dashboard-container--has-submenu': ctrl.dashboard.meta.submenuEnabled}">
-      <dashboard-submenu ng-if="ctrl.dashboard.meta.submenuEnabled" dashboard="ctrl.dashboard">
-      </dashboard-submenu>
-
-      <dashboard-grid dashboard="ctrl.dashboard"></dashboard-grid>
-    </div>
-  </div>
-</div>

+ 17 - 18
public/app/routes/GrafanaCtrl.ts

@@ -1,9 +1,11 @@
-import config from 'app/core/config';
+// Libraries
 import _ from 'lodash';
 import $ from 'jquery';
 import Drop from 'tether-drop';
-import { colors } from '@grafana/ui';
 
+// Utils and servies
+import { colors } from '@grafana/ui';
+import config from 'app/core/config';
 import coreModule from 'app/core/core_module';
 import { profiler } from 'app/core/profiler';
 import appEvents from 'app/core/app_events';
@@ -13,6 +15,9 @@ import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource
 import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
 import { configureStore } from 'app/store/configureStore';
 
+// Types
+import { KioskUrlValue } from 'app/types';
+
 export class GrafanaCtrl {
   /** @ngInject */
   constructor(
@@ -46,11 +51,6 @@ export class GrafanaCtrl {
 
     $rootScope.colors = colors;
 
-    $scope.initDashboard = (dashboardData, viewScope) => {
-      $scope.appEvent('dashboard-fetch-end', dashboardData);
-      $controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData);
-    };
-
     $rootScope.onAppEvent = function(name, callback, localScope) {
       const unbind = $rootScope.$on(name, callback);
       let callerScope = this;
@@ -72,7 +72,7 @@ export class GrafanaCtrl {
   }
 }
 
-function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) {
+function setViewModeBodyClass(body, mode: KioskUrlValue, sidemenuOpen: boolean) {
   body.removeClass('view-mode--tv');
   body.removeClass('view-mode--kiosk');
   body.removeClass('view-mode--inactive');
@@ -126,12 +126,13 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
         body.toggleClass('sidemenu-hidden');
       });
 
-      scope.$watch(
-        () => playlistSrv.isPlaying,
-        newValue => {
-          elem.toggleClass('view-mode--playlist', newValue === true);
-        }
-      );
+      appEvents.on('playlist-started', () => {
+        elem.toggleClass('view-mode--playlist', true);
+      });
+
+      appEvents.on('playlist-stopped', () => {
+        elem.toggleClass('view-mode--playlist', false);
+      });
 
       // check if we are in server side render
       if (document.cookie.indexOf('renderKey') !== -1) {
@@ -165,6 +166,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
         for (const drop of Drop.drops) {
           drop.destroy();
         }
+
+        appEvents.emit('hide-dash-search');
       });
 
       // handle kiosk mode
@@ -262,10 +265,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
           }, 100);
         }
 
-        if (target.parents('.navbar-buttons--playlist').length === 0) {
-          playlistSrv.stop();
-        }
-
         // hide search
         if (body.find('.search-container').length > 0) {
           if (target.parents('.search-results-container, .search-field-wrapper').length === 0) {

Some files were not shown because too many files changed in this diff