diff --git a/.gitignore b/.gitignore
index d599f762840..2945746832a 100644
--- a/.gitignore
+++ b/.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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4cf2262f7d2..7b97da0e81c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,39 @@
-# 6.0.0-beta2 (unreleased)
+# 6.0.0-beta3 (unreleased)
+
+# 6.0.0-beta2 (2019-02-11)
+
+### New Features
+* **AzureMonitor**: Enable alerting by converting Azure Monitor API to Go [#14623](https://github.com/grafana/grafana/issues/14623)
### Minor
-* **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
+* **Alerting**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
+* **Graphite/InfluxDB/OpenTSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges [#15284](https://github.com/grafana/grafana/issues/15284)
* **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182)
* **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock)
* **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson)
+* **Cloudwatch**: Add AWS/EC2/API metrics [#14233](https://github.com/grafana/grafana/issues/14233), 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)
+* **MySQL**: Adds datasource SSL CA/client certificates support [#8570](https://github.com/grafana/grafana/issues/8570), thx [@bugficks](https://github.com/bugficks)
+* **MSSQL**: Timerange are now passed for template variable queries [#13324](https://github.com/grafana/grafana/issues/13324), thx [@thatsparesh](https://github.com/thatsparesh)
+* **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)
+* **Login**: Anonymous usage stats for token auth [#15288](https://github.com/grafana/grafana/issues/15288)
+* **AzureMonitor**: improve autocomplete for Log Analytics and App Insights editor [#15131](https://github.com/grafana/grafana/issues/15131)
+* **LDAP**: Fix IPA/FreeIPA v4.6.4 does not allow LDAP searches with empty attributes [#14432](https://github.com/grafana/grafana/issues/14432)
+
+### Breaking changes
+
+* **Internal Metrics** Edition has been added to the build_info metric. This will break any Graphite queries using this metric. Edition will be a new label for the Prometheus metric. [#15363](https://github.com/grafana/grafana/pull/15363)
+
+### 6.0.0-beta1 fixes
+
+* **Postgres**: Fix default port not added when port not configured [#15189](https://github.com/grafana/grafana/issues/15189)
+* **Alerting**: Fixes crash bug when alert notifier folders are missing [#15295](https://github.com/grafana/grafana/issues/15295)
+* **Dashboard**: Fix save provisioned dashboard modal [#15219](https://github.com/grafana/grafana/pull/15219)
+* **Dashboard**: Fix having a long query in prometheus dashboard query editor blocks 30% of the query field when on OSX and having native scrollbars [#15122](https://github.com/grafana/grafana/issues/15122)
+* **Explore**: Fix issue with wrapping on long queries [#15222](https://github.com/grafana/grafana/issues/15222)
+* **Explore**: Fix cut & paste adds newline before and after selection [#15223](https://github.com/grafana/grafana/issues/15223)
+* **Dataproxy**: Fix global datasource proxy timeout not added to correct http client [#15258](https://github.com/grafana/grafana/issues/15258) [#5699](https://github.com/grafana/grafana/issues/5699)
# 6.0.0-beta1 (2019-01-30)
@@ -85,7 +113,7 @@
* **Stackdriver**: Fixes issue with data proxy and Authorization header [#14262](https://github.com/grafana/grafana/issues/14262)
* **Units**: fixedUnit for Flow:l/min and mL/min [#14294](https://github.com/grafana/grafana/issues/14294), thx [@flopp999](https://github.com/flopp999).
* **Logging**: Fix for issue where data proxy logged a secret when debug logging was enabled, now redacted. [#14319](https://github.com/grafana/grafana/issues/14319)
-* **InfluxDB**: Add support for alerting on InfluxDB queries that use the cumulative_sum function. [#14314](https://github.com/grafana/grafana/pull/14314), thx [@nitti](https://github.com/nitti)
+* TSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges**: Add support for alerting on InfluxDB queries that use the cumulative_sum function. [#14314](https://github.com/grafana/grafana/pull/14314), thx [@nitti](https://github.com/nitti)
* **Plugins**: Panel plugins should no receive the panel-initialized event again as usual.
* **Embedded Graphs**: Iframe graph panels should now work as usual. [#14284](https://github.com/grafana/grafana/issues/14284)
* **Postgres**: Improve PostgreSQL Query Editor if using different Schemas, [#14313](
@@ -1020,7 +1048,7 @@ Pull Request: [#8472](https://github.com/grafana/grafana/pull/8472)
* **Docs**: Added some details about Sessions in Postgres [#7694](https://github.com/grafana/grafana/pull/7694) thx [@rickard-von-essen](https://github.com/rickard-von-essen)
* **Influxdb**: Allow commas in template variables [#7681](https://github.com/grafana/grafana/issues/7681) thx [@thuck](https://github.com/thuck)
* **Cloudwatch**: stop using deprecated session.New() [#7736](https://github.com/grafana/grafana/issues/7736) thx [@mtanda](https://github.com/mtanda)
-* **OpenTSDB**: Pass dropcounter rate option if no max counter and no reset value or reset value as 0 is specified [#7743](https://github.com/grafana/grafana/pull/7743) thx [@r4um](https://github.com/r4um)
+*TSDB**: Fix always take dashboard timezone into consideration when handle custom time ranges**: Pass dropcounter rate option if no max counter and no reset value or reset value as 0 is specified [#7743](https://github.com/grafana/grafana/pull/7743) thx [@r4um](https://github.com/r4um)
* **Templating**: support full resolution for $interval variable [#7696](https://github.com/grafana/grafana/pull/7696) thx [@mtanda](https://github.com/mtanda)
* **Elasticsearch**: Unique Count on string fields in ElasticSearch [#3536](https://github.com/grafana/grafana/issues/3536), thx [@pyro2927](https://github.com/pyro2927)
* **Templating**: Data source template variable that refers to other variable in regex filter [#6365](https://github.com/grafana/grafana/issues/6365) thx [@rlodge](https://github.com/rlodge)
diff --git a/Dockerfile b/Dockerfile
index c3e59c8048e..9f07dc79c1d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -64,6 +64,7 @@ RUN mkdir -p "$GF_PATHS_HOME/.aws" && \
useradd -r -u $GF_UID -g grafana grafana && \
mkdir -p "$GF_PATHS_PROVISIONING/datasources" \
"$GF_PATHS_PROVISIONING/dashboards" \
+ "$GF_PATHS_PROVISIONING/notifiers" \
"$GF_PATHS_LOGS" \
"$GF_PATHS_PLUGINS" \
"$GF_PATHS_DATA" && \
diff --git a/README.md b/README.md
index 658f1e34257..2cb8bfee306 100644
--- a/README.md
+++ b/README.md
@@ -7,13 +7,18 @@
Grafana is an open source, feature rich metrics dashboard and graph editor for
Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB.
+
+
+Join us Feb 25-26 in Los Angeles, California for GrafanaCon - a two-day event with talks focused on Grafana and the surrounding open source monitoring ecosystem. Get deep dives into Loki, the Explore workflow and all of the new features of Grafana 6, plus participate in hands on workshops to help you get the most out of your data.
+
+Time is running out - grab your ticket now! http://grafanacon.org
+
+
## Installation
-Head to [docs.grafana.org](http://docs.grafana.org/installation/) and [download](https://grafana.com/get)
-the latest release.
-
-If you have any problems please read the [troubleshooting guide](http://docs.grafana.org/installation/troubleshooting/).
+Head to [docs.grafana.org](http://docs.grafana.org/installation/) for documentation or [download](https://grafana.com/get) to get the latest release.
## Documentation & Support
Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
diff --git a/conf/defaults.ini b/conf/defaults.ini
index d021d342fbf..a87aba10adb 100644
--- a/conf/defaults.ini
+++ b/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
diff --git a/conf/sample.ini b/conf/sample.ini
index ef677320686..dbbb3593f0f 100644
--- a/conf/sample.ini
+++ b/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
diff --git a/devenv/docker/blocks/elastic5/docker-compose.yaml b/devenv/docker/blocks/elastic5/docker-compose.yaml
index 33a7d9855b0..3a2ef39faba 100644
--- a/devenv/docker/blocks/elastic5/docker-compose.yaml
+++ b/devenv/docker/blocks/elastic5/docker-compose.yaml
@@ -1,6 +1,3 @@
-# You need to run 'sysctl -w vm.max_map_count=262144' on the host machine
-version: '2'
-services:
elasticsearch5:
image: elasticsearch:5
command: elasticsearch
diff --git a/devenv/docker/blocks/freeipa/docker-compose.yaml b/devenv/docker/blocks/freeipa/docker-compose.yaml
new file mode 100644
index 00000000000..8a9a5705f9d
--- /dev/null
+++ b/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
diff --git a/devenv/docker/blocks/freeipa/ldap_freeipa.toml b/devenv/docker/blocks/freeipa/ldap_freeipa.toml
new file mode 100644
index 00000000000..358b7cdebf9
--- /dev/null
+++ b/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"
diff --git a/devenv/docker/blocks/freeipa/notes.md b/devenv/docker/blocks/freeipa/notes.md
new file mode 100644
index 00000000000..76afdf913c8
--- /dev/null
+++ b/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
+```
diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml
index 504ee86404d..8087894c56e 100644
--- a/devenv/docker/ha_test/docker-compose.yaml
+++ b/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
diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml b/devenv/docker/ha_test/grafana/provisioning/dashboards/dashboards.yaml
similarity index 55%
rename from devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml
rename to devenv/docker/ha_test/grafana/provisioning/dashboards/dashboards.yaml
index 60b6cd4bb04..ad85bb7036f 100644
--- a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml
+++ b/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
diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json b/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json
new file mode 100644
index 00000000000..d072e4c1d28
--- /dev/null
+++ b/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
+}
\ No newline at end of file
diff --git a/devenv/docker/ha_test/prometheus/prometheus.yml b/devenv/docker/ha_test/prometheus/prometheus.yml
index ea97ba8ba05..0950bad6f7d 100644
--- a/devenv/docker/ha_test/prometheus/prometheus.yml
+++ b/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
\ No newline at end of file
+ - job_name: 'mysql'
+ dns_sd_configs:
+ - names:
+ - 'mysqld-exporter'
+ type: 'A'
+ port: 9104
+ refresh_interval: 10s
\ No newline at end of file
diff --git a/devenv/docker/loadtest/README.md b/devenv/docker/loadtest/README.md
index 8e724637acb..ca70a77dc74 100644
--- a/devenv/docker/loadtest/README.md
+++ b/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
diff --git a/devenv/docker/loadtest/auth_token_test.js b/devenv/docker/loadtest/auth_token_test.js
index e1356fb6f9a..2742f24e0e1 100644
--- a/devenv/docker/loadtest/auth_token_test.js
+++ b/devenv/docker/loadtest/auth_token_test.js
@@ -65,7 +65,7 @@ export default (data) => {
}
});
- sleep(1)
+ sleep(5)
}
export const teardown = (data) => {}
diff --git a/devenv/docker/loadtest/run.sh b/devenv/docker/loadtest/run.sh
index 474d75383b6..9517edf5d74 100755
--- a/devenv/docker/loadtest/run.sh
+++ b/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 "$@"
diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md
index 0480ee88adc..1d0b0d89b3f 100644
--- a/docs/sources/auth/overview.md
+++ b/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.
diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md
index 783b17874e0..6ca10b2f8e8 100644
--- a/docs/sources/features/datasources/cloudwatch.md
+++ b/docs/sources/features/datasources/cloudwatch.md
@@ -214,6 +214,16 @@ Example `ec2_instance_attribute()` query
ec2_instance_attribute(us-east-1, Tags.Name, { "tag:Team": [ "sysops" ] })
```
+## Using json format template variables
+
+Some of query takes JSON format filter. Grafana support to interpolate template variable to JSON format string, it can use as filter string.
+
+If `env = 'production', 'staging'`, following query will return ARNs of EC2 instances which `Environment` tag is `production` or `staging`.
+
+```
+resource_arns(us-east-1, ec2:instance, {"Environment":${env:json}})
+```
+
## Cost
Amazon provides 1 million CloudWatch API requests each month at no additional charge. Past this,
diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md
index 6633714d77b..e1d2876f48a 100644
--- a/docs/sources/http_api/annotations.md
+++ b/docs/sources/http_api/annotations.md
@@ -97,7 +97,7 @@ Creates an annotation in the Grafana database. The `dashboardId` and `panelId` f
**Example Request**:
-```json
+```http
POST /api/annotations HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -115,7 +115,7 @@ Content-Type: application/json
**Example Response**:
-```json
+```http
HTTP/1.1 200
Content-Type: application/json
@@ -135,7 +135,7 @@ format (string with multiple tags being separated by a space).
**Example Request**:
-```json
+```http
POST /api/annotations/graphite HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -150,7 +150,7 @@ Content-Type: application/json
**Example Response**:
-```json
+```http
HTTP/1.1 200
Content-Type: application/json
@@ -164,11 +164,14 @@ Content-Type: application/json
`PUT /api/annotations/:id`
+Updates all properties of an annotation that matches the specified id. To only update certain property, consider using the [Patch Annotation](#patch-annotation) operation.
+
**Example Request**:
-```json
+```http
PUT /api/annotations/1141 HTTP/1.1
Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
Content-Type: application/json
{
@@ -180,6 +183,50 @@ Content-Type: application/json
}
```
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "message":"Annotation updated"
+}
+```
+
+## Patch Annotation
+
+`PATCH /api/annotations/:id`
+
+Updates one or more properties of an annotation that matches the specified id.
+
+This operation currently supports updating of the `text`, `tags`, `time` and `timeEnd` properties. It does not handle updating of the `isRegion` and `regionId` properties. To make an annotation regional or vice versa, consider using the [Update Annotation](#update-annotation) operation.
+
+**Example Request**:
+
+```http
+PATCH /api/annotations/1145 HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+Content-Type: application/json
+
+{
+ "text":"New Annotation Description",
+ "tags":["tag6","tag7","tag8"]
+}
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "message":"Annotation patched"
+}
+```
+
## Delete Annotation By Id
`DELETE /api/annotations/:id`
@@ -201,7 +248,9 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
HTTP/1.1 200
Content-Type: application/json
-{"message":"Annotation deleted"}
+{
+ "message":"Annotation deleted"
+}
```
## Delete Annotation By RegionId
@@ -225,5 +274,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
HTTP/1.1 200
Content-Type: application/json
-{"message":"Annotation region deleted"}
+{
+ "message":"Annotation region deleted"
+}
```
diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md
index ac3dc6ebfd0..d85e39146dc 100644
--- a/docs/sources/installation/configuration.md
+++ b/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`.
+
## [users]
diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md
index 7426877654b..bf3fbd6a229 100644
--- a/docs/sources/reference/templating.md
+++ b/docs/sources/reference/templating.md
@@ -50,6 +50,7 @@ Filter Option | Example | Raw | Interpolated | Description
`regex` | ${servers:regex} | `'test.', 'test2'` | (test\.|test2) | Formats multi-value variable into a regex string
`pipe` | ${servers:pipe} | `'test.', 'test2'` | test.|test2 | Formats multi-value variable into a pipe-separated string
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
+`json`| ${servers:json} | `'test1', 'test2'` | `["test1","test2"]` | Formats multi-value variable as a JSON string
`distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
`lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
diff --git a/package.json b/package.json
index 77fd92baf57..f004ee07732 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
- "version": "6.0.0-prebeta2",
+ "version": "6.0.0-pre3",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"
@@ -27,6 +27,7 @@
"@types/react-dom": "^16.0.9",
"@types/react-grid-layout": "^0.16.6",
"@types/react-select": "^2.0.4",
+ "@types/react-transition-group": "^2.0.15",
"@types/react-virtualized": "^9.18.12",
"angular-mocks": "1.6.6",
"autoprefixer": "^6.4.0",
@@ -67,7 +68,7 @@
"husky": "^0.14.3",
"jest": "^23.6.0",
"jest-date-mock": "^1.0.6",
- "lint-staged": "^6.0.0",
+ "lint-staged": "^8.1.3",
"load-grunt-tasks": "3.5.2",
"mini-css-extract-plugin": "^0.4.0",
"mocha": "^4.0.1",
@@ -85,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",
diff --git a/packages/grafana-build/package.json b/packages/grafana-build/package.json
index 24fb648c8d4..056e5d2c7ea 100644
--- a/packages/grafana-build/package.json
+++ b/packages/grafana-build/package.json
@@ -8,6 +8,6 @@
"tslint": "echo \"Nothing to do\"",
"typecheck": "echo \"Nothing to do\""
},
- "author": "",
- "license": "ISC"
+ "author": "Grafana Labs",
+ "license": "Apache-2.0"
}
diff --git a/packages/grafana-ui/.storybook/config.ts b/packages/grafana-ui/.storybook/config.ts
index 9e50c6b501a..434e717bbab 100644
--- a/packages/grafana-ui/.storybook/config.ts
+++ b/packages/grafana-ui/.storybook/config.ts
@@ -1,10 +1,15 @@
-import { configure } from '@storybook/react';
+import { configure, addDecorator } from '@storybook/react';
+import { withKnobs } from '@storybook/addon-knobs';
+import { withTheme } from '../src/utils/storybook/withTheme';
import '../../../public/sass/grafana.light.scss';
// automatically import all files ending in *.stories.tsx
const req = require.context('../src/components', true, /.story.tsx$/);
+addDecorator(withKnobs);
+addDecorator(withTheme);
+
function loadStories() {
req.keys().forEach(req);
}
diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js
index 44de73a1e18..307a1142a7d 100644
--- a/packages/grafana-ui/.storybook/webpack.config.js
+++ b/packages/grafana-ui/.storybook/webpack.config.js
@@ -1,7 +1,6 @@
const path = require('path');
module.exports = (baseConfig, env, config) => {
-
config.module.rules.push({
test: /\.(ts|tsx)$/,
use: [
@@ -33,7 +32,12 @@ module.exports = (baseConfig, env, config) => {
config: { path: __dirname + '../../../../scripts/webpack/postcss.config.js' },
},
},
- { loader: 'sass-loader', options: { sourceMap: false } },
+ {
+ loader: 'sass-loader',
+ options: {
+ sourceMap: false
+ },
+ },
],
});
@@ -52,5 +56,9 @@ module.exports = (baseConfig, env, config) => {
});
config.resolve.extensions.push('.ts', '.tsx');
+
+ // Remove pure js loading rules as Storybook's Babel config is causing problems when mixing ES6 and CJS
+ // More about the problem we encounter: https://github.com/webpack/webpack/issues/4039
+ config.module.rules = config.module.rules.filter(rule => rule.test.toString() !== /\.(mjs|jsx?)$/.toString());
return config;
};
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index 0d1b14a7150..a0c76f711af 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -8,8 +8,8 @@
"typecheck": "tsc --noEmit",
"storybook": "start-storybook -p 9001 -c .storybook -s ../../public"
},
- "author": "",
- "license": "ISC",
+ "author": "Grafana Labs",
+ "license": "Apache-2.0",
"dependencies": {
"@torkelo/react-select": "2.1.1",
"@types/react-color": "^2.14.0",
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx
index 19ae2fda978..1fb31e86d72 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx
@@ -1,46 +1,43 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
-import { withKnobs, boolean } from '@storybook/addon-knobs';
+import { boolean } from '@storybook/addon-knobs';
import { SeriesColorPicker, ColorPicker } from './ColorPicker';
import { action } from '@storybook/addon-actions';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
-import { getThemeKnob } from '../../utils/storybook/themeKnob';
+import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
const getColorPickerKnobs = () => {
return {
- selectedTheme: getThemeKnob(),
enableNamedColors: boolean('Enable named colors', false),
};
};
const ColorPickerStories = storiesOf('UI/ColorPicker/Pickers', module);
-ColorPickerStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
+ColorPickerStories.addDecorator(withCenteredStory);
ColorPickerStories.add('default', () => {
- const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
+ const { enableNamedColors } = getColorPickerKnobs();
+
return (
{(selectedColor, updateSelectedColor) => {
- return (
- {
- action('Color changed')(color);
- updateSelectedColor(color);
- }}
- theme={selectedTheme || undefined}
- />
- );
+ return renderComponentWithTheme(ColorPicker, {
+ enableNamedColors,
+ color: selectedColor,
+ onChange: (color: any) => {
+ action('Color changed')(color);
+ updateSelectedColor(color);
+ },
+ });
}}
);
});
ColorPickerStories.add('Series color picker', () => {
- const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
+ const { enableNamedColors } = getColorPickerKnobs();
return (
@@ -52,7 +49,6 @@ ColorPickerStories.add('Series color picker', () => {
onToggleAxis={() => {}}
color={selectedColor}
onChange={color => updateSelectedColor(color)}
- theme={selectedTheme || undefined}
>
Open color picker
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
index b6cf176a24b..a48ecc44c45 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
@@ -1,12 +1,12 @@
import React, { Component, createRef } from 'react';
import PopperController from '../Tooltip/PopperController';
-import Popper, { RenderPopperArrowFn } from '../Tooltip/Popper';
+import Popper from '../Tooltip/Popper';
import { ColorPickerPopover } from './ColorPickerPopover';
-import { Themeable, GrafanaTheme } from '../../types';
+import { Themeable } from '../../types';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
import propDeprecationWarning from '../../utils/propDeprecationWarning';
-
+import { withTheme } from '../../themes/ThemeContext';
type ColorPickerChangeHandler = (color: string) => void;
export interface ColorPickerProps extends Themeable {
@@ -18,7 +18,6 @@ export interface ColorPickerProps extends Themeable {
*/
onColorChange?: ColorPickerChangeHandler;
enableNamedColors?: boolean;
- withArrow?: boolean;
children?: JSX.Element;
}
@@ -32,7 +31,6 @@ export const warnAboutColorPickerPropsDeprecation = (componentName: string, prop
export const colorPickerFactory = (
popover: React.ComponentType,
displayName = 'ColorPicker',
- renderPopoverArrowFunction?: RenderPopperArrowFn
) => {
return class ColorPicker extends Component {
static displayName = displayName;
@@ -50,17 +48,7 @@ export const colorPickerFactory = (
...this.props,
onChange: this.handleColorChange,
});
- const { theme, withArrow, children } = this.props;
-
- const renderArrow: RenderPopperArrowFn = ({ arrowProps, placement }) => {
- return (
-
- );
- };
+ const { theme, children } = this.props;
return (
@@ -72,7 +60,6 @@ export const colorPickerFactory = (
{...popperProps}
referenceElement={this.pickerTriggerRef.current}
wrapperClassName="ColorPicker"
- renderArrow={withArrow && (renderPopoverArrowFunction || renderArrow)}
onMouseLeave={hidePopper}
onMouseEnter={showPopper}
/>
@@ -95,7 +82,7 @@ export const colorPickerFactory = (
@@ -110,5 +97,5 @@ export const colorPickerFactory = (
};
};
-export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker');
-export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker');
+export const ColorPicker = withTheme(colorPickerFactory(ColorPickerPopover, 'ColorPicker'));
+export const SeriesColorPicker = withTheme(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker'));
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx
index dc51819a413..d749588ee31 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx
@@ -1,40 +1,27 @@
-import React from 'react';
import { storiesOf } from '@storybook/react';
import { ColorPickerPopover } from './ColorPickerPopover';
-import { withKnobs } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
-import { getThemeKnob } from '../../utils/storybook/themeKnob';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
-
+import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
const ColorPickerPopoverStories = storiesOf('UI/ColorPicker/Popovers', module);
-ColorPickerPopoverStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
+ColorPickerPopoverStories.addDecorator(withCenteredStory);
ColorPickerPopoverStories.add('default', () => {
- const selectedTheme = getThemeKnob();
-
- return (
- {
- console.log(color);
- }}
- theme={selectedTheme || undefined}
- />
- );
+ return renderComponentWithTheme(ColorPickerPopover, {
+ color: '#BC67E6',
+ onChange: (color: any) => {
+ console.log(color);
+ },
+ });
});
ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => {
- const selectedTheme = getThemeKnob();
-
- return (
- {
- console.log(color);
- }}
- theme={selectedTheme || undefined}
- />
- );
+ return renderComponentWithTheme(SeriesColorPickerPopover, {
+ color: '#BC67E6',
+ onChange: (color: any) => {
+ console.log(color);
+ },
+ });
});
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx
index 28d66e7af86..444f0e658c8 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx
@@ -4,7 +4,8 @@ import { ColorPickerPopover } from './ColorPickerPopover';
import { getColorDefinitionByName, getNamedColorPalette } from '../../utils/namedColorsPalette';
import { ColorSwatch } from './NamedColorsGroup';
import { flatten } from 'lodash';
-import { GrafanaTheme } from '../../types';
+import { GrafanaThemeType } from '../../types';
+import { getTheme } from '../../themes';
const allColors = flatten(Array.from(getNamedColorPalette().values()));
@@ -14,7 +15,7 @@ describe('ColorPickerPopover', () => {
describe('rendering', () => {
it('should render provided color as selected if color provided by name', () => {
- const wrapper = mount( {}} />);
+ const wrapper = mount( {}} theme={getTheme()}/>);
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
@@ -24,7 +25,7 @@ describe('ColorPickerPopover', () => {
});
it('should render provided color as selected if color provided by hex', () => {
- const wrapper = mount( {}} />);
+ const wrapper = mount( {}} theme={getTheme()} />);
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
@@ -45,7 +46,7 @@ describe('ColorPickerPopover', () => {
it('should pass hex color value to onChange prop by default', () => {
wrapper = mount(
-
+
);
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
@@ -61,7 +62,7 @@ describe('ColorPickerPopover', () => {
enableNamedColors
color={BasicGreen.variants.dark}
onChange={onChangeSpy}
- theme={GrafanaTheme.Light}
+ theme={getTheme(GrafanaThemeType.Light)}
/>
);
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
index d2937a1caba..b4c77a5e373 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
@@ -2,9 +2,9 @@ import React from 'react';
import { NamedColorsPalette } from './NamedColorsPalette';
import { getColorName, getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { ColorPickerProps, warnAboutColorPickerPropsDeprecation } from './ColorPicker';
-import { GrafanaTheme } from '../../types';
import { PopperContentProps } from '../Tooltip/PopperController';
import SpectrumPalette from './SpectrumPalette';
+import { GrafanaThemeType } from '@grafana/ui';
export interface Props extends ColorPickerProps, PopperContentProps {
customPickers?: T;
@@ -43,7 +43,7 @@ export class ColorPickerPopover extends React
if (enableNamedColors) {
return changeHandler(color);
}
- changeHandler(getColorFromHexRgbOrName(color, theme));
+ changeHandler(getColorFromHexRgbOrName(color, theme.type));
};
handleTabChange = (tab: PickerType | keyof T) => {
@@ -58,7 +58,9 @@ export class ColorPickerPopover extends React
case 'spectrum':
return ;
case 'palette':
- return ;
+ return (
+
+ );
default:
return this.renderCustomPicker(activePicker);
}
@@ -88,11 +90,7 @@ export class ColorPickerPopover extends React
<>
{Object.keys(customPickers).map(key => {
return (
-
+
{customPickers[key].name}
);
@@ -103,21 +101,14 @@ export class ColorPickerPopover
extends React
render() {
const { theme } = this.props;
- const colorPickerTheme = theme || GrafanaTheme.Dark;
-
+ const colorPickerTheme = theme.type || GrafanaThemeType.Dark;
return (
-
+
Colors
-
+
Custom
{this.renderCustomPickerTabs()}
@@ -128,3 +119,4 @@ export class ColorPickerPopover
extends React
);
}
}
+
diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx
index 91c4f21642a..2b5f7bb9b57 100644
--- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx
@@ -1,8 +1,9 @@
import React, { FunctionComponent } from 'react';
-import { Themeable, GrafanaTheme } from '../../types';
+import { Themeable } from '../../types';
import { ColorDefinition, getColorForTheme } from '../../utils/namedColorsPalette';
import { Color } from 'csstype';
import { find, upperFirst } from 'lodash';
+import { selectThemeVariant } from '../../themes/selectThemeVariant';
type ColorChangeHandler = (color: ColorDefinition) => void;
@@ -28,7 +29,15 @@ export const ColorSwatch: FunctionComponent = ({
}) => {
const isSmall = variant === ColorSwatchVariant.Small;
const swatchSize = isSmall ? '16px' : '32px';
- const selectedSwatchBorder = theme === GrafanaTheme.Light ? '#ffffff' : '#1A1B1F';
+
+ const selectedSwatchBorder = selectThemeVariant(
+ {
+ light: theme.colors.white,
+ dark: theme.colors.black,
+ },
+ theme.type
+ );
+
const swatchStyles = {
width: swatchSize,
height: swatchSize,
@@ -76,7 +85,7 @@ const NamedColorsGroup: FunctionComponent = ({
key={primaryColor.name}
isSelected={primaryColor.name === selectedColor}
variant={ColorSwatchVariant.Large}
- color={getColorForTheme(primaryColor, theme)}
+ color={getColorForTheme(primaryColor, theme.type)}
label={upperFirst(primaryColor.hue)}
onClick={() => onColorSelect(primaryColor)}
theme={theme}
@@ -95,7 +104,7 @@ const NamedColorsGroup: FunctionComponent = ({
onColorSelect(color)}
theme={theme}
/>
diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx
index af5de3b2a2d..f4901b28bfd 100644
--- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx
@@ -2,8 +2,9 @@ import React from 'react';
import { storiesOf } from '@storybook/react';
import { NamedColorsPalette } from './NamedColorsPalette';
import { getColorName, getColorDefinitionByName } from '../../utils/namedColorsPalette';
-import { withKnobs, select } from '@storybook/addon-knobs';
+import { select } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
import { UseState } from '../../utils/storybook/UseState';
const BasicGreen = getColorDefinitionByName('green');
@@ -12,7 +13,7 @@ const LightBlue = getColorDefinitionByName('light-blue');
const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module);
-NamedColorsPaletteStories.addDecorator(withKnobs).addDecorator(withCenteredStory);
+NamedColorsPaletteStories.addDecorator(withCenteredStory);
NamedColorsPaletteStories.add('Named colors swatch - support for named colors', () => {
const selectedColor = select(
@@ -28,7 +29,10 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors',
return (
{(selectedColor, updateSelectedColor) => {
- return ;
+ return renderComponentWithTheme(NamedColorsPalette, {
+ color: selectedColor,
+ onChange: updateSelectedColor,
+ });
}}
);
@@ -45,7 +49,10 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors',
return (
{(selectedColor, updateSelectedColor) => {
- return ;
+ return renderComponentWithTheme(NamedColorsPalette, {
+ color: getColorName(selectedColor),
+ onChange: updateSelectedColor,
+ });
}}
);
diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx
index 171d26f5c56..7a1ba95e81d 100644
--- a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx
@@ -3,7 +3,8 @@ import { mount, ReactWrapper } from 'enzyme';
import { NamedColorsPalette } from './NamedColorsPalette';
import { ColorSwatch } from './NamedColorsGroup';
import { getColorDefinitionByName } from '../../utils';
-import { GrafanaTheme } from '../../types';
+import { getTheme } from '../../themes';
+import { GrafanaThemeType } from '../../types';
describe('NamedColorsPalette', () => {
@@ -17,18 +18,18 @@ describe('NamedColorsPalette', () => {
});
it('should render provided color variant specific for theme', () => {
- wrapper = mount( {}} />);
+ wrapper = mount( {}} />);
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
wrapper.unmount();
- wrapper = mount( {}} />);
+ wrapper = mount( {}} />);
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light);
});
it('should render dar variant of provided color when theme not provided', () => {
- wrapper = mount( {}} />);
+ wrapper = mount( {}} theme={getTheme()}/>);
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
});
diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
index 3fa7a1f4a45..5fdad9c43cf 100644
--- a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
@@ -4,6 +4,7 @@ import { ColorPickerPopover } from './ColorPickerPopover';
import { ColorPickerProps } from './ColorPicker';
import { PopperContentProps } from '../Tooltip/PopperController';
import { Switch } from '../Switch/Switch';
+import { withTheme } from '../../themes/ThemeContext';
export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperContentProps {
yaxis?: number;
@@ -12,7 +13,6 @@ export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperC
export const SeriesColorPickerPopover: FunctionComponent = props => {
const { yaxis, onToggleAxis, color, ...colorPickerProps } = props;
-
return (
@@ -85,3 +85,6 @@ export class AxisSelector extends React.PureComponent {
- const selectedTheme = getThemeKnob();
-
return (
{(selectedColor, updateSelectedColor) => {
- return ;
+ return renderComponentWithTheme(SpectrumPalette, { color: selectedColor, onChange: updateSelectedColor });
}}
);
diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx
index cf001cf5629..0661821f3da 100644
--- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx
@@ -13,7 +13,7 @@ export interface SpectrumPaletteProps extends Themeable {
onChange: (color: string) => void;
}
-const renderPointer = (theme?: GrafanaTheme) => (props: SpectrumPalettePointerProps) => (
+const renderPointer = (theme: GrafanaTheme) => (props: SpectrumPalettePointerProps) => (
);
@@ -92,7 +92,7 @@ const SpectrumPalette: React.FunctionComponent = ({ color,
}}
theme={theme}
/>
-
+
);
};
diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx
index d0b2cbc4bff..7e3b2cf06a3 100644
--- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx
@@ -1,14 +1,12 @@
import React from 'react';
-import { GrafanaTheme, Themeable } from '../../types';
+import { Themeable } from '../../types';
+import { selectThemeVariant } from '../../themes/selectThemeVariant';
export interface SpectrumPalettePointerProps extends Themeable {
direction?: string;
}
-const SpectrumPalettePointer: React.FunctionComponent
= ({
- theme,
- direction,
-}) => {
+const SpectrumPalettePointer: React.FunctionComponent = ({ theme, direction }) => {
const styles = {
picker: {
width: '16px',
@@ -17,7 +15,14 @@ const SpectrumPalettePointer: React.FunctionComponent ({
plot: jest.fn(),
@@ -24,6 +25,7 @@ const setup = (propOverrides?: object) => {
width: 300,
value: 25,
decimals: 0,
+ theme: getTheme()
};
Object.assign(props, propOverrides);
diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
index 04d89bf3f57..a7435a56b3c 100644
--- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx
+++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
@@ -1,13 +1,14 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
-import { ValueMapping, Threshold, BasicGaugeColor, GrafanaTheme } from '../../types';
+import { ValueMapping, Threshold, BasicGaugeColor, GrafanaThemeType } from '../../types';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
+import { Themeable } from '../../index';
type TimeSeriesValue = string | number | null;
-export interface Props {
+export interface Props extends Themeable {
decimals: number;
height: number;
valueMappings: ValueMapping[];
@@ -22,7 +23,6 @@ export interface Props {
unit: string;
width: number;
value: number;
- theme?: GrafanaTheme;
}
const FONT_SCALE = 1;
@@ -41,7 +41,7 @@ export class Gauge extends PureComponent {
thresholds: [],
unit: 'none',
stat: 'avg',
- theme: GrafanaTheme.Dark,
+ theme: GrafanaThemeType.Dark,
};
componentDidMount() {
@@ -77,19 +77,19 @@ export class Gauge extends PureComponent {
const { thresholds, theme } = this.props;
if (thresholds.length === 1) {
- return getColorFromHexRgbOrName(thresholds[0].color, theme);
+ return getColorFromHexRgbOrName(thresholds[0].color, theme.type);
}
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
if (atThreshold) {
- return getColorFromHexRgbOrName(atThreshold.color, theme);
+ return getColorFromHexRgbOrName(atThreshold.color, theme.type);
}
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
if (belowThreshold.length > 0) {
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
- return getColorFromHexRgbOrName(nearestThreshold.color, theme);
+ return getColorFromHexRgbOrName(nearestThreshold.color, theme.type);
}
return BasicGaugeColor.Red;
@@ -104,13 +104,13 @@ export class Gauge extends PureComponent {
return [
...thresholdsSortedByIndex.map(threshold => {
if (threshold.index === 0) {
- return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme) };
+ return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme.type) };
}
const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
- return { value: threshold.value, color: getColorFromHexRgbOrName(previousThreshold.color, theme) };
+ return { value: threshold.value, color: getColorFromHexRgbOrName(previousThreshold.color, theme.type) };
}),
- { value: maxValue, color: getColorFromHexRgbOrName(lastThreshold.color, theme) },
+ { value: maxValue, color: getColorFromHexRgbOrName(lastThreshold.color, theme.type) },
];
}
@@ -126,7 +126,8 @@ export class Gauge extends PureComponent {
const formattedValue = this.formatValue(value) as string;
const dimension = Math.min(width, height * 1.3);
- const backgroundColor = theme === GrafanaTheme.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
+ const backgroundColor = theme.type === GrafanaThemeType.Light ? 'rgb(230,230,230)' : theme.colors.dark3;
+
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
const thresholdMarkersWidth = gaugeWidth / 5;
diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
index b5b815cf57c..4ce9c5264ea 100644
--- a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
+++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
@@ -29,14 +29,14 @@
&:hover {
.panel-options-group__add-circle {
- background-color: $btn-success-bg;
- color: $text-color-strong;
+ background-color: $btn-primary-bg;
+ color: $white;
}
}
}
.panel-options-group__add-circle {
- @include gradientBar($btn-success-bg, $btn-success-bg-hl, $text-color);
+ @include gradientBar($btn-success-bg, $btn-success-bg-hl, #fff);
border-radius: 50px;
width: 20px;
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
index 845ff5f6bf4..2b6af67df22 100644
--- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { ChangeEvent } from 'react';
import { shallow } from 'enzyme';
import { ThresholdsEditor, Props } from './ThresholdsEditor';
@@ -118,7 +118,7 @@ describe('change threshold value', () => {
];
const instance = setup({ thresholds });
- const mockEvent = { target: { value: 12 } };
+ const mockEvent = ({ target: { value: '12' } } as any) as ChangeEvent;
instance.onChangeThresholdValue(mockEvent, thresholds[0]);
@@ -137,7 +137,7 @@ describe('change threshold value', () => {
thresholds,
};
- const mockEvent = { target: { value: 78 } };
+ const mockEvent = ({ target: { value: '78' } } as any) as ChangeEvent;
instance.onChangeThresholdValue(mockEvent, thresholds[1]);
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
index c15f66cca54..f4db23d6656 100644
--- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
@@ -1,11 +1,11 @@
-import React, { PureComponent } from 'react';
-import { Threshold, Themeable } from '../../types';
+import React, { PureComponent, ChangeEvent } from 'react';
+import { Threshold } from '../../types';
import { ColorPicker } from '../ColorPicker/ColorPicker';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
import { colors } from '../../utils';
-import { getColorFromHexRgbOrName } from '@grafana/ui';
+import { getColorFromHexRgbOrName, ThemeContext } from '@grafana/ui';
-export interface Props extends Themeable {
+export interface Props {
thresholds: Threshold[];
onChange: (thresholds: Threshold[]) => void;
}
@@ -94,14 +94,15 @@ export class ThresholdsEditor extends PureComponent {
);
};
- onChangeThresholdValue = (event: any, threshold: Threshold) => {
+ onChangeThresholdValue = (event: ChangeEvent, threshold: Threshold) => {
if (threshold.index === 0) {
return;
}
const { thresholds } = this.state;
- const parsedValue = parseInt(event.target.value, 10);
- const value = isNaN(parsedValue) ? null : parsedValue;
+ const cleanValue = event.target.value.replace(/,/g, '.');
+ const parsedValue = parseFloat(cleanValue);
+ const value = isNaN(parsedValue) ? '' : parsedValue;
const newThresholds = thresholds.map(t => {
if (t === threshold && t.index !== 0) {
@@ -170,7 +171,8 @@ export class ThresholdsEditor extends PureComponent {
this.onChangeThresholdValue(event, threshold)}
value={value}
onBlur={this.onBlur}
@@ -188,27 +190,35 @@ export class ThresholdsEditor extends PureComponent
{
render() {
const { thresholds } = this.state;
- const { theme } = this.props;
return (
-
-
- {thresholds.map((threshold, index) => {
- return (
-
-
this.onAddThreshold(threshold.index + 1)}>
-
-
-
-
{this.renderInput(threshold)}
+
+ {theme => {
+ return (
+
+
+ {thresholds.map((threshold, index) => {
+ return (
+
+
this.onAddThreshold(threshold.index + 1)}
+ >
+
+
+
+
{this.renderInput(threshold)}
+
+ );
+ })}
- );
- })}
-
-
+
+ );
+ }}
+
);
}
}
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
index 200adfbfd75..8ef59bf08af 100644
--- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
@@ -21,7 +21,7 @@
}
.thresholds-row-add-button {
- @include buttonBackground($btn-success-bg, $btn-success-bg-hl, $text-color);
+ @include buttonBackground($btn-success-bg, $btn-success-bg-hl, #fff);
align-self: center;
margin-right: 5px;
@@ -34,7 +34,7 @@
cursor: pointer;
&:hover {
- color: $text-color-strong;
+ color: $white;
}
}
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index 5cd677761b0..dc435a8844d 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -14,8 +14,8 @@ export { FormLabel } from './FormLabel/FormLabel';
export { FormField } from './FormField/FormField';
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
-export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
-export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
+export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
+export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
export { Graph } from './Graph/Graph';
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts
index 974d976bbef..216f2f13bad 100644
--- a/packages/grafana-ui/src/index.ts
+++ b/packages/grafana-ui/src/index.ts
@@ -1,3 +1,5 @@
export * from './components';
export * from './types';
export * from './utils';
+export * from './themes';
+export * from './themes/ThemeContext';
diff --git a/packages/grafana-ui/src/themes/ThemeContext.tsx b/packages/grafana-ui/src/themes/ThemeContext.tsx
new file mode 100644
index 00000000000..a61a71d8af6
--- /dev/null
+++ b/packages/grafana-ui/src/themes/ThemeContext.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { GrafanaThemeType, Themeable } from '../types';
+import { getTheme } from './index';
+
+type Omit
= Pick>;
+type Subtract = Omit;
+
+// Use Grafana Dark theme by default
+export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark));
+
+export const withTheme = (Component: React.ComponentType
) => {
+ const WithTheme: React.FunctionComponent> = props => {
+ // @ts-ignore
+ return {theme => } ;
+ };
+
+ WithTheme.displayName = `WithTheme(${Component.displayName})`;
+
+ return WithTheme;
+};
diff --git a/packages/grafana-ui/src/themes/dark.ts b/packages/grafana-ui/src/themes/dark.ts
new file mode 100644
index 00000000000..deae022f63a
--- /dev/null
+++ b/packages/grafana-ui/src/themes/dark.ts
@@ -0,0 +1,69 @@
+import tinycolor from 'tinycolor2';
+import defaultTheme from './default';
+import { GrafanaTheme, GrafanaThemeType } from '../types/theme';
+
+const basicColors = {
+ black: '#000000',
+ white: '#ffffff',
+ dark1: '#141414',
+ dark2: '#1f1f20',
+ dark3: '#262628',
+ dark4: '#333333',
+ dark5: '#444444',
+ gray1: '#555555',
+ gray2: '#8e8e8e',
+ gray3: '#b3b3b3',
+ gray4: '#d8d9da',
+ gray5: '#ececec',
+ gray6: '#f4f5f8',
+ gray7: '#fbfbfb',
+ grayBlue: '#212327',
+ blue: '#33b5e5',
+ blueDark: '#005f81',
+ blueLight: '#00a8e6', // not used in dark theme
+ green: '#299c46',
+ red: '#d44a3a',
+ yellow: '#ecbb13',
+ pink: '#ff4444',
+ purple: '#9933cc',
+ variable: '#32d1df',
+ orange: '#eb7b18',
+};
+
+const darkTheme: GrafanaTheme = {
+ ...defaultTheme,
+ type: GrafanaThemeType.Dark,
+ name: 'Grafana Dark',
+ colors: {
+ ...basicColors,
+ inputBlack: '#09090b',
+ queryRed: '#e24d42',
+ queryGreen: '#74e680',
+ queryPurple: '#fe85fc',
+ queryKeyword: '#66d9ef',
+ queryOrange: 'eb7b18',
+ online: '#10a345',
+ warn: '#f79520',
+ critical: '#ed2e18',
+ bodyBg: '#171819',
+ pageBg: '#161719',
+ bodyColor: basicColors.gray4,
+ textColor: basicColors.gray4,
+ textColorStrong: basicColors.white,
+ textColorWeak: basicColors.gray2,
+ textColorEmphasis: basicColors.gray5,
+ textColorFaint: basicColors.dark5,
+ linkColor: new tinycolor(basicColors.white).darken(11).toString(),
+ linkColorDisabled: new tinycolor(basicColors.white).darken(11).toString(),
+ linkColorHover: basicColors.white,
+ linkColorExternal: basicColors.blue,
+ headingColor: new tinycolor(basicColors.white).darken(11).toString(),
+ },
+ background: {
+ dropdown: basicColors.dark3,
+ scrollbar: '#aeb5df',
+ scrollbar2: '#3a3a3a',
+ },
+};
+
+export default darkTheme;
diff --git a/packages/grafana-ui/src/themes/default.ts b/packages/grafana-ui/src/themes/default.ts
new file mode 100644
index 00000000000..bf318f526e7
--- /dev/null
+++ b/packages/grafana-ui/src/themes/default.ts
@@ -0,0 +1,62 @@
+
+
+const theme = {
+ name: 'Grafana Default',
+ typography: {
+ fontFamily: {
+ sansSerif: "'Roboto', Helvetica, Arial, sans-serif;",
+ serif: "Georgia, 'Times New Roman', Times, serif;",
+ monospace: "Menlo, Monaco, Consolas, 'Courier New', monospace;"
+ },
+ size: {
+ base: '13px',
+ xs: '10px',
+ s: '12px',
+ m: '14px',
+ l: '18px',
+ },
+ heading: {
+ h1: '2rem',
+ h2: '1.75rem',
+ h3: '1.5rem',
+ h4: '1.3rem',
+ h5: '1.2rem',
+ h6: '1rem',
+ },
+ weight: {
+ light: 300,
+ normal: 400,
+ semibold: 500,
+ },
+ lineHeight: {
+ xs: 1,
+ s: 1.1,
+ m: 4/3,
+ l: 1.5
+ }
+ },
+ brakpoints: {
+ xs: '0',
+ s: '544px',
+ m: '768px',
+ l: '992px',
+ xl: '1200px'
+ },
+ spacing: {
+ xs: '0',
+ s: '0.2rem',
+ m: '1rem',
+ l: '1.5rem',
+ xl: '3rem',
+ gutter: '30px',
+ },
+ border: {
+ radius: {
+ xs: '2px',
+ s: '3px',
+ m: '5px',
+ }
+ }
+};
+
+export default theme;
diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts
new file mode 100644
index 00000000000..c0d9a4f2d32
--- /dev/null
+++ b/packages/grafana-ui/src/themes/index.ts
@@ -0,0 +1,14 @@
+import darkTheme from './dark';
+import lightTheme from './light';
+import { GrafanaTheme } from '../types/theme';
+
+let themeMock: ((name?: string) => GrafanaTheme) | null;
+
+export let getTheme = (name?: string) => (themeMock && themeMock(name)) || (name === 'light' ? lightTheme : darkTheme);
+
+export const mockTheme = (mock: (name: string) => GrafanaTheme) => {
+ themeMock = mock;
+ return () => {
+ themeMock = null;
+ };
+};
diff --git a/packages/grafana-ui/src/themes/light.ts b/packages/grafana-ui/src/themes/light.ts
new file mode 100644
index 00000000000..fd1f1d05b95
--- /dev/null
+++ b/packages/grafana-ui/src/themes/light.ts
@@ -0,0 +1,70 @@
+import tinycolor from 'tinycolor2';
+import defaultTheme from './default';
+import { GrafanaTheme, GrafanaThemeType } from '../types/theme';
+
+const basicColors = {
+ black: '#000000',
+ white: '#ffffff',
+ dark1: '#13161d',
+ dark2: '#1e2028',
+ dark3: '#303133',
+ dark4: '#35373f',
+ dark5: '#41444b',
+ gray1: '#52545c',
+ gray2: '#767980',
+ gray3: '#acb6bf',
+ gray4: '#c7d0d9',
+ gray5: '#dde4ed',
+ gray6: '#e9edf2',
+ gray7: '#f7f8fa',
+ grayBlue: '#212327', // not used in light theme
+ blue: '#0083b3',
+ blueDark: '#005f81',
+ blueLight: '#00a8e6',
+ green: '#3aa655',
+ red: '#d44939',
+ yellow: '#ff851b',
+ pink: '#e671b8',
+ purple: '#9954bb',
+ variable: '#0083b3',
+ orange: '#ff7941',
+};
+
+const lightTheme: GrafanaTheme = {
+ ...defaultTheme,
+ type: GrafanaThemeType.Light,
+ name: 'Grafana Light',
+ colors: {
+ ...basicColors,
+ variable: basicColors.blue,
+ inputBlack: '#09090b',
+ queryRed: basicColors.red,
+ queryGreen: basicColors.green,
+ queryPurple: basicColors.purple,
+ queryKeyword: basicColors.blue,
+ queryOrange: basicColors.orange,
+ online: '#01a64f',
+ warn: '#f79520',
+ critical: '#ec2128',
+ bodyBg: basicColors.gray7,
+ pageBg: basicColors.gray7,
+ bodyColor: basicColors.gray1,
+ textColor: basicColors.gray1,
+ textColorStrong: basicColors.dark2,
+ textColorWeak: basicColors.gray2,
+ textColorEmphasis: basicColors.gray5,
+ textColorFaint: basicColors.dark4,
+ linkColor: basicColors.gray1,
+ linkColorDisabled: new tinycolor(basicColors.gray1).lighten(30).toString(),
+ linkColorHover: new tinycolor(basicColors.gray1).darken(20).toString(),
+ linkColorExternal: basicColors.blueLight,
+ headingColor: basicColors.gray1,
+ },
+ background: {
+ dropdown: basicColors.white,
+ scrollbar: basicColors.gray5,
+ scrollbar2: basicColors.gray5,
+ },
+};
+
+export default lightTheme;
diff --git a/packages/grafana-ui/src/themes/selectThemeVariant.test.ts b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts
new file mode 100644
index 00000000000..66cb02a2372
--- /dev/null
+++ b/packages/grafana-ui/src/themes/selectThemeVariant.test.ts
@@ -0,0 +1,52 @@
+import { GrafanaThemeType } from '../types/theme';
+import { selectThemeVariant } from './selectThemeVariant';
+import { mockTheme } from './index';
+
+const lightThemeMock = {
+ color: {
+ red: '#ff0000',
+ green: '#00ff00',
+ },
+};
+
+const darkThemeMock = {
+ color: {
+ red: '#ff0000',
+ green: '#00ff00',
+ },
+};
+
+describe('Theme variable variant selector', () => {
+ // @ts-ignore
+ const restoreTheme = mockTheme(name => (name === GrafanaThemeType.Light ? lightThemeMock : darkThemeMock));
+
+ afterAll(() => {
+ restoreTheme();
+ });
+ it('return correct variable value for given theme', () => {
+ const theme = lightThemeMock;
+
+ const selectedValue = selectThemeVariant(
+ {
+ dark: theme.color.red,
+ light: theme.color.green,
+ },
+ GrafanaThemeType.Light
+ );
+
+ expect(selectedValue).toBe(lightThemeMock.color.green);
+ });
+
+ it('return dark theme variant if no theme given', () => {
+ const theme = lightThemeMock;
+
+ const selectedValue = selectThemeVariant(
+ {
+ dark: theme.color.red,
+ light: theme.color.green,
+ }
+ );
+
+ expect(selectedValue).toBe(lightThemeMock.color.red);
+ });
+});
diff --git a/packages/grafana-ui/src/themes/selectThemeVariant.ts b/packages/grafana-ui/src/themes/selectThemeVariant.ts
new file mode 100644
index 00000000000..e7e8e780222
--- /dev/null
+++ b/packages/grafana-ui/src/themes/selectThemeVariant.ts
@@ -0,0 +1,9 @@
+import { GrafanaThemeType } from '../types/theme';
+
+type VariantDescriptor = {
+ [key in GrafanaThemeType]: string | number;
+};
+
+export const selectThemeVariant = (variants: VariantDescriptor, currentTheme?: GrafanaThemeType) => {
+ return variants[currentTheme || GrafanaThemeType.Dark];
+};
diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts
index e23b5e63af8..81bdf741f30 100644
--- a/packages/grafana-ui/src/types/index.ts
+++ b/packages/grafana-ui/src/types/index.ts
@@ -1,14 +1,7 @@
+
export * from './data';
export * from './time';
export * from './panel';
export * from './plugin';
export * from './datasource';
-
-export enum GrafanaTheme {
- Light = 'light',
- Dark = 'dark',
-}
-
-export interface Themeable {
- theme?: GrafanaTheme;
-}
+export * from './theme';
diff --git a/packages/grafana-ui/src/types/theme.ts b/packages/grafana-ui/src/types/theme.ts
new file mode 100644
index 00000000000..8a79658b423
--- /dev/null
+++ b/packages/grafana-ui/src/types/theme.ts
@@ -0,0 +1,129 @@
+export enum GrafanaThemeType {
+ Light = 'light',
+ Dark = 'dark',
+}
+
+export interface GrafanaTheme {
+ type: GrafanaThemeType;
+ name: string;
+ // TODO: not sure if should be a part of theme
+ brakpoints: {
+ xs: string;
+ s: string;
+ m: string;
+ l: string;
+ xl: string;
+ };
+ typography: {
+ fontFamily: {
+ sansSerif: string;
+ serif: string;
+ monospace: string;
+ };
+ size: {
+ base: string;
+ xs: string;
+ s: string;
+ m: string;
+ l: string;
+ };
+ weight: {
+ light: number;
+ normal: number;
+ semibold: number;
+ };
+ lineHeight: {
+ xs: number; //1
+ s: number; //1.1
+ m: number; // 4/3
+ l: number; // 1.5
+ };
+ // TODO: Refactor to use size instead of custom defs
+ heading: {
+ h1: string;
+ h2: string;
+ h3: string;
+ h4: string;
+ h5: string;
+ h6: string;
+ };
+ };
+ spacing: {
+ xs: string;
+ s: string;
+ m: string;
+ l: string;
+ gutter: string;
+ };
+ border: {
+ radius: {
+ xs: string;
+ s: string;
+ m: string;
+ };
+ };
+ background: {
+ dropdown: string;
+ scrollbar: string;
+ scrollbar2: string;
+ };
+ colors: {
+ black: string;
+ white: string;
+ dark1: string;
+ dark2: string;
+ dark3: string;
+ dark4: string;
+ dark5: string;
+ gray1: string;
+ gray2: string;
+ gray3: string;
+ gray4: string;
+ gray5: string;
+ gray6: string;
+ gray7: string;
+ grayBlue: string;
+ inputBlack: string;
+
+ // Accent colors
+ blue: string;
+ blueLight: string;
+ blueDark: string;
+ green: string;
+ red: string;
+ yellow: string;
+ pink: string;
+ purple: string;
+ variable: string;
+ orange: string;
+ queryRed: string;
+ queryGreen: string;
+ queryPurple: string;
+ queryKeyword: string;
+ queryOrange: string;
+
+ // Status colors
+ online: string;
+ warn: string;
+ critical: string;
+
+ // TODO: move to background section
+ bodyBg: string;
+ pageBg: string;
+ bodyColor: string;
+ textColor: string;
+ textColorStrong: string;
+ textColorWeak: string;
+ textColorFaint: string;
+ textColorEmphasis: string;
+ linkColor: string;
+ linkColorDisabled: string;
+ linkColorHover: string;
+ linkColorExternal: string;
+ headingColor: string;
+ };
+}
+
+export interface Themeable {
+ theme: GrafanaTheme;
+}
diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.test.ts b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
index c6a1aaf0dd0..19c7d9c84ad 100644
--- a/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
+++ b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
@@ -5,20 +5,20 @@ import {
getColorFromHexRgbOrName,
getColorDefinitionByName,
} from './namedColorsPalette';
-import { GrafanaTheme } from '../types/index';
+import { GrafanaThemeType } from '../types/index';
describe('colors', () => {
const SemiDarkBlue = getColorDefinitionByName('semi-dark-blue');
describe('getColorDefinition', () => {
it('returns undefined for unknown hex', () => {
- expect(getColorDefinition('#ff0000', GrafanaTheme.Light)).toBeUndefined();
- expect(getColorDefinition('#ff0000', GrafanaTheme.Dark)).toBeUndefined();
+ expect(getColorDefinition('#ff0000', GrafanaThemeType.Light)).toBeUndefined();
+ expect(getColorDefinition('#ff0000', GrafanaThemeType.Dark)).toBeUndefined();
});
it('returns definition for known hex', () => {
- expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue);
- expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue);
+ expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaThemeType.Light)).toEqual(SemiDarkBlue);
+ expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaThemeType.Dark)).toEqual(SemiDarkBlue);
});
});
@@ -28,8 +28,8 @@ describe('colors', () => {
});
it('returns name for known hex', () => {
- expect(getColorName(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue.name);
- expect(getColorName(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue.name);
+ expect(getColorName(SemiDarkBlue.variants.light, GrafanaThemeType.Light)).toEqual(SemiDarkBlue.name);
+ expect(getColorName(SemiDarkBlue.variants.dark, GrafanaThemeType.Dark)).toEqual(SemiDarkBlue.name);
});
});
@@ -44,8 +44,8 @@ describe('colors', () => {
});
describe('getColorFromHexRgbOrName', () => {
- it('returns undefined for unknown color', () => {
- expect(() => getColorFromHexRgbOrName('aruba-sunshine')).toThrow();
+ it('returns black for unknown color', () => {
+ expect(getColorFromHexRgbOrName('aruba-sunshine')).toBe("#000000");
});
it('returns dark hex variant for known color if theme not specified', () => {
@@ -53,14 +53,20 @@ describe('colors', () => {
});
it("returns correct variant's hex for known color if theme specified", () => {
- expect(getColorFromHexRgbOrName(SemiDarkBlue.name, GrafanaTheme.Light)).toBe(SemiDarkBlue.variants.light);
+ expect(getColorFromHexRgbOrName(SemiDarkBlue.name, GrafanaThemeType.Light)).toBe(SemiDarkBlue.variants.light);
});
it('returns color if specified as hex or rgb/a', () => {
expect(getColorFromHexRgbOrName('ff0000')).toBe('ff0000');
expect(getColorFromHexRgbOrName('#ff0000')).toBe('#ff0000');
+ expect(getColorFromHexRgbOrName('#FF0000')).toBe('#FF0000');
+ expect(getColorFromHexRgbOrName('#CCC')).toBe('#CCC');
expect(getColorFromHexRgbOrName('rgb(0,0,0)')).toBe('rgb(0,0,0)');
expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)');
});
+
+ it('returns hex for named color that is not a part of named colors palette', () => {
+ expect(getColorFromHexRgbOrName('lime')).toBe('#00ff00');
+ });
});
});
diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.ts b/packages/grafana-ui/src/utils/namedColorsPalette.ts
index 5312b27ad26..88ae510a6d8 100644
--- a/packages/grafana-ui/src/utils/namedColorsPalette.ts
+++ b/packages/grafana-ui/src/utils/namedColorsPalette.ts
@@ -1,5 +1,6 @@
import { flatten } from 'lodash';
-import { GrafanaTheme } from '../types';
+import { GrafanaThemeType } from '../types';
+import tinycolor from 'tinycolor2';
type Hue = 'green' | 'yellow' | 'red' | 'blue' | 'orange' | 'purple';
@@ -68,16 +69,16 @@ export const getColorDefinitionByName = (name: Color): ColorDefinition => {
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.name === name)[0];
};
-export const getColorDefinition = (hex: string, theme: GrafanaTheme): ColorDefinition | undefined => {
+export const getColorDefinition = (hex: string, theme: GrafanaThemeType): ColorDefinition | undefined => {
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.variants[theme] === hex)[0];
};
const isHex = (color: string) => {
- const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6})$/gi;
+ const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{3})$/gi;
return hexRegex.test(color);
};
-export const getColorName = (color?: string, theme?: GrafanaTheme): Color | undefined => {
+export const getColorName = (color?: string, theme?: GrafanaThemeType): Color | undefined => {
if (!color) {
return undefined;
}
@@ -86,7 +87,7 @@ export const getColorName = (color?: string, theme?: GrafanaTheme): Color | unde
return undefined;
}
if (isHex(color)) {
- const definition = getColorDefinition(color, theme || GrafanaTheme.Dark);
+ const definition = getColorDefinition(color, theme || GrafanaThemeType.Dark);
return definition ? definition.name : undefined;
}
@@ -98,7 +99,7 @@ export const getColorByName = (colorName: string) => {
return definition.length > 0 ? definition[0] : undefined;
};
-export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): string => {
+export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaThemeType): string => {
if (color.indexOf('rgb') > -1 || isHex(color)) {
return color;
}
@@ -106,20 +107,20 @@ export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): s
const colorDefinition = getColorByName(color);
if (!colorDefinition) {
- throw new Error('Unknown color');
+ return new tinycolor(color).toHexString();
}
return theme ? colorDefinition.variants[theme] : colorDefinition.variants.dark;
};
-export const getColorForTheme = (color: ColorDefinition, theme?: GrafanaTheme) => {
+export const getColorForTheme = (color: ColorDefinition, theme?: GrafanaThemeType) => {
return theme ? color.variants[theme] : color.variants.dark;
};
const buildNamedColorsPalette = () => {
const palette = new Map();
- const BasicGreen = buildColorDefinition('green', 'green', ['#56A64B', '#73BF69'], true);
+ const BasicGreen = buildColorDefinition('green', 'green', ['#56A64B', '#73BF69'], true);
const DarkGreen = buildColorDefinition('green', 'dark-green', ['#19730E', '#37872D']);
const SemiDarkGreen = buildColorDefinition('green', 'semi-dark-green', ['#37872D', '#56A64B']);
const LightGreen = buildColorDefinition('green', 'light-green', ['#73BF69', '#96D98D']);
diff --git a/packages/grafana-ui/src/utils/storybook/themeKnob.ts b/packages/grafana-ui/src/utils/storybook/themeKnob.ts
deleted file mode 100644
index a3733462bea..00000000000
--- a/packages/grafana-ui/src/utils/storybook/themeKnob.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { select } from '@storybook/addon-knobs';
-import { GrafanaTheme } from '../../types';
-
-export const getThemeKnob = (defaultTheme: GrafanaTheme = GrafanaTheme.Dark) => {
- return select(
- 'Theme',
- {
- Default: defaultTheme,
- Light: GrafanaTheme.Light,
- Dark: GrafanaTheme.Dark,
- },
- defaultTheme
- );
-};
diff --git a/packages/grafana-ui/src/utils/storybook/withTheme.tsx b/packages/grafana-ui/src/utils/storybook/withTheme.tsx
new file mode 100644
index 00000000000..5417af1de05
--- /dev/null
+++ b/packages/grafana-ui/src/utils/storybook/withTheme.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { RenderFunction } from '@storybook/react';
+import { ThemeContext } from '../../themes/ThemeContext';
+import { select } from '@storybook/addon-knobs';
+import { getTheme } from '../../themes';
+import { GrafanaThemeType } from '../../types';
+
+const ThemableStory: React.FunctionComponent<{}> = ({ children }) => {
+ const themeKnob = select(
+ 'Theme',
+ {
+ Light: GrafanaThemeType.Light,
+ Dark: GrafanaThemeType.Dark,
+ },
+ GrafanaThemeType.Dark
+ );
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+// Temporary solution. When we update to Storybook V5 we will be able to pass data from decorator to story
+// https://github.com/storybooks/storybook/issues/340#issuecomment-456013702
+export const renderComponentWithTheme = (component: React.ComponentType, props: any) => {
+ return (
+
+ {theme => {
+ return React.createElement(component, {
+ ...props,
+ theme,
+ });
+ }}
+
+ );
+};
+
+export const withTheme = (story: RenderFunction) => {story()} ;
diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst
index 351c966a8e6..957f8aef307 100755
--- a/packaging/deb/control/postinst
+++ b/packaging/deb/control/postinst
@@ -31,11 +31,16 @@ case "$1" in
cp /usr/share/grafana/conf/ldap.toml /etc/grafana/ldap.toml
fi
- if [ ! -f $PROVISIONING_CFG_DIR ]; then
+ if [ ! -d $PROVISIONING_CFG_DIR ]; then
mkdir -p $PROVISIONING_CFG_DIR/dashboards $PROVISIONING_CFG_DIR/datasources
cp /usr/share/grafana/conf/provisioning/dashboards/sample.yaml $PROVISIONING_CFG_DIR/dashboards/sample.yaml
cp /usr/share/grafana/conf/provisioning/datasources/sample.yaml $PROVISIONING_CFG_DIR/datasources/sample.yaml
- fi
+ fi
+
+ if [ ! -d $PROVISIONING_CFG_DIR/notifiers ]; then
+ mkdir -p $PROVISIONING_CFG_DIR/notifiers
+ cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml
+ fi
# configuration files should not be modifiable by grafana user, as this can be a security issue
chown -Rh root:$GRAFANA_GROUP /etc/grafana/*
diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile
index d4f2f2aa7a3..d783cb14377 100644
--- a/packaging/docker/Dockerfile
+++ b/packaging/docker/Dockerfile
@@ -39,6 +39,7 @@ RUN mkdir -p "$GF_PATHS_HOME/.aws" && \
useradd -r -u $GF_UID -g grafana grafana && \
mkdir -p "$GF_PATHS_PROVISIONING/datasources" \
"$GF_PATHS_PROVISIONING/dashboards" \
+ "$GF_PATHS_PROVISIONING/notifiers" \
"$GF_PATHS_LOGS" \
"$GF_PATHS_PLUGINS" \
"$GF_PATHS_DATA" && \
diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst
index e75850f258e..cd679838487 100755
--- a/packaging/rpm/control/postinst
+++ b/packaging/rpm/control/postinst
@@ -45,11 +45,16 @@ if [ $1 -eq 1 ] ; then
cp /usr/share/grafana/conf/ldap.toml /etc/grafana/ldap.toml
fi
- if [ ! -f $PROVISIONING_CFG_DIR ]; then
+ if [ ! -d $PROVISIONING_CFG_DIR ]; then
mkdir -p $PROVISIONING_CFG_DIR/dashboards $PROVISIONING_CFG_DIR/datasources
cp /usr/share/grafana/conf/provisioning/dashboards/sample.yaml $PROVISIONING_CFG_DIR/dashboards/sample.yaml
cp /usr/share/grafana/conf/provisioning/datasources/sample.yaml $PROVISIONING_CFG_DIR/datasources/sample.yaml
- fi
+ fi
+
+ if [ ! -d $PROVISIONING_CFG_DIR/notifiers ]; then
+ mkdir -p $PROVISIONING_CFG_DIR/notifiers
+ cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml
+ fi
# Set user permissions on /var/log/grafana, /var/lib/grafana
mkdir -p /var/log/grafana /var/lib/grafana
diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go
index 242b5531f51..de9d2517caa 100644
--- a/pkg/api/annotations.go
+++ b/pkg/api/annotations.go
@@ -210,6 +210,65 @@ func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response {
return Success("Annotation updated")
}
+func PatchAnnotation(c *m.ReqContext, cmd dtos.PatchAnnotationsCmd) Response {
+ annotationID := c.ParamsInt64(":annotationId")
+
+ repo := annotations.GetRepository()
+
+ if resp := canSave(c, repo, annotationID); resp != nil {
+ return resp
+ }
+
+ items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: c.OrgId})
+
+ if err != nil || len(items) == 0 {
+ return Error(404, "Could not find annotation to update", err)
+ }
+
+ existing := annotations.Item{
+ OrgId: c.OrgId,
+ UserId: c.UserId,
+ Id: annotationID,
+ Epoch: items[0].Time,
+ Text: items[0].Text,
+ Tags: items[0].Tags,
+ RegionId: items[0].RegionId,
+ }
+
+ if cmd.Tags != nil {
+ existing.Tags = cmd.Tags
+ }
+
+ if cmd.Text != "" && cmd.Text != existing.Text {
+ existing.Text = cmd.Text
+ }
+
+ if cmd.Time > 0 && cmd.Time != existing.Epoch {
+ existing.Epoch = cmd.Time
+ }
+
+ if err := repo.Update(&existing); err != nil {
+ return Error(500, "Failed to update annotation", err)
+ }
+
+ // Update region end time if provided
+ if existing.RegionId != 0 && cmd.TimeEnd > 0 {
+ itemRight := existing
+ itemRight.RegionId = existing.Id
+ itemRight.Epoch = cmd.TimeEnd
+
+ // We don't know id of region right event, so set it to 0 and find then using query like
+ // ... WHERE region_id = AND id != ...
+ itemRight.Id = 0
+
+ if err := repo.Update(&itemRight); err != nil {
+ return Error(500, "Failed to update annotation for region end time", err)
+ }
+ }
+
+ return Success("Annotation patched")
+}
+
func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response {
repo := annotations.GetRepository()
diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go
index 08f3018c694..ebdd867a031 100644
--- a/pkg/api/annotations_test.go
+++ b/pkg/api/annotations_test.go
@@ -27,6 +27,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
IsRegion: false,
}
+ patchCmd := dtos.PatchAnnotationsCmd{
+ Time: 1000,
+ Text: "annotation text",
+ Tags: []string{"tag1", "tag2"},
+ }
+
Convey("When user is an Org Viewer", func() {
role := m.ROLE_VIEWER
Convey("Should not be allowed to save an annotation", func() {
@@ -40,6 +46,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 403)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 403)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -67,6 +78,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 200)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -100,6 +116,13 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
Id: 1,
}
+ patchCmd := dtos.PatchAnnotationsCmd{
+ Time: 8000,
+ Text: "annotation text 50",
+ Tags: []string{"foo", "bar"},
+ Id: 1,
+ }
+
deleteCmd := dtos.DeleteAnnotationsCmd{
DashboardId: 1,
PanelId: 1,
@@ -136,6 +159,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 403)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 403)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -163,6 +191,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 200)
})
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
sc.handlerFunc = DeleteAnnotationByID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
@@ -189,6 +222,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
})
+
+ patchAnnotationScenario("When calling PATCH on", "/api/annotations/1", "/api/annotations/:annotationId", role, patchCmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("PATCH", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+
deleteAnnotationsScenario("When calling POST on", "/api/annotations/mass-delete", "/api/annotations/mass-delete", role, deleteCmd, func(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
So(sc.resp.Code, ShouldEqual, 200)
@@ -264,6 +303,29 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m.
})
}
+func patchAnnotationScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PatchAnnotationsCmd, fn scenarioFunc) {
+ Convey(desc+" "+url, func() {
+ defer bus.ClearBusHandlers()
+
+ sc := setupScenarioContext(url)
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = role
+
+ return PatchAnnotation(c, cmd)
+ })
+
+ fakeAnnoRepo = &fakeAnnotationsRepo{}
+ annotations.SetRepository(fakeAnnoRepo)
+
+ sc.m.Patch(routePattern, sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
func deleteAnnotationsScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.DeleteAnnotationsCmd, fn scenarioFunc) {
Convey(desc+" "+url, func() {
defer bus.ClearBusHandlers()
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 980706d8355..0685ef3814d 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -354,6 +354,7 @@ func (hs *HTTPServer) registerRoutes() {
annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation))
annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID))
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation))
+ annotationsRoute.Patch("/:annotationId", bind(dtos.PatchAnnotationsCmd{}), Wrap(PatchAnnotation))
annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion))
annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation))
})
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index fe02c94e277..3f3a50aae69 100644
--- a/pkg/api/common_test.go
+++ b/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 }
diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go
index c917b0d9feb..bdee8599fea 100644
--- a/pkg/api/dtos/annotations.go
+++ b/pkg/api/dtos/annotations.go
@@ -22,6 +22,14 @@ type UpdateAnnotationsCmd struct {
TimeEnd int64 `json:"timeEnd"`
}
+type PatchAnnotationsCmd struct {
+ Id int64 `json:"id"`
+ Time int64 `json:"time"`
+ Text string `json:"text"`
+ Tags []string `json:"tags"`
+ TimeEnd int64 `json:"timeEnd"`
+}
+
type DeleteAnnotationsCmd struct {
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
diff --git a/pkg/api/dtos/playlist.go b/pkg/api/dtos/playlist.go
index 317ff83339a..7f43bb4df8a 100644
--- a/pkg/api/dtos/playlist.go
+++ b/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"`
}
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 7b7c1478a4c..cadf6896bf4 100644
--- a/pkg/api/http_server.go
+++ b/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 {
diff --git a/pkg/api/login.go b/pkg/api/login.go
index 49da147724e..106a48dd6a8 100644
--- a/pkg/api/login.go
+++ b/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
diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go
index 4160d48733e..87a8ecc876f 100644
--- a/pkg/api/login_oauth.go
+++ b/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,
})
}
diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go
index e82c7b438b4..5ca136c32c4 100644
--- a/pkg/api/playlist_play.go
+++ b/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],
})
}
diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go
index a0ad96a6977..b1950998297 100644
--- a/pkg/api/pluginproxy/ds_proxy.go
+++ b/pkg/api/pluginproxy/ds_proxy.go
@@ -54,7 +54,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx
func newHTTPClient() httpClient {
return &http.Client{
- Timeout: time.Duration(setting.DataProxyTimeout) * time.Second,
+ Timeout: 30 * time.Second,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
}
}
diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go
index 3bdaf0cc80e..d371d4e91da 100644
--- a/pkg/cmd/grafana-server/main.go
+++ b/pkg/cmd/grafana-server/main.go
@@ -19,6 +19,7 @@ import (
_ "github.com/grafana/grafana/pkg/services/alerting/conditions"
_ "github.com/grafana/grafana/pkg/services/alerting/notifiers"
"github.com/grafana/grafana/pkg/setting"
+ _ "github.com/grafana/grafana/pkg/tsdb/azuremonitor"
_ "github.com/grafana/grafana/pkg/tsdb/cloudwatch"
_ "github.com/grafana/grafana/pkg/tsdb/elasticsearch"
_ "github.com/grafana/grafana/pkg/tsdb/graphite"
diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go
index 4781361b9b9..f663e6be895 100644
--- a/pkg/cmd/grafana-server/server.go
+++ b/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"
diff --git a/pkg/infra/usagestats/service.go b/pkg/infra/usagestats/service.go
new file mode 100644
index 00000000000..c2bf0d06349
--- /dev/null
+++ b/pkg/infra/usagestats/service.go
@@ -0,0 +1,54 @@
+package usagestats
+
+import (
+ "context"
+ "time"
+
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/social"
+
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/registry"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+var metricsLogger log.Logger = log.New("metrics")
+
+func init() {
+ registry.RegisterService(&UsageStatsService{})
+}
+
+type UsageStatsService struct {
+ Cfg *setting.Cfg `inject:""`
+ Bus bus.Bus `inject:""`
+ SQLStore *sqlstore.SqlStore `inject:""`
+
+ oauthProviders map[string]bool
+}
+
+func (uss *UsageStatsService) Init() error {
+
+ uss.oauthProviders = social.GetOAuthProviders(uss.Cfg)
+ return nil
+}
+
+func (uss *UsageStatsService) Run(ctx context.Context) error {
+ uss.updateTotalStats()
+
+ onceEveryDayTick := time.NewTicker(time.Hour * 24)
+ everyMinuteTicker := time.NewTicker(time.Minute)
+ defer onceEveryDayTick.Stop()
+ defer everyMinuteTicker.Stop()
+
+ for {
+ select {
+ case <-onceEveryDayTick.C:
+ uss.sendUsageStats(uss.oauthProviders)
+ case <-everyMinuteTicker.C:
+ uss.updateTotalStats()
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
diff --git a/pkg/infra/usagestats/usage_stats.go b/pkg/infra/usagestats/usage_stats.go
new file mode 100644
index 00000000000..9d7501b7765
--- /dev/null
+++ b/pkg/infra/usagestats/usage_stats.go
@@ -0,0 +1,177 @@
+package usagestats
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/grafana/grafana/pkg/metrics"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+var usageStatsURL = "https://stats.grafana.org/grafana-usage-report"
+
+func (uss *UsageStatsService) sendUsageStats(oauthProviders map[string]bool) {
+ if !setting.ReportingEnabled {
+ return
+ }
+
+ metricsLogger.Debug(fmt.Sprintf("Sending anonymous usage stats to %s", usageStatsURL))
+
+ version := strings.Replace(setting.BuildVersion, ".", "_", -1)
+
+ metrics := map[string]interface{}{}
+ report := map[string]interface{}{
+ "version": version,
+ "metrics": metrics,
+ "os": runtime.GOOS,
+ "arch": runtime.GOARCH,
+ "edition": getEdition(),
+ "packaging": setting.Packaging,
+ }
+
+ statsQuery := models.GetSystemStatsQuery{}
+ if err := uss.Bus.Dispatch(&statsQuery); err != nil {
+ metricsLogger.Error("Failed to get system stats", "error", err)
+ return
+ }
+
+ metrics["stats.dashboards.count"] = statsQuery.Result.Dashboards
+ metrics["stats.users.count"] = statsQuery.Result.Users
+ metrics["stats.orgs.count"] = statsQuery.Result.Orgs
+ metrics["stats.playlist.count"] = statsQuery.Result.Playlists
+ metrics["stats.plugins.apps.count"] = len(plugins.Apps)
+ metrics["stats.plugins.panels.count"] = len(plugins.Panels)
+ metrics["stats.plugins.datasources.count"] = len(plugins.DataSources)
+ metrics["stats.alerts.count"] = statsQuery.Result.Alerts
+ metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers
+ metrics["stats.datasources.count"] = statsQuery.Result.Datasources
+ metrics["stats.stars.count"] = statsQuery.Result.Stars
+ metrics["stats.folders.count"] = statsQuery.Result.Folders
+ metrics["stats.dashboard_permissions.count"] = statsQuery.Result.DashboardPermissions
+ metrics["stats.folder_permissions.count"] = statsQuery.Result.FolderPermissions
+ metrics["stats.provisioned_dashboards.count"] = statsQuery.Result.ProvisionedDashboards
+ metrics["stats.snapshots.count"] = statsQuery.Result.Snapshots
+ metrics["stats.teams.count"] = statsQuery.Result.Teams
+ metrics["stats.total_auth_token.count"] = statsQuery.Result.AuthTokens
+
+ userCount := statsQuery.Result.Users
+ avgAuthTokensPerUser := statsQuery.Result.AuthTokens
+ if userCount != 0 {
+ avgAuthTokensPerUser = avgAuthTokensPerUser / userCount
+ }
+
+ metrics["stats.avg_auth_token_per_user.count"] = avgAuthTokensPerUser
+
+ dsStats := models.GetDataSourceStatsQuery{}
+ if err := uss.Bus.Dispatch(&dsStats); err != nil {
+ metricsLogger.Error("Failed to get datasource stats", "error", err)
+ return
+ }
+
+ // send counters for each data source
+ // but ignore any custom data sources
+ // as sending that name could be sensitive information
+ dsOtherCount := 0
+ for _, dsStat := range dsStats.Result {
+ if models.IsKnownDataSourcePlugin(dsStat.Type) {
+ metrics["stats.ds."+dsStat.Type+".count"] = dsStat.Count
+ } else {
+ dsOtherCount += dsStat.Count
+ }
+ }
+ metrics["stats.ds.other.count"] = dsOtherCount
+
+ metrics["stats.packaging."+setting.Packaging+".count"] = 1
+
+ dsAccessStats := models.GetDataSourceAccessStatsQuery{}
+ if err := uss.Bus.Dispatch(&dsAccessStats); err != nil {
+ metricsLogger.Error("Failed to get datasource access stats", "error", err)
+ return
+ }
+
+ // send access counters for each data source
+ // but ignore any custom data sources
+ // as sending that name could be sensitive information
+ dsAccessOtherCount := make(map[string]int64)
+ for _, dsAccessStat := range dsAccessStats.Result {
+ if dsAccessStat.Access == "" {
+ continue
+ }
+
+ access := strings.ToLower(dsAccessStat.Access)
+
+ if models.IsKnownDataSourcePlugin(dsAccessStat.Type) {
+ metrics["stats.ds_access."+dsAccessStat.Type+"."+access+".count"] = dsAccessStat.Count
+ } else {
+ old := dsAccessOtherCount[access]
+ dsAccessOtherCount[access] = old + dsAccessStat.Count
+ }
+ }
+
+ for access, count := range dsAccessOtherCount {
+ metrics["stats.ds_access.other."+access+".count"] = count
+ }
+
+ anStats := models.GetAlertNotifierUsageStatsQuery{}
+ if err := uss.Bus.Dispatch(&anStats); err != nil {
+ metricsLogger.Error("Failed to get alert notification stats", "error", err)
+ return
+ }
+
+ for _, stats := range anStats.Result {
+ metrics["stats.alert_notifiers."+stats.Type+".count"] = stats.Count
+ }
+
+ authTypes := map[string]bool{}
+ authTypes["anonymous"] = setting.AnonymousEnabled
+ authTypes["basic_auth"] = setting.BasicAuthEnabled
+ authTypes["ldap"] = setting.LdapEnabled
+ authTypes["auth_proxy"] = setting.AuthProxyEnabled
+
+ for provider, enabled := range oauthProviders {
+ authTypes["oauth_"+provider] = enabled
+ }
+
+ for authType, enabled := range authTypes {
+ enabledValue := 0
+ if enabled {
+ enabledValue = 1
+ }
+ metrics["stats.auth_enabled."+authType+".count"] = enabledValue
+ }
+
+ out, _ := json.MarshalIndent(report, "", " ")
+ data := bytes.NewBuffer(out)
+
+ client := http.Client{Timeout: 5 * time.Second}
+ go client.Post(usageStatsURL, "application/json", data)
+}
+
+func (uss *UsageStatsService) updateTotalStats() {
+ statsQuery := models.GetSystemStatsQuery{}
+ if err := uss.Bus.Dispatch(&statsQuery); err != nil {
+ metricsLogger.Error("Failed to get system stats", "error", err)
+ return
+ }
+
+ metrics.M_StatTotal_Dashboards.Set(float64(statsQuery.Result.Dashboards))
+ metrics.M_StatTotal_Users.Set(float64(statsQuery.Result.Users))
+ metrics.M_StatActive_Users.Set(float64(statsQuery.Result.ActiveUsers))
+ metrics.M_StatTotal_Playlists.Set(float64(statsQuery.Result.Playlists))
+ metrics.M_StatTotal_Orgs.Set(float64(statsQuery.Result.Orgs))
+}
+
+func getEdition() string {
+ if setting.IsEnterprise {
+ return "enterprise"
+ } else {
+ return "oss"
+ }
+}
diff --git a/pkg/metrics/metrics_test.go b/pkg/infra/usagestats/usage_stats_test.go
similarity index 91%
rename from pkg/metrics/metrics_test.go
rename to pkg/infra/usagestats/usage_stats_test.go
index c27d6f64b8c..ea5b95d6ef0 100644
--- a/pkg/metrics/metrics_test.go
+++ b/pkg/infra/usagestats/usage_stats_test.go
@@ -1,4 +1,4 @@
-package metrics
+package usagestats
import (
"bytes"
@@ -15,14 +15,21 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
)
func TestMetrics(t *testing.T) {
Convey("Test send usage stats", t, func() {
+ uss := &UsageStatsService{
+ Bus: bus.New(),
+ SQLStore: sqlstore.InitTestDB(t),
+ }
+
var getSystemStatsQuery *models.GetSystemStatsQuery
- bus.AddHandler("test", func(query *models.GetSystemStatsQuery) error {
+ uss.Bus.AddHandler(func(query *models.GetSystemStatsQuery) error {
+
query.Result = &models.SystemStats{
Dashboards: 1,
Datasources: 2,
@@ -38,13 +45,14 @@ func TestMetrics(t *testing.T) {
ProvisionedDashboards: 12,
Snapshots: 13,
Teams: 14,
+ AuthTokens: 15,
}
getSystemStatsQuery = query
return nil
})
var getDataSourceStatsQuery *models.GetDataSourceStatsQuery
- bus.AddHandler("test", func(query *models.GetDataSourceStatsQuery) error {
+ uss.Bus.AddHandler(func(query *models.GetDataSourceStatsQuery) error {
query.Result = []*models.DataSourceStats{
{
Type: models.DS_ES,
@@ -68,7 +76,7 @@ func TestMetrics(t *testing.T) {
})
var getDataSourceAccessStatsQuery *models.GetDataSourceAccessStatsQuery
- bus.AddHandler("test", func(query *models.GetDataSourceAccessStatsQuery) error {
+ uss.Bus.AddHandler(func(query *models.GetDataSourceAccessStatsQuery) error {
query.Result = []*models.DataSourceAccessStats{
{
Type: models.DS_ES,
@@ -116,7 +124,7 @@ func TestMetrics(t *testing.T) {
})
var getAlertNotifierUsageStatsQuery *models.GetAlertNotifierUsageStatsQuery
- bus.AddHandler("test", func(query *models.GetAlertNotifierUsageStatsQuery) error {
+ uss.Bus.AddHandler(func(query *models.GetAlertNotifierUsageStatsQuery) error {
query.Result = []*models.NotifierUsageStats{
{
Type: "slack",
@@ -155,11 +163,11 @@ func TestMetrics(t *testing.T) {
"grafana_com": true,
}
- sendUsageStats(oauthProviders)
+ uss.sendUsageStats(oauthProviders)
Convey("Given reporting not enabled and sending usage stats", func() {
setting.ReportingEnabled = false
- sendUsageStats(oauthProviders)
+ uss.sendUsageStats(oauthProviders)
Convey("Should not gather stats or call http endpoint", func() {
So(getSystemStatsQuery, ShouldBeNil)
@@ -179,7 +187,7 @@ func TestMetrics(t *testing.T) {
setting.Packaging = "deb"
wg.Add(1)
- sendUsageStats(oauthProviders)
+ uss.sendUsageStats(oauthProviders)
Convey("Should gather stats and call http endpoint", func() {
if waitTimeout(&wg, 2*time.Second) {
@@ -221,6 +229,8 @@ func TestMetrics(t *testing.T) {
So(metrics.Get("stats.provisioned_dashboards.count").MustInt(), ShouldEqual, getSystemStatsQuery.Result.ProvisionedDashboards)
So(metrics.Get("stats.snapshots.count").MustInt(), ShouldEqual, getSystemStatsQuery.Result.Snapshots)
So(metrics.Get("stats.teams.count").MustInt(), ShouldEqual, getSystemStatsQuery.Result.Teams)
+ So(metrics.Get("stats.total_auth_token.count").MustInt64(), ShouldEqual, 15)
+ So(metrics.Get("stats.avg_auth_token_per_user.count").MustInt64(), ShouldEqual, 5)
So(metrics.Get("stats.ds."+models.DS_ES+".count").MustInt(), ShouldEqual, 9)
So(metrics.Get("stats.ds."+models.DS_PROMETHEUS+".count").MustInt(), ShouldEqual, 10)
@@ -246,6 +256,7 @@ func TestMetrics(t *testing.T) {
So(metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.packaging.deb.count").MustInt(), ShouldEqual, 1)
+
})
})
diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go
index 402160ef5e3..c15cb865bd3 100644
--- a/pkg/login/ldap.go
+++ b/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))
diff --git a/pkg/login/ldap_test.go b/pkg/login/ldap_test.go
index f1cf44dc554..c02fa02e030 100644
--- a/pkg/login/ldap_test.go
+++ b/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
}
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index 326514a9687..bab2fb45127 100644
--- a/pkg/metrics/metrics.go
+++ b/pkg/metrics/metrics.go
@@ -1,17 +1,10 @@
package metrics
import (
- "bytes"
- "encoding/json"
- "net/http"
"runtime"
- "strings"
- "time"
- "github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
+
"github.com/prometheus/client_golang/prometheus"
)
@@ -68,23 +61,6 @@ var (
grafanaBuildVersion *prometheus.GaugeVec
)
-func newCounterVecStartingAtZero(opts prometheus.CounterOpts, labels []string, labelValues ...string) *prometheus.CounterVec {
- counter := prometheus.NewCounterVec(opts, labels)
-
- for _, label := range labelValues {
- counter.WithLabelValues(label).Add(0)
- }
-
- return counter
-}
-
-func newCounterStartingAtZero(opts prometheus.CounterOpts, labelValues ...string) prometheus.Counter {
- counter := prometheus.NewCounter(opts)
- counter.Add(0)
-
- return counter
-}
-
func init() {
M_Instance_Start = prometheus.NewCounter(prometheus.CounterOpts{
Name: "instance_start_total",
@@ -308,7 +284,7 @@ func init() {
Name: "build_info",
Help: "A metric with a constant '1' value labeled by version, revision, branch, and goversion from which Grafana was built.",
Namespace: exporterName,
- }, []string{"version", "revision", "branch", "goversion"})
+ }, []string{"version", "revision", "branch", "goversion", "edition"})
}
// SetBuildInformation sets the build information for this binary
@@ -317,8 +293,13 @@ func SetBuildInformation(version, revision, branch string) {
// Once this have been released for some time we should be able to remote `M_Grafana_Version`
// The reason we added a new one is that its common practice in the prometheus community
// to name this metric `*_build_info` so its easy to do aggregation on all programs.
+ edition := "oss"
+ if setting.IsEnterprise {
+ edition = "enterprise"
+ }
+
M_Grafana_Version.WithLabelValues(version).Set(1)
- grafanaBuildVersion.WithLabelValues(version, revision, branch, runtime.Version()).Set(1)
+ grafanaBuildVersion.WithLabelValues(version, revision, branch, runtime.Version(), edition).Set(1)
}
func initMetricVars() {
@@ -362,154 +343,19 @@ func initMetricVars() {
}
-func updateTotalStats() {
- statsQuery := models.GetSystemStatsQuery{}
- if err := bus.Dispatch(&statsQuery); err != nil {
- metricsLogger.Error("Failed to get system stats", "error", err)
- return
+func newCounterVecStartingAtZero(opts prometheus.CounterOpts, labels []string, labelValues ...string) *prometheus.CounterVec {
+ counter := prometheus.NewCounterVec(opts, labels)
+
+ for _, label := range labelValues {
+ counter.WithLabelValues(label).Add(0)
}
- M_StatTotal_Dashboards.Set(float64(statsQuery.Result.Dashboards))
- M_StatTotal_Users.Set(float64(statsQuery.Result.Users))
- M_StatActive_Users.Set(float64(statsQuery.Result.ActiveUsers))
- M_StatTotal_Playlists.Set(float64(statsQuery.Result.Playlists))
- M_StatTotal_Orgs.Set(float64(statsQuery.Result.Orgs))
+ return counter
}
-var usageStatsURL = "https://stats.grafana.org/grafana-usage-report"
+func newCounterStartingAtZero(opts prometheus.CounterOpts, labelValues ...string) prometheus.Counter {
+ counter := prometheus.NewCounter(opts)
+ counter.Add(0)
-func getEdition() string {
- if setting.IsEnterprise {
- return "enterprise"
- } else {
- return "oss"
- }
-}
-
-func sendUsageStats(oauthProviders map[string]bool) {
- if !setting.ReportingEnabled {
- return
- }
-
- metricsLogger.Debug("Sending anonymous usage stats to stats.grafana.org")
-
- version := strings.Replace(setting.BuildVersion, ".", "_", -1)
-
- metrics := map[string]interface{}{}
- report := map[string]interface{}{
- "version": version,
- "metrics": metrics,
- "os": runtime.GOOS,
- "arch": runtime.GOARCH,
- "edition": getEdition(),
- "packaging": setting.Packaging,
- }
-
- statsQuery := models.GetSystemStatsQuery{}
- if err := bus.Dispatch(&statsQuery); err != nil {
- metricsLogger.Error("Failed to get system stats", "error", err)
- return
- }
-
- metrics["stats.dashboards.count"] = statsQuery.Result.Dashboards
- metrics["stats.users.count"] = statsQuery.Result.Users
- metrics["stats.orgs.count"] = statsQuery.Result.Orgs
- metrics["stats.playlist.count"] = statsQuery.Result.Playlists
- metrics["stats.plugins.apps.count"] = len(plugins.Apps)
- metrics["stats.plugins.panels.count"] = len(plugins.Panels)
- metrics["stats.plugins.datasources.count"] = len(plugins.DataSources)
- metrics["stats.alerts.count"] = statsQuery.Result.Alerts
- metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers
- metrics["stats.datasources.count"] = statsQuery.Result.Datasources
- metrics["stats.stars.count"] = statsQuery.Result.Stars
- metrics["stats.folders.count"] = statsQuery.Result.Folders
- metrics["stats.dashboard_permissions.count"] = statsQuery.Result.DashboardPermissions
- metrics["stats.folder_permissions.count"] = statsQuery.Result.FolderPermissions
- metrics["stats.provisioned_dashboards.count"] = statsQuery.Result.ProvisionedDashboards
- metrics["stats.snapshots.count"] = statsQuery.Result.Snapshots
- metrics["stats.teams.count"] = statsQuery.Result.Teams
-
- dsStats := models.GetDataSourceStatsQuery{}
- if err := bus.Dispatch(&dsStats); err != nil {
- metricsLogger.Error("Failed to get datasource stats", "error", err)
- return
- }
-
- // send counters for each data source
- // but ignore any custom data sources
- // as sending that name could be sensitive information
- dsOtherCount := 0
- for _, dsStat := range dsStats.Result {
- if models.IsKnownDataSourcePlugin(dsStat.Type) {
- metrics["stats.ds."+dsStat.Type+".count"] = dsStat.Count
- } else {
- dsOtherCount += dsStat.Count
- }
- }
- metrics["stats.ds.other.count"] = dsOtherCount
-
- metrics["stats.packaging."+setting.Packaging+".count"] = 1
-
- dsAccessStats := models.GetDataSourceAccessStatsQuery{}
- if err := bus.Dispatch(&dsAccessStats); err != nil {
- metricsLogger.Error("Failed to get datasource access stats", "error", err)
- return
- }
-
- // send access counters for each data source
- // but ignore any custom data sources
- // as sending that name could be sensitive information
- dsAccessOtherCount := make(map[string]int64)
- for _, dsAccessStat := range dsAccessStats.Result {
- if dsAccessStat.Access == "" {
- continue
- }
-
- access := strings.ToLower(dsAccessStat.Access)
-
- if models.IsKnownDataSourcePlugin(dsAccessStat.Type) {
- metrics["stats.ds_access."+dsAccessStat.Type+"."+access+".count"] = dsAccessStat.Count
- } else {
- old := dsAccessOtherCount[access]
- dsAccessOtherCount[access] = old + dsAccessStat.Count
- }
- }
-
- for access, count := range dsAccessOtherCount {
- metrics["stats.ds_access.other."+access+".count"] = count
- }
-
- anStats := models.GetAlertNotifierUsageStatsQuery{}
- if err := bus.Dispatch(&anStats); err != nil {
- metricsLogger.Error("Failed to get alert notification stats", "error", err)
- return
- }
-
- for _, stats := range anStats.Result {
- metrics["stats.alert_notifiers."+stats.Type+".count"] = stats.Count
- }
-
- authTypes := map[string]bool{}
- authTypes["anonymous"] = setting.AnonymousEnabled
- authTypes["basic_auth"] = setting.BasicAuthEnabled
- authTypes["ldap"] = setting.LdapEnabled
- authTypes["auth_proxy"] = setting.AuthProxyEnabled
-
- for provider, enabled := range oauthProviders {
- authTypes["oauth_"+provider] = enabled
- }
-
- for authType, enabled := range authTypes {
- enabledValue := 0
- if enabled {
- enabledValue = 1
- }
- metrics["stats.auth_enabled."+authType+".count"] = enabledValue
- }
-
- out, _ := json.MarshalIndent(report, "", " ")
- data := bytes.NewBuffer(out)
-
- client := http.Client{Timeout: 5 * time.Second}
- go client.Post(usageStatsURL, "application/json", data)
+ return counter
}
diff --git a/pkg/metrics/service.go b/pkg/metrics/service.go
index d2c0c815da9..44b83187cac 100644
--- a/pkg/metrics/service.go
+++ b/pkg/metrics/service.go
@@ -2,7 +2,6 @@ package metrics
import (
"context"
- "time"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics/graphitebridge"
@@ -30,7 +29,6 @@ type InternalMetricsService struct {
intervalSeconds int64
graphiteCfg *graphitebridge.Config
- oauthProviders map[string]bool
}
func (im *InternalMetricsService) Init() error {
@@ -50,22 +48,6 @@ func (im *InternalMetricsService) Run(ctx context.Context) error {
M_Instance_Start.Inc()
- // set the total stats gauges before we publishing metrics
- updateTotalStats()
-
- onceEveryDayTick := time.NewTicker(time.Hour * 24)
- everyMinuteTicker := time.NewTicker(time.Minute)
- defer onceEveryDayTick.Stop()
- defer everyMinuteTicker.Stop()
-
- for {
- select {
- case <-onceEveryDayTick.C:
- sendUsageStats(im.oauthProviders)
- case <-everyMinuteTicker.C:
- updateTotalStats()
- case <-ctx.Done():
- return ctx.Err()
- }
- }
+ <-ctx.Done()
+ return ctx.Err()
}
diff --git a/pkg/metrics/settings.go b/pkg/metrics/settings.go
index 18b9e78d6ff..048e4134690 100644
--- a/pkg/metrics/settings.go
+++ b/pkg/metrics/settings.go
@@ -5,8 +5,6 @@ import (
"strings"
"time"
- "github.com/grafana/grafana/pkg/social"
-
"github.com/grafana/grafana/pkg/metrics/graphitebridge"
"github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
@@ -24,8 +22,6 @@ func (im *InternalMetricsService) readSettings() error {
return fmt.Errorf("Unable to parse metrics graphite section, %v", err)
}
- im.oauthProviders = social.GetOAuthProviders(im.Cfg)
-
return nil
}
diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go
index 3722ac3058f..fa335eb10d9 100644
--- a/pkg/middleware/middleware.go
+++ b/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" {
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index 4679c449853..8545c3856c9 100644
--- a/pkg/middleware/middleware_test.go
+++ b/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 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, "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 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)
+}
diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go
index 46b8776fdcc..e01d1a68d21 100644
--- a/pkg/middleware/org_redirect_test.go
+++ b/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()
diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go
index 4f2203a5d3d..e2a6ef63377 100644
--- a/pkg/middleware/quota_test.go
+++ b/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 {
diff --git a/pkg/models/context.go b/pkg/models/context.go
index df970451304..b0c6ec9226d 100644
--- a/pkg/models/context.go
+++ b/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
diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go
index e1cb185d92a..22c53dfa0dd 100644
--- a/pkg/models/datasource.go
+++ b/pkg/models/datasource.go
@@ -23,7 +23,7 @@ const (
DS_ACCESS_DIRECT = "direct"
DS_ACCESS_PROXY = "proxy"
DS_STACKDRIVER = "stackdriver"
- DS_AZURE_MONITOR = "azure-monitor"
+ DS_AZURE_MONITOR = "grafana-azure-monitor-datasource"
)
var (
diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go
index 66ba66e4d39..864adb4a5a6 100644
--- a/pkg/models/datasource_cache.go
+++ b/pkg/models/datasource_cache.go
@@ -8,6 +8,8 @@ import (
"net/http"
"sync"
"time"
+
+ "github.com/grafana/grafana/pkg/setting"
)
type proxyTransportCache struct {
@@ -46,21 +48,18 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
return t.Transport, nil
}
- var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool
- if ds.JsonData != nil {
- tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
- tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
- tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false)
+ tlsConfig, err := ds.GetTLSConfig()
+ if err != nil {
+ return nil, err
}
+ tlsConfig.Renegotiation = tls.RenegotiateFreelyAsClient
+
transport := &http.Transport{
- TLSClientConfig: &tls.Config{
- InsecureSkipVerify: tlsSkipVerify,
- Renegotiation: tls.RenegotiateFreelyAsClient,
- },
- Proxy: http.ProxyFromEnvironment,
+ TLSClientConfig: tlsConfig,
+ Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
- Timeout: 30 * time.Second,
+ Timeout: time.Duration(setting.DataProxyTimeout) * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).Dial,
@@ -70,6 +69,26 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
IdleConnTimeout: 90 * time.Second,
}
+ ptc.cache[ds.Id] = cachedTransport{
+ Transport: transport,
+ updated: ds.Updated,
+ }
+
+ return transport, nil
+}
+
+func (ds *DataSource) GetTLSConfig() (*tls.Config, error) {
+ var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool
+ if ds.JsonData != nil {
+ tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
+ tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
+ tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false)
+ }
+
+ tlsConfig := &tls.Config{
+ InsecureSkipVerify: tlsSkipVerify,
+ }
+
if tlsClientAuth || tlsAuthWithCACert {
decrypted := ds.SecureJsonData.Decrypt()
if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 {
@@ -78,7 +97,7 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
if !ok {
return nil, errors.New("Failed to parse TLS CA PEM certificate")
}
- transport.TLSClientConfig.RootCAs = caPool
+ tlsConfig.RootCAs = caPool
}
if tlsClientAuth {
@@ -86,14 +105,9 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
if err != nil {
return nil, err
}
- transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
+ tlsConfig.Certificates = []tls.Certificate{cert}
}
}
- ptc.cache[ds.Id] = cachedTransport{
- Transport: transport,
- updated: ds.Updated,
- }
-
- return transport, nil
+ return tlsConfig, nil
}
diff --git a/pkg/models/stats.go b/pkg/models/stats.go
index d3e145dedf4..0edd204ec03 100644
--- a/pkg/models/stats.go
+++ b/pkg/models/stats.go
@@ -15,6 +15,7 @@ type SystemStats struct {
FolderPermissions int64
Folders int64
ProvisionedDashboards int64
+ AuthTokens int64
}
type DataSourceStats struct {
diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go
new file mode 100644
index 00000000000..c8084cf1eba
--- /dev/null
+++ b/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
+}
diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go
index 13b9ef607f5..ef5dccd779f 100644
--- a/pkg/services/auth/auth_token.go
+++ b/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
- return &userToken, nil
+ s.log.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken)
+
+ 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
- return &userToken, nil
+ var userToken models.UserToken
+ err = model.toUserToken(&userToken)
+
+ 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[:])
diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go
index 312e53a3970..26dcbc5c868 100644
--- a/pkg/services/auth/auth_token_test.go
+++ b/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: ""})
+ Convey("revoking nil token should return error", func() {
+ err = userAuthTokenService.RevokeToken(nil)
+ So(err, ShouldEqual, models.ErrUserTokenNotFound)
+ })
- 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)
- 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")
- So(err, ShouldBeNil)
- So(token, ShouldNotBeNil)
-
- _, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+ userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
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
+}
diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go
index 7a0f49539f2..36652e70436 100644
--- a/pkg/services/auth/model.go
+++ b/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
+}
diff --git a/pkg/services/auth/session_cleanup.go b/pkg/services/auth/session_cleanup.go
deleted file mode 100644
index 7e523181a7b..00000000000
--- a/pkg/services/auth/session_cleanup.go
+++ /dev/null
@@ -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
-}
diff --git a/pkg/services/auth/session_cleanup_test.go b/pkg/services/auth/session_cleanup_test.go
deleted file mode 100644
index eef2cd74d04..00000000000
--- a/pkg/services/auth/session_cleanup_test.go
+++ /dev/null
@@ -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)
- })
-}
diff --git a/pkg/services/auth/token_cleanup.go b/pkg/services/auth/token_cleanup.go
new file mode 100644
index 00000000000..aa5bc4856ab
--- /dev/null
+++ b/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
+}
diff --git a/pkg/services/auth/token_cleanup_test.go b/pkg/services/auth/token_cleanup_test.go
new file mode 100644
index 00000000000..410764d3f8d
--- /dev/null
+++ b/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)
+ })
+ })
+}
diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go
index fa08972961d..c57ca1c55e1 100644
--- a/pkg/services/provisioning/dashboards/config_reader.go
+++ b/pkg/services/provisioning/dashboards/config_reader.go
@@ -59,7 +59,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
files, err := ioutil.ReadDir(cr.path)
if err != nil {
- cr.log.Error("can't read dashboard provisioning files from directory", "path", cr.path)
+ cr.log.Error("can't read dashboard provisioning files from directory", "path", cr.path, "error", err)
return dashboards, nil
}
diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go
index b2930c2b679..34c1418aa98 100644
--- a/pkg/services/provisioning/datasources/config_reader.go
+++ b/pkg/services/provisioning/datasources/config_reader.go
@@ -19,7 +19,7 @@ func (cr *configReader) readConfig(path string) ([]*DatasourcesAsConfig, error)
files, err := ioutil.ReadDir(path)
if err != nil {
- cr.log.Error("can't read datasource provisioning files from directory", "path", path)
+ cr.log.Error("can't read datasource provisioning files from directory", "path", path, "error", err)
return datasources, nil
}
diff --git a/pkg/services/provisioning/notifiers/config_reader.go b/pkg/services/provisioning/notifiers/config_reader.go
index e712e8e3eff..c1b4cbf9f29 100644
--- a/pkg/services/provisioning/notifiers/config_reader.go
+++ b/pkg/services/provisioning/notifiers/config_reader.go
@@ -23,7 +23,7 @@ func (cr *configReader) readConfig(path string) ([]*notificationsAsConfig, error
files, err := ioutil.ReadDir(path)
if err != nil {
- cr.log.Error("Can't read alert notification provisioning files from directory", "path", path)
+ cr.log.Error("Can't read alert notification provisioning files from directory", "path", path, "error", err)
return notifications, nil
}
diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go
index 2cec86e7239..2b7c35a4b4a 100644
--- a/pkg/services/sqlstore/stats.go
+++ b/pkg/services/sqlstore/stats.go
@@ -74,7 +74,8 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error {
sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`)
sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("user_auth_token") + `) AS auth_tokens`)
var stats m.SystemStats
_, err := x.SQL(sb.GetSqlString(), sb.params...).Get(&stats)
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index c3c78d10fec..21899482529 100644
--- a/pkg/setting/setting.go
+++ b/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)
diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go
new file mode 100644
index 00000000000..cae8d8bfb73
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go
@@ -0,0 +1,312 @@
+package azuremonitor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/grafana/grafana/pkg/api/pluginproxy"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/setting"
+ opentracing "github.com/opentracing/opentracing-go"
+ "golang.org/x/net/context/ctxhttp"
+
+ "github.com/grafana/grafana/pkg/components/null"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/tsdb"
+)
+
+// AzureMonitorDatasource calls the Azure Monitor API - one of the four API's supported
+type AzureMonitorDatasource struct {
+ httpClient *http.Client
+ dsInfo *models.DataSource
+}
+
+var (
+ // 1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d in milliseconds
+ allowedIntervalsMS = []int64{60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000}
+)
+
+// executeTimeSeriesQuery does the following:
+// 1. build the AzureMonitor url and querystring for each query
+// 2. executes each query by calling the Azure Monitor API
+// 3. parses the responses for each query into the timeseries format
+func (e *AzureMonitorDatasource) executeTimeSeriesQuery(ctx context.Context, originalQueries []*tsdb.Query, timeRange *tsdb.TimeRange) (*tsdb.Response, error) {
+ result := &tsdb.Response{
+ Results: map[string]*tsdb.QueryResult{},
+ }
+
+ queries, err := e.buildQueries(originalQueries, timeRange)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, query := range queries {
+ queryRes, resp, err := e.executeQuery(ctx, query, originalQueries, timeRange)
+ if err != nil {
+ return nil, err
+ }
+ // azlog.Debug("AzureMonitor", "Response", resp)
+
+ err = e.parseResponse(queryRes, resp, query)
+ if err != nil {
+ queryRes.Error = err
+ }
+ result.Results[query.RefID] = queryRes
+ }
+
+ return result, nil
+}
+
+func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange *tsdb.TimeRange) ([]*AzureMonitorQuery, error) {
+ azureMonitorQueries := []*AzureMonitorQuery{}
+ startTime, err := timeRange.ParseFrom()
+ if err != nil {
+ return nil, err
+ }
+
+ endTime, err := timeRange.ParseTo()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, query := range queries {
+ var target string
+
+ azureMonitorTarget := query.Model.Get("azureMonitor").MustMap()
+ azlog.Debug("AzureMonitor", "target", azureMonitorTarget)
+
+ urlComponents := map[string]string{}
+ urlComponents["resourceGroup"] = fmt.Sprintf("%v", azureMonitorTarget["resourceGroup"])
+ urlComponents["metricDefinition"] = fmt.Sprintf("%v", azureMonitorTarget["metricDefinition"])
+ urlComponents["resourceName"] = fmt.Sprintf("%v", azureMonitorTarget["resourceName"])
+
+ ub := urlBuilder{
+ ResourceGroup: urlComponents["resourceGroup"],
+ MetricDefinition: urlComponents["metricDefinition"],
+ ResourceName: urlComponents["resourceName"],
+ }
+ azureURL := ub.Build()
+
+ alias := fmt.Sprintf("%v", azureMonitorTarget["alias"])
+
+ timeGrain := fmt.Sprintf("%v", azureMonitorTarget["timeGrain"])
+ if timeGrain == "auto" {
+ autoInterval := e.findClosestAllowedIntervalMS(query.IntervalMs)
+ tg := &TimeGrain{}
+ timeGrain, err = tg.createISO8601DurationFromIntervalMS(autoInterval)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ params := url.Values{}
+ params.Add("api-version", "2018-01-01")
+ params.Add("timespan", fmt.Sprintf("%v/%v", startTime.UTC().Format(time.RFC3339), endTime.UTC().Format(time.RFC3339)))
+ params.Add("interval", timeGrain)
+ params.Add("aggregation", fmt.Sprintf("%v", azureMonitorTarget["aggregation"]))
+ params.Add("metricnames", fmt.Sprintf("%v", azureMonitorTarget["metricName"]))
+
+ dimension := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimension"]))
+ dimensionFilter := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimensionFilter"]))
+ if azureMonitorTarget["dimension"] != nil && azureMonitorTarget["dimensionFilter"] != nil && len(dimension) > 0 && len(dimensionFilter) > 0 {
+ params.Add("$filter", fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter))
+ }
+
+ target = params.Encode()
+
+ if setting.Env == setting.DEV {
+ azlog.Debug("Azuremonitor request", "params", params)
+ }
+
+ azureMonitorQueries = append(azureMonitorQueries, &AzureMonitorQuery{
+ URL: azureURL,
+ UrlComponents: urlComponents,
+ Target: target,
+ Params: params,
+ RefID: query.RefId,
+ Alias: alias,
+ })
+ }
+
+ return azureMonitorQueries, nil
+}
+
+func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *AzureMonitorQuery, queries []*tsdb.Query, timeRange *tsdb.TimeRange) (*tsdb.QueryResult, AzureMonitorResponse, error) {
+ queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefID}
+
+ req, err := e.createRequest(ctx, e.dsInfo)
+ if err != nil {
+ queryResult.Error = err
+ return queryResult, AzureMonitorResponse{}, nil
+ }
+
+ req.URL.Path = path.Join(req.URL.Path, query.URL)
+ req.URL.RawQuery = query.Params.Encode()
+ queryResult.Meta.Set("rawQuery", req.URL.RawQuery)
+
+ span, ctx := opentracing.StartSpanFromContext(ctx, "azuremonitor query")
+ span.SetTag("target", query.Target)
+ span.SetTag("from", timeRange.From)
+ span.SetTag("until", timeRange.To)
+ span.SetTag("datasource_id", e.dsInfo.Id)
+ span.SetTag("org_id", e.dsInfo.OrgId)
+
+ defer span.Finish()
+
+ opentracing.GlobalTracer().Inject(
+ span.Context(),
+ opentracing.HTTPHeaders,
+ opentracing.HTTPHeadersCarrier(req.Header))
+
+ azlog.Debug("AzureMonitor", "Request URL", req.URL.String())
+ res, err := ctxhttp.Do(ctx, e.httpClient, req)
+ if err != nil {
+ queryResult.Error = err
+ return queryResult, AzureMonitorResponse{}, nil
+ }
+
+ data, err := e.unmarshalResponse(res)
+ if err != nil {
+ queryResult.Error = err
+ return queryResult, AzureMonitorResponse{}, nil
+ }
+
+ return queryResult, data, nil
+}
+
+func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo *models.DataSource) (*http.Request, error) {
+ // find plugin
+ plugin, ok := plugins.DataSources[dsInfo.Type]
+ if !ok {
+ return nil, errors.New("Unable to find datasource plugin Azure Monitor")
+ }
+
+ var azureMonitorRoute *plugins.AppPluginRoute
+ for _, route := range plugin.Routes {
+ if route.Path == "azuremonitor" {
+ azureMonitorRoute = route
+ break
+ }
+ }
+
+ cloudName := dsInfo.JsonData.Get("cloudName").MustString("azuremonitor")
+ subscriptionID := dsInfo.JsonData.Get("subscriptionId").MustString()
+ proxyPass := fmt.Sprintf("%s/subscriptions/%s", cloudName, subscriptionID)
+
+ u, _ := url.Parse(dsInfo.Url)
+ u.Path = path.Join(u.Path, "render")
+
+ req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+ if err != nil {
+ azlog.Error("Failed to create request", "error", err)
+ return nil, fmt.Errorf("Failed to create request. error: %v", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion))
+
+ pluginproxy.ApplyRoute(ctx, req, proxyPass, azureMonitorRoute, dsInfo)
+
+ return req, nil
+}
+
+func (e *AzureMonitorDatasource) unmarshalResponse(res *http.Response) (AzureMonitorResponse, error) {
+ body, err := ioutil.ReadAll(res.Body)
+ defer res.Body.Close()
+ if err != nil {
+ return AzureMonitorResponse{}, err
+ }
+
+ if res.StatusCode/100 != 2 {
+ azlog.Error("Request failed", "status", res.Status, "body", string(body))
+ return AzureMonitorResponse{}, fmt.Errorf(string(body))
+ }
+
+ var data AzureMonitorResponse
+ err = json.Unmarshal(body, &data)
+ if err != nil {
+ azlog.Error("Failed to unmarshal AzureMonitor response", "error", err, "status", res.Status, "body", string(body))
+ return AzureMonitorResponse{}, err
+ }
+
+ return data, nil
+}
+
+func (e *AzureMonitorDatasource) parseResponse(queryRes *tsdb.QueryResult, data AzureMonitorResponse, query *AzureMonitorQuery) error {
+ if len(data.Value) == 0 {
+ return nil
+ }
+
+ for _, series := range data.Value[0].Timeseries {
+ points := []tsdb.TimePoint{}
+
+ metadataName := ""
+ metadataValue := ""
+ if len(series.Metadatavalues) > 0 {
+ metadataName = series.Metadatavalues[0].Name.LocalizedValue
+ metadataValue = series.Metadatavalues[0].Value
+ }
+ defaultMetricName := formatLegendKey(query.UrlComponents["resourceName"], data.Value[0].Name.LocalizedValue, metadataName, metadataValue)
+
+ for _, point := range series.Data {
+ var value float64
+ switch query.Params.Get("aggregation") {
+ case "Average":
+ value = point.Average
+ case "Total":
+ value = point.Total
+ case "Maximum":
+ value = point.Maximum
+ case "Minimum":
+ value = point.Minimum
+ case "Count":
+ value = point.Count
+ default:
+ value = point.Count
+ }
+ points = append(points, tsdb.NewTimePoint(null.FloatFrom(value), float64((point.TimeStamp).Unix())*1000))
+ }
+
+ queryRes.Series = append(queryRes.Series, &tsdb.TimeSeries{
+ Name: defaultMetricName,
+ Points: points,
+ })
+ }
+
+ return nil
+}
+
+// findClosestAllowedIntervalMs is used for the auto time grain setting.
+// It finds the closest time grain from the list of allowed time grains for Azure Monitor
+// using the Grafana interval in milliseconds
+func (e *AzureMonitorDatasource) findClosestAllowedIntervalMS(intervalMs int64) int64 {
+ closest := allowedIntervalsMS[0]
+
+ for i, allowed := range allowedIntervalsMS {
+ if intervalMs > allowed {
+ if i+1 < len(allowedIntervalsMS) {
+ closest = allowedIntervalsMS[i+1]
+ } else {
+ closest = allowed
+ }
+ }
+ }
+ return closest
+}
+
+// formatLegendKey builds the legend key or timeseries name
+func formatLegendKey(resourceName string, metricName string, metadataName string, metadataValue string) string {
+ if len(metadataName) > 0 {
+ return fmt.Sprintf("%s{%s=%s}.%s", resourceName, metadataName, metadataValue, metricName)
+ }
+ return fmt.Sprintf("%s.%s", resourceName, metricName)
+}
diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go
new file mode 100644
index 00000000000..b8d1d6cc266
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go
@@ -0,0 +1,264 @@
+package azuremonitor
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/tsdb"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAzureMonitorDatasource(t *testing.T) {
+ Convey("AzureMonitorDatasource", t, func() {
+ datasource := &AzureMonitorDatasource{}
+
+ Convey("Parse queries from frontend and build AzureMonitor API queries", func() {
+ fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
+ tsdbQuery := &tsdb.TsdbQuery{
+ TimeRange: &tsdb.TimeRange{
+ From: fmt.Sprintf("%v", fromStart.Unix()*1000),
+ To: fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000),
+ },
+ Queries: []*tsdb.Query{
+ {
+ Model: simplejson.NewFromAny(map[string]interface{}{
+ "azureMonitor": map[string]interface{}{
+ "timeGrain": "PT1M",
+ "aggregation": "Average",
+ "resourceGroup": "grafanastaging",
+ "resourceName": "grafana",
+ "metricDefinition": "Microsoft.Compute/virtualMachines",
+ "metricName": "Percentage CPU",
+ "alias": "testalias",
+ "queryType": "Azure Monitor",
+ },
+ }),
+ RefId: "A",
+ },
+ },
+ }
+ Convey("and is a normal query", func() {
+ queries, err := datasource.buildQueries(tsdbQuery.Queries, tsdbQuery.TimeRange)
+ So(err, ShouldBeNil)
+
+ So(len(queries), ShouldEqual, 1)
+ So(queries[0].RefID, ShouldEqual, "A")
+ So(queries[0].URL, ShouldEqual, "resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics")
+ So(queries[0].Target, ShouldEqual, "aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z")
+ So(len(queries[0].Params), ShouldEqual, 5)
+ So(queries[0].Params["timespan"][0], ShouldEqual, "2018-03-15T13:00:00Z/2018-03-15T13:34:00Z")
+ So(queries[0].Params["api-version"][0], ShouldEqual, "2018-01-01")
+ So(queries[0].Params["aggregation"][0], ShouldEqual, "Average")
+ So(queries[0].Params["metricnames"][0], ShouldEqual, "Percentage CPU")
+ So(queries[0].Params["interval"][0], ShouldEqual, "PT1M")
+ So(queries[0].Alias, ShouldEqual, "testalias")
+ })
+
+ Convey("and has a dimension filter", func() {
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "azureMonitor": map[string]interface{}{
+ "timeGrain": "PT1M",
+ "aggregation": "Average",
+ "resourceGroup": "grafanastaging",
+ "resourceName": "grafana",
+ "metricDefinition": "Microsoft.Compute/virtualMachines",
+ "metricName": "Percentage CPU",
+ "alias": "testalias",
+ "queryType": "Azure Monitor",
+ "dimension": "blob",
+ "dimensionFilter": "*",
+ },
+ })
+
+ queries, err := datasource.buildQueries(tsdbQuery.Queries, tsdbQuery.TimeRange)
+ So(err, ShouldBeNil)
+
+ So(queries[0].Target, ShouldEqual, "%24filter=blob+eq+%27%2A%27&aggregation=Average&api-version=2018-01-01&interval=PT1M&metricnames=Percentage+CPU×pan=2018-03-15T13%3A00%3A00Z%2F2018-03-15T13%3A34%3A00Z")
+
+ })
+ })
+
+ Convey("Parse AzureMonitor API response in the time series format", func() {
+ Convey("when data from query aggregated as average to one time series", func() {
+ data, err := loadTestFile("./test-data/1-azure-monitor-response-avg.json")
+ So(err, ShouldBeNil)
+ So(data.Interval, ShouldEqual, "PT1M")
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &AzureMonitorQuery{
+ UrlComponents: map[string]string{
+ "resourceName": "grafana",
+ },
+ Params: url.Values{
+ "aggregation": {"Average"},
+ },
+ }
+ err = datasource.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ So(len(res.Series), ShouldEqual, 1)
+ So(res.Series[0].Name, ShouldEqual, "grafana.Percentage CPU")
+ So(len(res.Series[0].Points), ShouldEqual, 5)
+
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 2.0875)
+ So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1549620780000)
+
+ So(res.Series[0].Points[1][0].Float64, ShouldEqual, 2.1525)
+ So(res.Series[0].Points[1][1].Float64, ShouldEqual, 1549620840000)
+
+ So(res.Series[0].Points[2][0].Float64, ShouldEqual, 2.155)
+ So(res.Series[0].Points[2][1].Float64, ShouldEqual, 1549620900000)
+
+ So(res.Series[0].Points[3][0].Float64, ShouldEqual, 3.6925)
+ So(res.Series[0].Points[3][1].Float64, ShouldEqual, 1549620960000)
+
+ So(res.Series[0].Points[4][0].Float64, ShouldEqual, 2.44)
+ So(res.Series[0].Points[4][1].Float64, ShouldEqual, 1549621020000)
+ })
+
+ Convey("when data from query aggregated as total to one time series", func() {
+ data, err := loadTestFile("./test-data/2-azure-monitor-response-total.json")
+ So(err, ShouldBeNil)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &AzureMonitorQuery{
+ UrlComponents: map[string]string{
+ "resourceName": "grafana",
+ },
+ Params: url.Values{
+ "aggregation": {"Total"},
+ },
+ }
+ err = datasource.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 8.26)
+ So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1549718940000)
+ })
+
+ Convey("when data from query aggregated as maximum to one time series", func() {
+ data, err := loadTestFile("./test-data/3-azure-monitor-response-maximum.json")
+ So(err, ShouldBeNil)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &AzureMonitorQuery{
+ UrlComponents: map[string]string{
+ "resourceName": "grafana",
+ },
+ Params: url.Values{
+ "aggregation": {"Maximum"},
+ },
+ }
+ err = datasource.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 3.07)
+ So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1549722360000)
+ })
+
+ Convey("when data from query aggregated as minimum to one time series", func() {
+ data, err := loadTestFile("./test-data/4-azure-monitor-response-minimum.json")
+ So(err, ShouldBeNil)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &AzureMonitorQuery{
+ UrlComponents: map[string]string{
+ "resourceName": "grafana",
+ },
+ Params: url.Values{
+ "aggregation": {"Minimum"},
+ },
+ }
+ err = datasource.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 1.51)
+ So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1549723380000)
+ })
+
+ Convey("when data from query aggregated as Count to one time series", func() {
+ data, err := loadTestFile("./test-data/5-azure-monitor-response-count.json")
+ So(err, ShouldBeNil)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &AzureMonitorQuery{
+ UrlComponents: map[string]string{
+ "resourceName": "grafana",
+ },
+ Params: url.Values{
+ "aggregation": {"Count"},
+ },
+ }
+ err = datasource.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 4)
+ So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1549723440000)
+ })
+
+ Convey("when data from query aggregated as total and has dimension filter", func() {
+ data, err := loadTestFile("./test-data/6-azure-monitor-response-multi-dimension.json")
+ So(err, ShouldBeNil)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &AzureMonitorQuery{
+ UrlComponents: map[string]string{
+ "resourceName": "grafana",
+ },
+ Params: url.Values{
+ "aggregation": {"Average"},
+ },
+ }
+ err = datasource.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+ So(len(res.Series), ShouldEqual, 3)
+
+ So(res.Series[0].Name, ShouldEqual, "grafana{blobtype=PageBlob}.Blob Count")
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 3)
+
+ So(res.Series[1].Name, ShouldEqual, "grafana{blobtype=BlockBlob}.Blob Count")
+ So(res.Series[1].Points[0][0].Float64, ShouldEqual, 1)
+
+ So(res.Series[2].Name, ShouldEqual, "grafana{blobtype=Azure Data Lake Storage}.Blob Count")
+ So(res.Series[2].Points[0][0].Float64, ShouldEqual, 0)
+ })
+ })
+
+ Convey("Find closest allowed interval for auto time grain", func() {
+ intervals := map[string]int64{
+ "3m": 180000,
+ "5m": 300000,
+ "10m": 600000,
+ "15m": 900000,
+ "1d": 86400000,
+ "2d": 172800000,
+ }
+
+ closest := datasource.findClosestAllowedIntervalMS(intervals["3m"])
+ So(closest, ShouldEqual, intervals["5m"])
+
+ closest = datasource.findClosestAllowedIntervalMS(intervals["10m"])
+ So(closest, ShouldEqual, intervals["15m"])
+
+ closest = datasource.findClosestAllowedIntervalMS(intervals["2d"])
+ So(closest, ShouldEqual, intervals["1d"])
+ })
+ })
+}
+
+func loadTestFile(path string) (AzureMonitorResponse, error) {
+ var data AzureMonitorResponse
+
+ jsonBody, err := ioutil.ReadFile(path)
+ if err != nil {
+ return data, err
+ }
+ err = json.Unmarshal(jsonBody, &data)
+ return data, err
+}
diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go
new file mode 100644
index 00000000000..31a42d21a12
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/azuremonitor.go
@@ -0,0 +1,70 @@
+package azuremonitor
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/tsdb"
+)
+
+var (
+ azlog log.Logger
+)
+
+// AzureMonitorExecutor executes queries for the Azure Monitor datasource - all four services
+type AzureMonitorExecutor struct {
+ httpClient *http.Client
+ dsInfo *models.DataSource
+}
+
+// NewAzureMonitorExecutor initializes a http client
+func NewAzureMonitorExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
+ httpClient, err := dsInfo.GetHttpClient()
+ if err != nil {
+ return nil, err
+ }
+
+ return &AzureMonitorExecutor{
+ httpClient: httpClient,
+ dsInfo: dsInfo,
+ }, nil
+}
+
+func init() {
+ azlog = log.New("tsdb.azuremonitor")
+ tsdb.RegisterTsdbQueryEndpoint("grafana-azure-monitor-datasource", NewAzureMonitorExecutor)
+}
+
+// Query takes in the frontend queries, parses them into the query format
+// expected by chosen Azure Monitor service (Azure Monitor, App Insights etc.)
+// executes the queries against the API and parses the response into
+// the right format
+func (e *AzureMonitorExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+ var result *tsdb.Response
+ var err error
+
+ var azureMonitorQueries []*tsdb.Query
+
+ for _, query := range tsdbQuery.Queries {
+ queryType := query.Model.Get("queryType").MustString("")
+
+ switch queryType {
+ case "Azure Monitor":
+ azureMonitorQueries = append(azureMonitorQueries, query)
+ default:
+ return nil, fmt.Errorf("Alerting not supported for %s", queryType)
+ }
+ }
+
+ azDatasource := &AzureMonitorDatasource{
+ httpClient: e.httpClient,
+ dsInfo: e.dsInfo,
+ }
+
+ result, err = azDatasource.executeTimeSeriesQuery(ctx, azureMonitorQueries, tsdbQuery.TimeRange)
+
+ return result, err
+}
diff --git a/pkg/tsdb/azuremonitor/test-data/1-azure-monitor-response-avg.json b/pkg/tsdb/azuremonitor/test-data/1-azure-monitor-response-avg.json
new file mode 100644
index 00000000000..5fc84f6afa6
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/test-data/1-azure-monitor-response-avg.json
@@ -0,0 +1,47 @@
+{
+ "cost": 0,
+ "timespan": "2019-02-08T10:13:50Z\/2019-02-08T16:13:50Z",
+ "interval": "PT1M",
+ "value": [
+ {
+ "id": "\/subscriptions\/xxx\/resourceGroups\/grafanastaging\/providers\/Microsoft.Compute\/virtualMachines\/grafana\/providers\/Microsoft.Insights\/metrics\/Percentage CPU",
+ "type": "Microsoft.Insights\/metrics",
+ "name": {
+ "value": "Percentage CPU",
+ "localizedValue": "Percentage CPU"
+ },
+ "unit": "Percent",
+ "timeseries": [
+ {
+ "metadatavalues": [
+
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-08T10:13:00Z",
+ "average": 2.0875
+ },
+ {
+ "timeStamp": "2019-02-08T10:14:00Z",
+ "average": 2.1525
+ },
+ {
+ "timeStamp": "2019-02-08T10:15:00Z",
+ "average": 2.155
+ },
+ {
+ "timeStamp": "2019-02-08T10:16:00Z",
+ "average": 3.6925
+ },
+ {
+ "timeStamp": "2019-02-08T10:17:00Z",
+ "average": 2.44
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "namespace": "Microsoft.Compute\/virtualMachines",
+ "resourceregion": "westeurope"
+}
diff --git a/pkg/tsdb/azuremonitor/test-data/2-azure-monitor-response-total.json b/pkg/tsdb/azuremonitor/test-data/2-azure-monitor-response-total.json
new file mode 100644
index 00000000000..d0b22f1b02c
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/test-data/2-azure-monitor-response-total.json
@@ -0,0 +1,47 @@
+{
+ "cost": 0,
+ "timespan": "2019-02-09T13:29:41Z\/2019-02-09T19:29:41Z",
+ "interval": "PT1M",
+ "value": [
+ {
+ "id": "\/subscriptions\/xxx\/resourceGroups\/grafanastaging\/providers\/Microsoft.Compute\/virtualMachines\/grafana\/providers\/Microsoft.Insights\/metrics\/Percentage CPU",
+ "type": "Microsoft.Insights\/metrics",
+ "name": {
+ "value": "Percentage CPU",
+ "localizedValue": "Percentage CPU"
+ },
+ "unit": "Percent",
+ "timeseries": [
+ {
+ "metadatavalues": [
+
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-09T13:29:00Z",
+ "total": 8.26
+ },
+ {
+ "timeStamp": "2019-02-09T13:30:00Z",
+ "total": 8.7
+ },
+ {
+ "timeStamp": "2019-02-09T13:31:00Z",
+ "total": 14.82
+ },
+ {
+ "timeStamp": "2019-02-09T13:32:00Z",
+ "total": 10.07
+ },
+ {
+ "timeStamp": "2019-02-09T13:33:00Z",
+ "total": 8.52
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "namespace": "Microsoft.Compute\/virtualMachines",
+ "resourceregion": "westeurope"
+}
diff --git a/pkg/tsdb/azuremonitor/test-data/3-azure-monitor-response-maximum.json b/pkg/tsdb/azuremonitor/test-data/3-azure-monitor-response-maximum.json
new file mode 100644
index 00000000000..1e46cceb2be
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/test-data/3-azure-monitor-response-maximum.json
@@ -0,0 +1,47 @@
+{
+ "cost": 0,
+ "timespan": "2019-02-09T14:26:12Z\/2019-02-09T20:26:12Z",
+ "interval": "PT1M",
+ "value": [
+ {
+ "id": "\/subscriptions\/xxx\/resourceGroups\/grafanastaging\/providers\/Microsoft.Compute\/virtualMachines\/grafana\/providers\/Microsoft.Insights\/metrics\/Percentage CPU",
+ "type": "Microsoft.Insights\/metrics",
+ "name": {
+ "value": "Percentage CPU",
+ "localizedValue": "Percentage CPU"
+ },
+ "unit": "Percent",
+ "timeseries": [
+ {
+ "metadatavalues": [
+
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-09T14:26:00Z",
+ "maximum": 3.07
+ },
+ {
+ "timeStamp": "2019-02-09T14:27:00Z",
+ "maximum": 2.92
+ },
+ {
+ "timeStamp": "2019-02-09T14:28:00Z",
+ "maximum": 2.87
+ },
+ {
+ "timeStamp": "2019-02-09T14:29:00Z",
+ "maximum": 2.27
+ },
+ {
+ "timeStamp": "2019-02-09T14:30:00Z",
+ "maximum": 2.52
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "namespace": "Microsoft.Compute\/virtualMachines",
+ "resourceregion": "westeurope"
+}
diff --git a/pkg/tsdb/azuremonitor/test-data/4-azure-monitor-response-minimum.json b/pkg/tsdb/azuremonitor/test-data/4-azure-monitor-response-minimum.json
new file mode 100644
index 00000000000..16310614214
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/test-data/4-azure-monitor-response-minimum.json
@@ -0,0 +1,47 @@
+{
+ "cost": 0,
+ "timespan": "2019-02-09T14:43:21Z\/2019-02-09T20:43:21Z",
+ "interval": "PT1M",
+ "value": [
+ {
+ "id": "\/subscriptions\/xxx\/resourceGroups\/grafanastaging\/providers\/Microsoft.Compute\/virtualMachines\/grafana\/providers\/Microsoft.Insights\/metrics\/Percentage CPU",
+ "type": "Microsoft.Insights\/metrics",
+ "name": {
+ "value": "Percentage CPU",
+ "localizedValue": "Percentage CPU"
+ },
+ "unit": "Percent",
+ "timeseries": [
+ {
+ "metadatavalues": [
+
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-09T14:43:00Z",
+ "minimum": 1.51
+ },
+ {
+ "timeStamp": "2019-02-09T14:44:00Z",
+ "minimum": 2.38
+ },
+ {
+ "timeStamp": "2019-02-09T14:45:00Z",
+ "minimum": 1.69
+ },
+ {
+ "timeStamp": "2019-02-09T14:46:00Z",
+ "minimum": 2.27
+ },
+ {
+ "timeStamp": "2019-02-09T14:47:00Z",
+ "minimum": 1.96
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "namespace": "Microsoft.Compute\/virtualMachines",
+ "resourceregion": "westeurope"
+}
diff --git a/pkg/tsdb/azuremonitor/test-data/5-azure-monitor-response-count.json b/pkg/tsdb/azuremonitor/test-data/5-azure-monitor-response-count.json
new file mode 100644
index 00000000000..91afc33f070
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/test-data/5-azure-monitor-response-count.json
@@ -0,0 +1,47 @@
+{
+ "cost": 0,
+ "timespan": "2019-02-09T14:44:52Z\/2019-02-09T20:44:52Z",
+ "interval": "PT1M",
+ "value": [
+ {
+ "id": "\/subscriptions\/xxx\/resourceGroups\/grafanastaging\/providers\/Microsoft.Compute\/virtualMachines\/grafana\/providers\/Microsoft.Insights\/metrics\/Percentage CPU",
+ "type": "Microsoft.Insights\/metrics",
+ "name": {
+ "value": "Percentage CPU",
+ "localizedValue": "Percentage CPU"
+ },
+ "unit": "Percent",
+ "timeseries": [
+ {
+ "metadatavalues": [
+
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-09T14:44:00Z",
+ "count": 4
+ },
+ {
+ "timeStamp": "2019-02-09T14:45:00Z",
+ "count": 4
+ },
+ {
+ "timeStamp": "2019-02-09T14:46:00Z",
+ "count": 4
+ },
+ {
+ "timeStamp": "2019-02-09T14:47:00Z",
+ "count": 4
+ },
+ {
+ "timeStamp": "2019-02-09T14:48:00Z",
+ "count": 4
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "namespace": "Microsoft.Compute\/virtualMachines",
+ "resourceregion": "westeurope"
+}
diff --git a/pkg/tsdb/azuremonitor/test-data/6-azure-monitor-response-multi-dimension.json b/pkg/tsdb/azuremonitor/test-data/6-azure-monitor-response-multi-dimension.json
new file mode 100644
index 00000000000..dddcef0e79c
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/test-data/6-azure-monitor-response-multi-dimension.json
@@ -0,0 +1,128 @@
+{
+ "cost": 0,
+ "timespan": "2019-02-09T15:21:39Z\/2019-02-09T21:21:39Z",
+ "interval": "PT1H",
+ "value": [
+ {
+ "id": "\/subscriptions\/xxx\/resourceGroups\/grafanastaging\/providers\/Microsoft.Storage\/storageAccounts\/grafanastaging\/blobServices\/default\/providers\/Microsoft.Insights\/metrics\/BlobCount",
+ "type": "Microsoft.Insights\/metrics",
+ "name": {
+ "value": "BlobCount",
+ "localizedValue": "Blob Count"
+ },
+ "unit": "Count",
+ "timeseries": [
+ {
+ "metadatavalues": [
+ {
+ "name": {
+ "value": "blobtype",
+ "localizedValue": "blobtype"
+ },
+ "value": "PageBlob"
+ }
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-09T15:21:00Z",
+ "average": 3
+ },
+ {
+ "timeStamp": "2019-02-09T16:21:00Z",
+ "average": 3
+ },
+ {
+ "timeStamp": "2019-02-09T17:21:00Z",
+ "average": 3
+ },
+ {
+ "timeStamp": "2019-02-09T18:21:00Z",
+ "average": 3
+ },
+ {
+ "timeStamp": "2019-02-09T19:21:00Z",
+ "average": 3
+ },
+ {
+ "timeStamp": "2019-02-09T20:21:00Z"
+ }
+ ]
+ },
+ {
+ "metadatavalues": [
+ {
+ "name": {
+ "value": "blobtype",
+ "localizedValue": "blobtype"
+ },
+ "value": "BlockBlob"
+ }
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-09T15:21:00Z",
+ "average": 1
+ },
+ {
+ "timeStamp": "2019-02-09T16:21:00Z",
+ "average": 1
+ },
+ {
+ "timeStamp": "2019-02-09T17:21:00Z",
+ "average": 1
+ },
+ {
+ "timeStamp": "2019-02-09T18:21:00Z",
+ "average": 1
+ },
+ {
+ "timeStamp": "2019-02-09T19:21:00Z",
+ "average": 1
+ },
+ {
+ "timeStamp": "2019-02-09T20:21:00Z"
+ }
+ ]
+ },
+ {
+ "metadatavalues": [
+ {
+ "name": {
+ "value": "blobtype",
+ "localizedValue": "blobtype"
+ },
+ "value": "Azure Data Lake Storage"
+ }
+ ],
+ "data": [
+ {
+ "timeStamp": "2019-02-09T15:21:00Z",
+ "average": 0
+ },
+ {
+ "timeStamp": "2019-02-09T16:21:00Z",
+ "average": 0
+ },
+ {
+ "timeStamp": "2019-02-09T17:21:00Z",
+ "average": 0
+ },
+ {
+ "timeStamp": "2019-02-09T18:21:00Z",
+ "average": 0
+ },
+ {
+ "timeStamp": "2019-02-09T19:21:00Z",
+ "average": 0
+ },
+ {
+ "timeStamp": "2019-02-09T20:21:00Z"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "namespace": "Microsoft.Storage\/storageAccounts\/blobServices",
+ "resourceregion": "westeurope"
+}
diff --git a/pkg/tsdb/azuremonitor/time-grain.go b/pkg/tsdb/azuremonitor/time-grain.go
new file mode 100644
index 00000000000..425e39b6208
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/time-grain.go
@@ -0,0 +1,52 @@
+package azuremonitor
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/grafana/grafana/pkg/tsdb"
+)
+
+// TimeGrain handles convertions between
+// the ISO 8601 Duration format (PT1H), Kbn units (1h) and Time Grains (1 hour)
+// Also handles using the automatic Grafana interval to calculate a ISO 8601 Duration.
+type TimeGrain struct{}
+
+var (
+ smallTimeUnits = []string{"hour", "minute", "h", "m"}
+)
+
+func (tg *TimeGrain) createISO8601DurationFromIntervalMS(interval int64) (string, error) {
+ formatted := tsdb.FormatDuration(time.Duration(interval) * time.Millisecond)
+
+ if strings.Contains(formatted, "ms") {
+ return "PT1M", nil
+ }
+
+ timeValueString := formatted[0 : len(formatted)-1]
+ timeValue, err := strconv.Atoi(timeValueString)
+ if err != nil {
+ return "", fmt.Errorf("Could not parse interval %v to an ISO 8061 duration", interval)
+ }
+
+ unit := formatted[len(formatted)-1:]
+
+ if unit == "s" && timeValue < 60 {
+ // minimum interval is 1m for Azure Monitor
+ return "PT1M", nil
+ }
+
+ return tg.createISO8601Duration(timeValue, unit), nil
+}
+
+func (tg *TimeGrain) createISO8601Duration(timeValue int, timeUnit string) string {
+ for _, smallTimeUnit := range smallTimeUnits {
+ if timeUnit == smallTimeUnit {
+ return fmt.Sprintf("PT%v%v", timeValue, strings.ToUpper(timeUnit[0:1]))
+ }
+ }
+
+ return fmt.Sprintf("P%v%v", timeValue, strings.ToUpper(timeUnit[0:1]))
+}
diff --git a/pkg/tsdb/azuremonitor/time-grain_test.go b/pkg/tsdb/azuremonitor/time-grain_test.go
new file mode 100644
index 00000000000..2df3c92b0ff
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/time-grain_test.go
@@ -0,0 +1,71 @@
+package azuremonitor
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestTimeGrain(t *testing.T) {
+ Convey("TimeGrain", t, func() {
+ tgc := &TimeGrain{}
+
+ Convey("create ISO 8601 Duration", func() {
+ Convey("when given a time unit smaller than a day", func() {
+ minuteKbnDuration := tgc.createISO8601Duration(1, "m")
+ hourKbnDuration := tgc.createISO8601Duration(2, "h")
+ minuteDuration := tgc.createISO8601Duration(1, "minute")
+ hourDuration := tgc.createISO8601Duration(2, "hour")
+
+ Convey("should convert it to a time duration", func() {
+ So(minuteKbnDuration, ShouldEqual, "PT1M")
+ So(hourKbnDuration, ShouldEqual, "PT2H")
+
+ So(minuteDuration, ShouldEqual, "PT1M")
+ So(hourDuration, ShouldEqual, "PT2H")
+ })
+ })
+
+ Convey("when given the day time unit", func() {
+ kbnDuration := tgc.createISO8601Duration(1, "d")
+ duration := tgc.createISO8601Duration(2, "day")
+
+ Convey("should convert it to a date duration", func() {
+ So(kbnDuration, ShouldEqual, "P1D")
+ So(duration, ShouldEqual, "P2D")
+ })
+ })
+ })
+
+ Convey("create ISO 8601 Duration from Grafana interval in milliseconds", func() {
+ Convey("and interval is less than a minute", func() {
+ durationMS, err := tgc.createISO8601DurationFromIntervalMS(100)
+ So(err, ShouldBeNil)
+
+ durationS, err := tgc.createISO8601DurationFromIntervalMS(59999)
+ So(err, ShouldBeNil)
+
+ Convey("should be rounded up to a minute as is the minimum interval for Azure Monitor", func() {
+ So(durationMS, ShouldEqual, "PT1M")
+ So(durationS, ShouldEqual, "PT1M")
+ })
+ })
+
+ Convey("and interval is more than a minute", func() {
+ intervals := map[string]int64{
+ "10m": 600000,
+ "2d": 172800000,
+ }
+ durationM, err := tgc.createISO8601DurationFromIntervalMS(intervals["10m"])
+ So(err, ShouldBeNil)
+ durationD, err := tgc.createISO8601DurationFromIntervalMS(intervals["2d"])
+ So(err, ShouldBeNil)
+
+ Convey("should be rounded up to a minute as is the minimum interval for Azure Monitor", func() {
+ So(durationM, ShouldEqual, "PT10M")
+ So(durationD, ShouldEqual, "P2D")
+ })
+ })
+ })
+ })
+}
diff --git a/pkg/tsdb/azuremonitor/types.go b/pkg/tsdb/azuremonitor/types.go
new file mode 100644
index 00000000000..b547c71f185
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/types.go
@@ -0,0 +1,77 @@
+package azuremonitor
+
+import (
+ "net/url"
+ "time"
+)
+
+// AzureMonitorQuery is the query for all the services as they have similar queries
+// with a url, a querystring and an alias field
+type AzureMonitorQuery struct {
+ URL string
+ UrlComponents map[string]string
+ Target string
+ Params url.Values
+ RefID string
+ Alias string
+}
+
+// AzureMonitorResponse is the json response from the Azure Monitor API
+type AzureMonitorResponse struct {
+ Cost int `json:"cost"`
+ Timespan string `json:"timespan"`
+ Interval string `json:"interval"`
+ Value []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Name struct {
+ Value string `json:"value"`
+ LocalizedValue string `json:"localizedValue"`
+ } `json:"name"`
+ Unit string `json:"unit"`
+ Timeseries []struct {
+ Metadatavalues []struct {
+ Name struct {
+ Value string `json:"value"`
+ LocalizedValue string `json:"localizedValue"`
+ } `json:"name"`
+ Value string `json:"value"`
+ } `json:"metadatavalues"`
+ Data []struct {
+ TimeStamp time.Time `json:"timeStamp"`
+ Average float64 `json:"average,omitempty"`
+ Total float64 `json:"total,omitempty"`
+ Count float64 `json:"count,omitempty"`
+ Maximum float64 `json:"maximum,omitempty"`
+ Minimum float64 `json:"minimum,omitempty"`
+ } `json:"data"`
+ } `json:"timeseries"`
+ } `json:"value"`
+ Namespace string `json:"namespace"`
+ Resourceregion string `json:"resourceregion"`
+}
+
+// ApplicationInsightsResponse is the json response from the Application Insights API
+type ApplicationInsightsResponse struct {
+ Tables []struct {
+ TableName string `json:"TableName"`
+ Columns []struct {
+ ColumnName string `json:"ColumnName"`
+ DataType string `json:"DataType"`
+ ColumnType string `json:"ColumnType"`
+ } `json:"Columns"`
+ Rows [][]interface{} `json:"Rows"`
+ } `json:"Tables"`
+}
+
+// AzureLogAnalyticsResponse is the json response object from the Azure Log Analytics API.
+type AzureLogAnalyticsResponse struct {
+ Tables []struct {
+ Name string `json:"name"`
+ Columns []struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ } `json:"columns"`
+ Rows [][]interface{} `json:"rows"`
+ } `json:"tables"`
+}
diff --git a/pkg/tsdb/azuremonitor/url-builder.go b/pkg/tsdb/azuremonitor/url-builder.go
new file mode 100644
index 00000000000..c252048f517
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/url-builder.go
@@ -0,0 +1,28 @@
+package azuremonitor
+
+import (
+ "fmt"
+ "strings"
+)
+
+// urlBuilder builds the URL for calling the Azure Monitor API
+type urlBuilder struct {
+ ResourceGroup string
+ MetricDefinition string
+ ResourceName string
+}
+
+// Build checks the metric definition property to see which form of the url
+// should be returned
+func (ub *urlBuilder) Build() string {
+
+ if strings.Count(ub.MetricDefinition, "/") > 1 {
+ rn := strings.Split(ub.ResourceName, "/")
+ lastIndex := strings.LastIndex(ub.MetricDefinition, "/")
+ service := ub.MetricDefinition[lastIndex+1:]
+ md := ub.MetricDefinition[0:lastIndex]
+ return fmt.Sprintf("resourceGroups/%s/providers/%s/%s/%s/%s/providers/microsoft.insights/metrics", ub.ResourceGroup, md, rn[0], service, rn[1])
+ }
+
+ return fmt.Sprintf("resourceGroups/%s/providers/%s/%s/providers/microsoft.insights/metrics", ub.ResourceGroup, ub.MetricDefinition, ub.ResourceName)
+}
diff --git a/pkg/tsdb/azuremonitor/url-builder_test.go b/pkg/tsdb/azuremonitor/url-builder_test.go
new file mode 100644
index 00000000000..85c4f81bc83
--- /dev/null
+++ b/pkg/tsdb/azuremonitor/url-builder_test.go
@@ -0,0 +1,45 @@
+package azuremonitor
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestURLBuilder(t *testing.T) {
+ Convey("AzureMonitor URL Builder", t, func() {
+
+ Convey("when metric definition is in the short form", func() {
+ ub := &urlBuilder{
+ ResourceGroup: "rg",
+ MetricDefinition: "Microsoft.Compute/virtualMachines",
+ ResourceName: "rn",
+ }
+
+ url := ub.Build()
+ So(url, ShouldEqual, "resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/rn/providers/microsoft.insights/metrics")
+ })
+
+ Convey("when metric definition is Microsoft.Storage/storageAccounts/blobServices", func() {
+ ub := &urlBuilder{
+ ResourceGroup: "rg",
+ MetricDefinition: "Microsoft.Storage/storageAccounts/blobServices",
+ ResourceName: "rn1/default",
+ }
+
+ url := ub.Build()
+ So(url, ShouldEqual, "resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/providers/microsoft.insights/metrics")
+ })
+
+ Convey("when metric definition is Microsoft.Storage/storageAccounts/fileServices", func() {
+ ub := &urlBuilder{
+ ResourceGroup: "rg",
+ MetricDefinition: "Microsoft.Storage/storageAccounts/fileServices",
+ ResourceName: "rn1/default",
+ }
+
+ url := ub.Build()
+ So(url, ShouldEqual, "resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/providers/microsoft.insights/metrics")
+ })
+ })
+}
diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go
index 34181d19673..ddda26dfd24 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query.go
@@ -55,6 +55,7 @@ func init() {
"AWS/DynamoDB": {"ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "OnlineIndexConsumedWriteCapacity", "OnlineIndexPercentageProgress", "OnlineIndexThrottleEvents", "ProvisionedReadCapacityUnits", "ProvisionedWriteCapacityUnits", "ReadThrottleEvents", "ReturnedBytes", "ReturnedItemCount", "ReturnedRecordsCount", "SuccessfulRequestLatency", "SystemErrors", "TimeToLiveDeletedItemCount", "ThrottledRequests", "UserErrors", "WriteThrottleEvents"},
"AWS/EBS": {"VolumeReadBytes", "VolumeWriteBytes", "VolumeReadOps", "VolumeWriteOps", "VolumeTotalReadTime", "VolumeTotalWriteTime", "VolumeIdleTime", "VolumeQueueLength", "VolumeThroughputPercentage", "VolumeConsumedReadWriteOps", "BurstBalance"},
"AWS/EC2": {"CPUCreditUsage", "CPUCreditBalance", "CPUUtilization", "DiskReadOps", "DiskWriteOps", "DiskReadBytes", "DiskWriteBytes", "NetworkIn", "NetworkOut", "NetworkPacketsIn", "NetworkPacketsOut", "StatusCheckFailed", "StatusCheckFailed_Instance", "StatusCheckFailed_System"},
+ "AWS/EC2/API": {"ClientErrors", "RequestLimitExceeded", "ServerErrors", "SuccessfulCalls"},
"AWS/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"},
"AWS/ECS": {"CPUReservation", "MemoryReservation", "CPUUtilization", "MemoryUtilization"},
"AWS/EFS": {"BurstCreditBalance", "ClientConnections", "DataReadIOBytes", "DataWriteIOBytes", "MetadataIOBytes", "TotalIOBytes", "PermittedThroughput", "PercentIOLimit"},
@@ -133,6 +134,7 @@ func init() {
"AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"},
"AWS/EBS": {"VolumeId"},
"AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"},
+ "AWS/EC2/API": {},
"AWS/EC2Spot": {"AvailabilityZone", "FleetRequestId", "InstanceType"},
"AWS/ECS": {"ClusterName", "ServiceName"},
"AWS/EFS": {"FileSystemId"},
diff --git a/pkg/tsdb/interval.go b/pkg/tsdb/interval.go
index fd6adee39d7..7819ef3ecad 100644
--- a/pkg/tsdb/interval.go
+++ b/pkg/tsdb/interval.go
@@ -59,11 +59,11 @@ func (ic *intervalCalculator) Calculate(timerange *TimeRange, minInterval time.D
interval := time.Duration((to - from) / defaultRes)
if interval < minInterval {
- return Interval{Text: formatDuration(minInterval), Value: minInterval}
+ return Interval{Text: FormatDuration(minInterval), Value: minInterval}
}
rounded := roundInterval(interval)
- return Interval{Text: formatDuration(rounded), Value: rounded}
+ return Interval{Text: FormatDuration(rounded), Value: rounded}
}
func GetIntervalFrom(dsInfo *models.DataSource, queryModel *simplejson.Json, defaultInterval time.Duration) (time.Duration, error) {
@@ -89,7 +89,8 @@ func GetIntervalFrom(dsInfo *models.DataSource, queryModel *simplejson.Json, def
return parsedInterval, nil
}
-func formatDuration(inter time.Duration) string {
+// FormatDuration converts a duration into the kbn format e.g. 1m 2h or 3d
+func FormatDuration(inter time.Duration) string {
if inter >= year {
return fmt.Sprintf("%dy", inter/year)
}
diff --git a/pkg/tsdb/interval_test.go b/pkg/tsdb/interval_test.go
index 941b08dd554..4cd3fcea532 100644
--- a/pkg/tsdb/interval_test.go
+++ b/pkg/tsdb/interval_test.go
@@ -51,11 +51,11 @@ func TestInterval(t *testing.T) {
})
Convey("Format value", func() {
- So(formatDuration(time.Second*61), ShouldEqual, "1m")
- So(formatDuration(time.Millisecond*30), ShouldEqual, "30ms")
- So(formatDuration(time.Hour*23), ShouldEqual, "23h")
- So(formatDuration(time.Hour*24), ShouldEqual, "1d")
- So(formatDuration(time.Hour*24*367), ShouldEqual, "1y")
+ So(FormatDuration(time.Second*61), ShouldEqual, "1m")
+ So(FormatDuration(time.Millisecond*30), ShouldEqual, "30ms")
+ So(FormatDuration(time.Hour*23), ShouldEqual, "23h")
+ So(FormatDuration(time.Hour*24), ShouldEqual, "1d")
+ So(FormatDuration(time.Hour*24*367), ShouldEqual, "1y")
})
})
}
diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go
index 35b03e489a0..d307e12166c 100644
--- a/pkg/tsdb/mysql/mysql.go
+++ b/pkg/tsdb/mysql/mysql.go
@@ -32,6 +32,18 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
datasource.Url,
datasource.Database,
)
+
+ tlsConfig, err := datasource.GetTLSConfig()
+ if err != nil {
+ return nil, err
+ }
+
+ if tlsConfig.RootCAs != nil || len(tlsConfig.Certificates) > 0 {
+ tlsConfigString := fmt.Sprintf("ds%d", datasource.Id)
+ mysql.RegisterTLSConfig(tlsConfigString, tlsConfig)
+ cnnstr += "&tls=" + tlsConfigString
+ }
+
logger.Debug("getEngine", "connection", cnnstr)
config := tsdb.SqlQueryEndpointConfiguration{
diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts
index 4806275e87d..6db442e7470 100644
--- a/public/app/core/angular_wrappers.ts
+++ b/public/app/core/angular_wrappers.ts
@@ -9,7 +9,7 @@ import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
-import { ColorPicker, SeriesColorPickerPopover } from '@grafana/ui';
+import { ColorPicker, SeriesColorPickerPopoverWithTheme } from '@grafana/ui';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -27,7 +27,7 @@ export function registerAngularDirectives() {
'color',
['onChange', { watchDepth: 'reference', wrapApply: true }],
]);
- react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
+ react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopoverWithTheme, [
'color',
'series',
'onColorChange',
diff --git a/public/app/core/app_events.ts b/public/app/core/app_events.ts
index 6af7913167b..1951fd87001 100644
--- a/public/app/core/app_events.ts
+++ b/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;
diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx
new file mode 100644
index 00000000000..c99bf11ed7c
--- /dev/null
+++ b/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 = ({ title, icon, text, severity, onClose }) => {
+ return (
+
+
+
+
+
+
{title}
+ {text &&
{text}
}
+
+ {onClose && (
+
+
+
+ )}
+
+ );
+};
diff --git a/public/app/core/components/Animations/FadeIn.tsx b/public/app/core/components/Animations/FadeIn.tsx
index ea9a92d5f0f..d667b54261e 100644
--- a/public/app/core/components/Animations/FadeIn.tsx
+++ b/public/app/core/components/Animations/FadeIn.tsx
@@ -1,11 +1,12 @@
import React, { FC } from 'react';
-import Transition from 'react-transition-group/Transition';
+import Transition, { ExitHandler } from 'react-transition-group/Transition';
interface Props {
duration: number;
children: JSX.Element;
in: boolean;
unmountOnExit?: boolean;
+ onExited?: ExitHandler;
}
export const FadeIn: FC = props => {
@@ -22,7 +23,12 @@ export const FadeIn: FC = props => {
};
return (
-
+
{state => (
-
+
{linkButton.title}
diff --git a/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap b/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap
index dc53e7863ea..25de037930a 100644
--- a/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap
+++ b/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap
@@ -29,7 +29,7 @@ exports[`Render should render component 1`] = `
className="page-action-bar__spacer"
/>
diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx
index c4846ecf85d..997f02b700c 100644
--- a/public/app/core/components/Page/Page.tsx
+++ b/public/app/core/components/Page/Page.tsx
@@ -17,13 +17,10 @@ interface Props {
}
class Page extends Component {
- 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 {
}
}
- componentWillUnmount() {
- this.body.classList.remove(this.bodyClass);
- }
-
updateTitle = () => {
const title = this.getPageTitle;
document.title = title ? title + ' - Grafana' : 'Grafana';
diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx
index 30219371257..80afcedf873 100644
--- a/public/app/core/components/PermissionList/AddPermission.tsx
+++ b/public/app/core/components/PermissionList/AddPermission.tsx
@@ -130,7 +130,7 @@ class AddPermissions extends Component {
-
+
Save
diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
index 33aca1de2aa..171e0e8109e 100644
--- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx
+++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
@@ -126,7 +126,7 @@ export class SharedPreferences extends PureComponent
{
/>
-
+
Save
diff --git a/public/app/core/components/gf_page.ts b/public/app/core/components/gf_page.ts
deleted file mode 100644
index 057a307f205..00000000000
--- a/public/app/core/components/gf_page.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import coreModule from 'app/core/core_module';
-
-const template = `
-
-`;
-
-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);
diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.html b/public/app/core/components/manage_dashboards/manage_dashboards.html
index 6fbd65afaf5..4ef2d7c9a66 100644
--- a/public/app/core/components/manage_dashboards/manage_dashboards.html
+++ b/public/app/core/components/manage_dashboards/manage_dashboards.html
@@ -5,16 +5,13 @@
-
-
- Dashboard
+
+ New Dashboard
-
-
- Folder
+
+ New Folder
-
-
+
Import
diff --git a/public/app/core/components/scroll/page_scroll.ts b/public/app/core/components/scroll/page_scroll.ts
deleted file mode 100644
index 2d6e27f8b22..00000000000
--- a/public/app/core/components/scroll/page_scroll.ts
+++ /dev/null
@@ -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);
diff --git a/public/app/core/components/sidemenu/SideMenu.test.tsx b/public/app/core/components/sidemenu/SideMenu.test.tsx
index 2a262adca5a..2286787d777 100644
--- a/public/app/core/components/sidemenu/SideMenu.test.tsx
+++ b/public/app/core/components/sidemenu/SideMenu.test.tsx
@@ -8,6 +8,16 @@ jest.mock('../../app_events', () => ({
emit: jest.fn(),
}));
+jest.mock('app/store/store', () => ({
+ store: {
+ getState: jest.fn().mockReturnValue({
+ location: {
+ lastUpdated: 0,
+ }
+ })
+ }
+}));
+
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
sidemenu: true,
diff --git a/public/app/core/components/sidemenu/SideMenu.tsx b/public/app/core/components/sidemenu/SideMenu.tsx
index fd3e0d95564..29ef0fed069 100644
--- a/public/app/core/components/sidemenu/SideMenu.tsx
+++ b/public/app/core/components/sidemenu/SideMenu.tsx
@@ -3,9 +3,16 @@ import appEvents from '../../app_events';
import { contextSrv } from 'app/core/services/context_srv';
import TopSection from './TopSection';
import BottomSection from './BottomSection';
+import { store } from 'app/store/store';
export class SideMenu extends PureComponent {
toggleSideMenu = () => {
+ // ignore if we just made a location change, stops hiding sidemenu on double clicks of back button
+ const timeSinceLocationChanged = new Date().getTime() - store.getState().location.lastUpdated;
+ if (timeSinceLocationChanged < 1000) {
+ return;
+ }
+
contextSrv.toggleSideMenu();
appEvents.emit('toggle-sidemenu');
};
diff --git a/public/app/core/config.ts b/public/app/core/config.ts
index 395e40e914b..f4254ac251a 100644
--- a/public/app/core/config.ts
+++ b/public/app/core/config.ts
@@ -1,5 +1,6 @@
import _ from 'lodash';
import { PanelPlugin } from 'app/types/plugins';
+import { GrafanaTheme, getTheme, GrafanaThemeType } from '@grafana/ui';
export interface BuildInfo {
version: string;
@@ -36,8 +37,11 @@ export class Settings {
loginError: any;
viewersCanEdit: boolean;
disableSanitizeHtml: boolean;
+ theme: GrafanaTheme;
constructor(options: Settings) {
+ this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);
+
const defaults = {
datasources: {},
windowTitlePrefix: 'Grafana - ',
@@ -68,5 +72,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;
diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts
index c34480d7aad..2869c121fa8 100644
--- a/public/app/core/copy/appNotification.ts
+++ b/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,
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index fb38cefd435..80987b8fc88 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -1,4 +1,3 @@
-import './directives/dash_class';
import './directives/dropdown_typeahead';
import './directives/autofill_event_fix';
import './directives/metric_segment';
@@ -43,8 +42,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 +76,6 @@ export {
NavModelSrv,
NavModel,
geminiScrollbar,
- pageScrollbar,
- gfPageDirective,
orgSwitcher,
manageDashboardsDirective,
TimeSeries,
diff --git a/public/app/core/directives/dash_class.ts b/public/app/core/directives/dash_class.ts
deleted file mode 100644
index 1fb93d29cf3..00000000000
--- a/public/app/core/directives/dash_class.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import $ from 'jquery';
-import _ from 'lodash';
-import coreModule from '../core_module';
-
-/** @ngInject */
-function dashClass($timeout) {
- return {
- link: ($scope, elem) => {
- const body = $('body');
-
- $scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
- console.log('view-mode-changed', panel.fullscreen);
- if (panel.fullscreen) {
- body.addClass('panel-in-fullscreen');
- } else {
- $timeout(() => {
- body.removeClass('panel-in-fullscreen');
- });
- }
- });
-
- body.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
-
- $scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
- if (newValue) {
- elem.toggleClass('dashboard-page--settings-opening', _.isString(newValue));
- setTimeout(() => {
- elem.toggleClass('dashboard-page--settings-open', _.isString(newValue));
- }, 10);
- } else {
- elem.removeClass('dashboard-page--settings-opening');
- elem.removeClass('dashboard-page--settings-open');
- }
- });
- },
- };
-}
-
-coreModule.directive('dashClass', dashClass);
diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts
index abcd5563bd0..2cde5379448 100644
--- a/public/app/core/logs_model.ts
+++ b/public/app/core/logs_model.ts
@@ -340,6 +340,11 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Time
return a[1] - b[1];
});
- return { datapoints: series.datapoints, target: series.alias, color: series.color };
+ return {
+ datapoints: series.datapoints,
+ target: series.alias,
+ alias: series.alias,
+ color: series.color
+ };
});
}
diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts
index 6b39710dcca..dff1ac8f5c1 100644
--- a/public/app/core/reducers/location.ts
+++ b/public/app/core/reducers/location.ts
@@ -8,12 +8,14 @@ export const initialState: LocationState = {
path: '',
query: {},
routeParams: {},
+ replace: false,
+ lastUpdated: 0,
};
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 +28,8 @@ export const locationReducer = (state = initialState, action: Action): LocationS
path: path || state.path,
query: { ...query },
routeParams: routeParams || state.routeParams,
+ replace: replace === true,
+ lastUpdated: new Date().getTime(),
};
}
}
diff --git a/public/app/core/redux/actionCreatorFactory.ts b/public/app/core/redux/actionCreatorFactory.ts
index d6477144df4..df0f02f5c99 100644
--- a/public/app/core/redux/actionCreatorFactory.ts
+++ b/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 => {
+ 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);
diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts
index 359f160b9ce..e5087123c1e 100644
--- a/public/app/core/redux/index.ts
+++ b/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';
diff --git a/public/app/core/services/__mocks__/backend_srv.ts b/public/app/core/services/__mocks__/backend_srv.ts
new file mode 100644
index 00000000000..cbb04ebf9df
--- /dev/null
+++ b/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;
+}
+
+
diff --git a/public/app/core/services/bridge_srv.ts b/public/app/core/services/bridge_srv.ts
index 37f71946364..8bb828310cf 100644
--- a/public/app/core/services/bridge_srv.ts
+++ b/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);
}
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index ed321c6a69e..7dab7cffd6f 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/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;
}
@@ -139,6 +139,10 @@ export class KeybindingSrv {
);
}
+ unbind(keyArg: string, keyType?: string) {
+ Mousetrap.unbind(keyArg, keyType);
+ }
+
showDashEditView() {
const search = _.extend(this.$location.search(), { editview: 'settings' });
this.$location.search(search);
@@ -174,7 +178,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 +190,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 +216,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;
}
});
@@ -293,3 +295,17 @@ export class KeybindingSrv {
}
coreModule.service('keybindingSrv', KeybindingSrv);
+
+/**
+ * Code below exports the service to react components
+ */
+
+let singletonInstance: KeybindingSrv;
+
+export function setKeybindingSrv(instance: KeybindingSrv) {
+ singletonInstance = instance;
+}
+
+export function getKeybindingSrv(): KeybindingSrv {
+ return singletonInstance;
+}
diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx
index 6883401ad27..cb3ad88b191 100644
--- a/public/app/core/utils/ConfigProvider.tsx
+++ b/public/app/core/utils/ConfigProvider.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import config, { Settings } from 'app/core/config';
-import { GrafanaTheme } from '@grafana/ui';
+import { GrafanaThemeType, ThemeContext, getTheme } from '@grafana/ui';
export const ConfigContext = React.createContext(config);
export const ConfigConsumer = ConfigContext.Consumer;
@@ -13,16 +13,20 @@ export const provideConfig = (component: React.ComponentType) => {
return ConfigProvider;
};
-interface ThemeProviderProps {
- children: (theme: GrafanaTheme) => JSX.Element;
-}
+export const getCurrentThemeName = () =>
+ config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark;
+export const getCurrentTheme = () => getTheme(getCurrentThemeName());
-export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
return (
- {({ bootData }) => {
- return children(bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark);
+ {config => {
+ return {children} ;
}}
);
};
+
+export const provideTheme = (component: React.ComponentType) => {
+ return provideConfig((props: any) => {React.createElement(component, { ...props })} );
+};
diff --git a/public/app/core/utils/errors.ts b/public/app/core/utils/errors.ts
new file mode 100644
index 00000000000..3f6f1cfbc8d
--- /dev/null
+++ b/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;
+}
diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts
index 1c00142c3b8..dae6554ade0 100644
--- a/public/app/core/utils/explore.test.ts
+++ b/public/app/core/utils/explore.test.ts
@@ -8,6 +8,7 @@ import {
} from './explore';
import { ExploreUrlState } from 'app/types/explore';
import store from 'app/core/store';
+import { LogsDedupStrategy } from 'app/core/logs_model';
const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
datasource: null,
@@ -17,6 +18,7 @@ const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
showingGraph: true,
showingTable: true,
showingLogs: true,
+ dedupStrategy: LogsDedupStrategy.none,
}
};
@@ -78,7 +80,7 @@ describe('state functions', () => {
expect(serializeStateToUrlParam(state)).toBe(
'{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
'{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"},' +
- '"ui":{"showingGraph":true,"showingTable":true,"showingLogs":true}}'
+ '"ui":{"showingGraph":true,"showingTable":true,"showingLogs":true,"dedupStrategy":"none"}}'
);
});
@@ -100,7 +102,7 @@ describe('state functions', () => {
},
};
expect(serializeStateToUrlParam(state, true)).toBe(
- '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"},{"ui":[true,true,true]}]'
+ '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"},{"ui":[true,true,true,"none"]}]'
);
});
});
diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts
index 107f411353c..1dcd66c6369 100644
--- a/public/app/core/utils/explore.ts
+++ b/public/app/core/utils/explore.ts
@@ -21,6 +21,7 @@ import {
QueryIntervals,
QueryOptions,
} from 'app/types/explore';
+import { LogsDedupStrategy } from 'app/core/logs_model';
export const DEFAULT_RANGE = {
from: 'now-6h',
@@ -31,6 +32,7 @@ export const DEFAULT_UI_STATE = {
showingTable: true,
showingGraph: true,
showingLogs: true,
+ dedupStrategy: LogsDedupStrategy.none,
};
const MAX_HISTORY_ITEMS = 100;
@@ -183,6 +185,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
showingGraph: segment.ui[0],
showingLogs: segment.ui[1],
showingTable: segment.ui[2],
+ dedupStrategy: segment.ui[3],
};
}
});
@@ -204,7 +207,7 @@ export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: bo
urlState.range.to,
urlState.datasource,
...urlState.queries,
- { ui: [!!urlState.ui.showingGraph, !!urlState.ui.showingLogs, !!urlState.ui.showingTable] },
+ { ui: [!!urlState.ui.showingGraph, !!urlState.ui.showingLogs, !!urlState.ui.showingTable, urlState.ui.dedupStrategy] },
]);
}
return JSON.stringify(urlState);
diff --git a/public/app/core/utils/location_util.ts b/public/app/core/utils/location_util.ts
index 76f2fc5881f..15e1c275550 100644
--- a/public/app/core/utils/location_util.ts
+++ b/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 =
diff --git a/public/app/core/utils/react2angular.ts b/public/app/core/utils/react2angular.ts
index 1057f68fcda..eb4bccab267 100644
--- a/public/app/core/utils/react2angular.ts
+++ b/public/app/core/utils/react2angular.ts
@@ -1,11 +1,11 @@
import coreModule from 'app/core/core_module';
-import { provideConfig } from 'app/core/utils/ConfigProvider';
+import { provideTheme } from 'app/core/utils/ConfigProvider';
export function react2AngularDirective(name: string, component: any, options: any) {
coreModule.directive(name, [
'reactDirective',
reactDirective => {
- return reactDirective(provideConfig(component), options);
+ return reactDirective(provideTheme(component), options);
},
]);
}
diff --git a/public/app/core/utils/version.ts b/public/app/core/utils/version.ts
index 1131e1d2ab8..746de761fa3 100644
--- a/public/app/core/utils/version.ts
+++ b/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 {
diff --git a/public/app/features/admin/partials/edit_org.html b/public/app/features/admin/partials/edit_org.html
index 975d663e9b0..911181ef999 100644
--- a/public/app/features/admin/partials/edit_org.html
+++ b/public/app/features/admin/partials/edit_org.html
@@ -10,7 +10,7 @@
- Update
+ Update
diff --git a/public/app/features/admin/partials/edit_user.html b/public/app/features/admin/partials/edit_user.html
index 5b0efa8bdf3..7e6457a8a76 100644
--- a/public/app/features/admin/partials/edit_user.html
+++ b/public/app/features/admin/partials/edit_user.html
@@ -21,7 +21,7 @@
- Update
+ Update
@@ -34,7 +34,7 @@
- Update
+ Update
@@ -46,7 +46,7 @@
- Update
+ Update
@@ -65,7 +65,7 @@
- Add
+ Add
diff --git a/public/app/features/admin/partials/new_user.html b/public/app/features/admin/partials/new_user.html
index 5199d957c33..e3374d080ca 100644
--- a/public/app/features/admin/partials/new_user.html
+++ b/public/app/features/admin/partials/new_user.html
@@ -24,7 +24,7 @@
- Create
+ Create
diff --git a/public/app/features/admin/partials/orgs.html b/public/app/features/admin/partials/orgs.html
index d28cf4dc967..b40aed6faab 100644
--- a/public/app/features/admin/partials/orgs.html
+++ b/public/app/features/admin/partials/orgs.html
@@ -3,7 +3,7 @@
-
diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html
index 806c10851e5..08704dc0459 100644
--- a/public/app/features/admin/partials/users.html
+++ b/public/app/features/admin/partials/users.html
@@ -7,7 +7,7 @@
-
+
Add new user
diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html
index b2cd2f21e4d..5e7201cdfdd 100644
--- a/public/app/features/alerting/partials/notification_edit.html
+++ b/public/app/features/alerting/partials/notification_edit.html
@@ -68,7 +68,7 @@
diff --git a/public/app/features/alerting/partials/notifications_list.html b/public/app/features/alerting/partials/notifications_list.html
index 246cb45b4db..ce4fea9ff49 100644
--- a/public/app/features/alerting/partials/notifications_list.html
+++ b/public/app/features/alerting/partials/notifications_list.html
@@ -7,8 +7,7 @@
-
-
+
New Channel
diff --git a/public/app/features/all.ts b/public/app/features/all.ts
index 83146596ea0..d5e684e4a4e 100644
--- a/public/app/features/all.ts
+++ b/public/app/features/all.ts
@@ -12,3 +12,4 @@ import './manage-dashboards';
import './teams/CreateTeamCtrl';
import './profile/all';
import './datasources/settings/HttpSettingsCtrl';
+import './datasources/settings/TlsAuthSettingsCtrl';
diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts
index 18b00793ff8..c12e442f6d3 100644
--- a/public/app/features/annotations/editor_ctrl.ts
+++ b/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) {
diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html
index 65ee7e52bd0..9a7a8cb738a 100644
--- a/public/app/features/annotations/partials/editor.html
+++ b/public/app/features/annotations/partials/editor.html
@@ -9,7 +9,7 @@
@@ -48,7 +48,7 @@
diff --git a/public/app/features/annotations/partials/event_editor.html b/public/app/features/annotations/partials/event_editor.html
index 529434755f1..286decb34ce 100644
--- a/public/app/features/annotations/partials/event_editor.html
+++ b/public/app/features/annotations/partials/event_editor.html
@@ -26,7 +26,7 @@
diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx
index 41b9b0c8a55..2627b1a6862 100644
--- a/public/app/features/api-keys/ApiKeysPage.tsx
+++ b/public/app/features/api-keys/ApiKeysPage.tsx
@@ -107,7 +107,7 @@ export class ApiKeysPage extends PureComponent {
renderEmptyList() {
const { isAdding } = this.state;
return (
-
+ <>
{!isAdding && (
{
/>
)}
{this.renderAddApiKeyForm()}
-
+ >
);
}
@@ -169,7 +169,7 @@ export class ApiKeysPage extends PureComponent {
- Add
+ Add
@@ -183,7 +183,7 @@ export class ApiKeysPage extends PureComponent {
const { apiKeys, searchQuery } = this.props;
return (
-
+ <>
@@ -199,8 +199,8 @@ export class ApiKeysPage extends PureComponent {
-
- Add API Key
+
+ Add API Key
@@ -231,7 +231,7 @@ export class ApiKeysPage extends PureComponent
{
) : null}
-
+ >
);
}
diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
index f40894426ae..2deb7fa5e7f 100644
--- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
+++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
@@ -35,118 +35,114 @@ exports[`Render should render CTA if there are no API keys 1`] = `
-
-
-
+
+
-
-
-
-
-
- Add API Key
-
-
+
+
+ Add API Key
+
+
+
+
+ Add
+
+
+
+
+
+
`;
diff --git a/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
index 0ceac9ddbba..a7616e0e513 100644
--- a/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
+++ b/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: '=',
},
};
}
diff --git a/public/app/features/dashboard/components/DashExportModal/template.html b/public/app/features/dashboard/components/DashExportModal/template.html
index 3c14c4b184d..e399d166d04 100644
--- a/public/app/features/dashboard/components/DashExportModal/template.html
+++ b/public/app/features/dashboard/components/DashExportModal/template.html
@@ -12,7 +12,7 @@
+ >
);
}
diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx
index a25f1786a5b..ab8d43cb1a3 100644
--- a/public/app/features/teams/TeamMembers.tsx
+++ b/public/app/features/teams/TeamMembers.tsx
@@ -103,7 +103,7 @@ export class TeamMembers extends PureComponent {
-
+
Add a member
@@ -117,7 +117,7 @@ export class TeamMembers extends PureComponent {
{this.state.newTeamMember && (
-
+
Add to team
)}
diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx
index ebbde595601..7a38197ff71 100644
--- a/public/app/features/teams/TeamPages.tsx
+++ b/public/app/features/teams/TeamPages.tsx
@@ -84,7 +84,7 @@ export class TeamPages extends PureComponent {
return (
- {team && Object.keys(team).length !== 0 && {this.renderPage()}
}
+ {team && Object.keys(team).length !== 0 && this.renderPage()}
);
diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx
index 01a4a3347b2..8d77cd32017 100644
--- a/public/app/features/teams/TeamSettings.tsx
+++ b/public/app/features/teams/TeamSettings.tsx
@@ -75,7 +75,7 @@ export class TeamSettings extends React.Component {
-
+
Update
diff --git a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap
index e28eb86dc7a..5d384e99ef8 100644
--- a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap
+++ b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap
@@ -62,7 +62,7 @@ exports[`Render should render component 1`] = `
className="gf-form"
>
@@ -81,7 +81,7 @@ exports[`Render should render component 1`] = `
There are no external groups to sync with
diff --git a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
index 812fe05c424..3a26b566e14 100644
--- a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
+++ b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
@@ -36,320 +36,316 @@ exports[`Render should render teams table 1`] = `
isLoading={false}
>
diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
index 5ebddb36d48..536102cb880 100644
--- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
+++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
@@ -27,7 +27,7 @@ exports[`Render should render component 1`] = `
className="page-action-bar__spacer"
/>
@@ -121,7 +121,7 @@ exports[`Render should render team members 1`] = `
className="page-action-bar__spacer"
/>
@@ -341,7 +341,7 @@ exports[`Render should render team members when sync enabled 1`] = `
className="page-action-bar__spacer"
/>
diff --git a/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap
index 0c09eb3f82d..70f37cea4c5 100644
--- a/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap
+++ b/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap
@@ -17,11 +17,7 @@ exports[`Render should render group sync page 1`] = `
-
-
-
+
`;
@@ -33,13 +29,9 @@ exports[`Render should render member page if team not empty 1`] = `
-
-
-
+
`;
@@ -51,11 +43,7 @@ exports[`Render should render settings and preferences page 1`] = `
-
-
-
+
`;
diff --git a/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap
index 49c44d3291f..8363a4f2f38 100644
--- a/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap
+++ b/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap
@@ -46,7 +46,7 @@ exports[`Render should render component 1`] = `
className="gf-form-button-row"
>
Update
diff --git a/public/app/features/teams/partials/create_team.html b/public/app/features/teams/partials/create_team.html
index 9f606408164..4e04b4b54e2 100644
--- a/public/app/features/teams/partials/create_team.html
+++ b/public/app/features/teams/partials/create_team.html
@@ -18,7 +18,7 @@
-
+
Create
diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html
index 78733b3b2be..b34ef1f4807 100644
--- a/public/app/features/templating/partials/editor.html
+++ b/public/app/features/templating/partials/editor.html
@@ -11,7 +11,7 @@