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 1a19957b3ad..30f4961343b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@ ### Minor * **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae) +* **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) + +### 6.0.0-beta1 fixes + +* **Postgres**: Fix default port not added when port not configured [#15189](https://github.com/grafana/grafana/issues/15189) # 6.0.0-beta1 (2019-01-30) diff --git a/Gopkg.lock b/Gopkg.lock index d7795cbd6ba..dca36f1b3d0 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -37,6 +37,7 @@ "aws/credentials", "aws/credentials/ec2rolecreds", "aws/credentials/endpointcreds", + "aws/credentials/processcreds", "aws/credentials/stscreds", "aws/csm", "aws/defaults", @@ -45,13 +46,18 @@ "aws/request", "aws/session", "aws/signer/v4", + "internal/ini", + "internal/s3err", "internal/sdkio", "internal/sdkrand", + "internal/sdkuri", "internal/shareddefaults", "private/protocol", "private/protocol/ec2query", "private/protocol/eventstream", "private/protocol/eventstream/eventstreamapi", + "private/protocol/json/jsonutil", + "private/protocol/jsonrpc", "private/protocol/query", "private/protocol/query/queryutil", "private/protocol/rest", @@ -60,11 +66,13 @@ "service/cloudwatch", "service/ec2", "service/ec2/ec2iface", + "service/resourcegroupstaggingapi", + "service/resourcegroupstaggingapi/resourcegroupstaggingapiiface", "service/s3", "service/sts" ] - revision = "fde4ded7becdeae4d26bf1212916aabba79349b4" - version = "v1.14.12" + revision = "62936e15518acb527a1a9cb4a39d96d94d0fd9a2" + version = "v1.16.15" [[projects]] branch = "master" 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/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 22f9f38c854..6ca10b2f8e8 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -74,6 +74,12 @@ Here is a minimal policy example: "ec2:DescribeRegions" ], "Resource": "*" + }, + { + "Sid": "AllowReadingResourcesForTags", + "Effect" : "Allow", + "Action" : "tag:GetResources", + "Resource" : "*" } ] } @@ -128,6 +134,7 @@ Name | Description *dimension_values(region, namespace, metric, dimension_key, [filters])* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well. *ebs_volume_ids(region, instance_id)* | Returns a list of volume ids matching the specified `region`, `instance_id`. *ec2_instance_attribute(region, attribute_name, filters)* | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`. +*resource_arns(region, resource_type, tags)* | Returns a list of ARNs matching the specified `region`, `resource_type` and `tags`. For details about the metrics CloudWatch provides, please refer to the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/CW_Support_For_AWS.html). @@ -143,6 +150,8 @@ Query | Service *dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)* | RDS *dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)* | S3 *dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})* | CloudWatch Agent +*resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})* | ELB +*resource_arns(eu-west-1,ec2:instance,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})* | EC2 ## ec2_instance_attribute examples @@ -205,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 46bab83654e..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] @@ -393,9 +401,7 @@ Analytics ID here. By default this feature is disabled. ### check_for_updates -Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used -in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor -send any sensitive information. +Set to false to disable all checks to https://grafana.com for new versions of installed plugins and to the Grafana GitHub repository to check for a newer version of Grafana. The version information is used in some UI views to notify that a new Grafana update or a plugin update exists. This option does not cause any auto updates, nor send any sensitive information. The check is run every 10 minutes.
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..5ac751ced3f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,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 75727f18dcb..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 ( { - 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 ddcb8971275..9af18675553 100644 --- a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss +++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss @@ -29,13 +29,14 @@ &:hover { .panel-options-group__add-circle { - background-color: $btn-primary-bg; - color: $text-color-strong; + background-color: $btn-primary-bg;; + color: $white; } } } .panel-options-group__add-circle { + @include gradientBar($btn-primary-bg, $btn-primary-bg-hl, #fff); border-radius: 50px; diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx index c15f66cca54..b2a2e07c58d 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 { 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; } @@ -164,7 +164,10 @@ export class ThresholdsEditor extends PureComponent {
{threshold.color && (
- this.onChangeThresholdColor(threshold, color)} /> + this.onChangeThresholdColor(threshold, color)} + />
)}
@@ -188,27 +191,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 923244af781..76f390defaf 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss +++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss @@ -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..aa57b46636c 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); }); }); @@ -53,12 +53,14 @@ 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)'); }); diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.ts b/packages/grafana-ui/src/utils/namedColorsPalette.ts index 5312b27ad26..ee5741e794e 100644 --- a/packages/grafana-ui/src/utils/namedColorsPalette.ts +++ b/packages/grafana-ui/src/utils/namedColorsPalette.ts @@ -1,5 +1,5 @@ import { flatten } from 'lodash'; -import { GrafanaTheme } from '../types'; +import { GrafanaThemeType } from '../types'; type Hue = 'green' | 'yellow' | 'red' | 'blue' | 'orange' | 'purple'; @@ -68,16 +68,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 +86,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 +98,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; } @@ -112,14 +112,14 @@ export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): s 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/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/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/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/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_cache.go b/pkg/models/datasource_cache.go index 66ba66e4d39..1c895514ace 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -46,19 +46,16 @@ 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, KeepAlive: 30 * time.Second, @@ -70,6 +67,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 +95,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 +103,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/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/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/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 8bb1ab6c928..8d67fe7db8c 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -21,6 +21,7 @@ import ( "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/metrics" @@ -28,7 +29,8 @@ import ( type CloudWatchExecutor struct { *models.DataSource - ec2Svc ec2iface.EC2API + ec2Svc ec2iface.EC2API + rgtaSvc resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI } type DatasourceInfo struct { diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index f898a65f911..ddda26dfd24 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -15,6 +15,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/tsdb" @@ -54,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"}, @@ -99,7 +101,7 @@ func init() { "AWS/NetworkELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "UnHealthyHostCount"}, "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"}, "AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMQueueLength", "WriteIOPS", "WriteLatency", "WriteThroughput"}, - "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, + "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, "AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"}, "AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"}, "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"}, @@ -132,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"}, @@ -200,6 +203,8 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext) case "ec2_instance_attribute": data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext) + case "resource_arns": + data, err = e.handleGetResourceArns(ctx, parameters, queryContext) } transformToTable(data, queryResult) @@ -536,6 +541,65 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context, return result, nil } +func (e *CloudWatchExecutor) ensureRGTAClientSession(region string) error { + if e.rgtaSvc == nil { + dsInfo := e.getDsInfo(region) + cfg, err := e.getAwsConfig(dsInfo) + if err != nil { + return fmt.Errorf("Failed to call ec2:getAwsConfig, %v", err) + } + sess, err := session.NewSession(cfg) + if err != nil { + return fmt.Errorf("Failed to call ec2:NewSession, %v", err) + } + e.rgtaSvc = resourcegroupstaggingapi.New(sess, cfg) + } + return nil +} + +func (e *CloudWatchExecutor) handleGetResourceArns(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + region := parameters.Get("region").MustString() + resourceType := parameters.Get("resourceType").MustString() + filterJson := parameters.Get("tags").MustMap() + + err := e.ensureRGTAClientSession(region) + if err != nil { + return nil, err + } + + var filters []*resourcegroupstaggingapi.TagFilter + for k, v := range filterJson { + if vv, ok := v.([]interface{}); ok { + var vvvvv []*string + for _, vvv := range vv { + if vvvv, ok := vvv.(string); ok { + vvvvv = append(vvvvv, &vvvv) + } + } + filters = append(filters, &resourcegroupstaggingapi.TagFilter{ + Key: aws.String(k), + Values: vvvvv, + }) + } + } + + var resourceTypes []*string + resourceTypes = append(resourceTypes, &resourceType) + + resources, err := e.resourceGroupsGetResources(region, filters, resourceTypes) + if err != nil { + return nil, err + } + + result := make([]suggestData, 0) + for _, resource := range resources.ResourceTagMappingList { + data := *resource.ResourceARN + result = append(result, suggestData{Text: data, Value: data}) + } + + return result, nil +} + func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace string, metricName string, dimensions []*cloudwatch.DimensionFilter) (*cloudwatch.ListMetricsOutput, error) { svc, err := e.getClient(region) if err != nil { @@ -587,6 +651,28 @@ func (e *CloudWatchExecutor) ec2DescribeInstances(region string, filters []*ec2. return &resp, nil } +func (e *CloudWatchExecutor) resourceGroupsGetResources(region string, filters []*resourcegroupstaggingapi.TagFilter, resourceTypes []*string) (*resourcegroupstaggingapi.GetResourcesOutput, error) { + params := &resourcegroupstaggingapi.GetResourcesInput{ + ResourceTypeFilters: resourceTypes, + TagFilters: filters, + } + + var resp resourcegroupstaggingapi.GetResourcesOutput + err := e.rgtaSvc.GetResourcesPages(params, + func(page *resourcegroupstaggingapi.GetResourcesOutput, lastPage bool) bool { + resources, _ := awsutil.ValuesAtPath(page, "ResourceTagMappingList") + for _, resource := range resources { + resp.ResourceTagMappingList = append(resp.ResourceTagMappingList, resource.(*resourcegroupstaggingapi.ResourceTagMapping)) + } + return !lastPage + }) + if err != nil { + return nil, errors.New("Failed to call tags:GetResources") + } + + return &resp, nil +} + func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) { creds, err := GetCredentials(cwData) if err != nil { diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 34c3379b4df..bc6c8b163a0 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -8,6 +8,8 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" "github.com/bmizerany/assert" "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" @@ -22,6 +24,11 @@ type mockedEc2 struct { RespRegions ec2.DescribeRegionsOutput } +type mockedRGTA struct { + resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI + Resp resourcegroupstaggingapi.GetResourcesOutput +} + func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error { fn(&m.Resp, true) return nil @@ -30,6 +37,11 @@ func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeR return &m.RespRegions, nil } +func (m mockedRGTA) GetResourcesPages(in *resourcegroupstaggingapi.GetResourcesInput, fn func(*resourcegroupstaggingapi.GetResourcesOutput, bool) bool) error { + fn(&m.Resp, true) + return nil +} + func TestCloudWatchMetrics(t *testing.T) { Convey("When calling getMetricsForCustomMetrics", t, func() { @@ -209,6 +221,51 @@ func TestCloudWatchMetrics(t *testing.T) { So(result[7].Text, ShouldEqual, "vol-4-2") }) }) + + Convey("When calling handleGetResourceArns", t, func() { + executor := &CloudWatchExecutor{ + rgtaSvc: mockedRGTA{ + Resp: resourcegroupstaggingapi.GetResourcesOutput{ + ResourceTagMappingList: []*resourcegroupstaggingapi.ResourceTagMapping{ + { + ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567"), + Tags: []*resourcegroupstaggingapi.Tag{ + { + Key: aws.String("Environment"), + Value: aws.String("production"), + }, + }, + }, + { + ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321"), + Tags: []*resourcegroupstaggingapi.Tag{ + { + Key: aws.String("Environment"), + Value: aws.String("production"), + }, + }, + }, + }, + }, + }, + } + + json := simplejson.New() + json.Set("region", "us-east-1") + json.Set("resourceType", "ec2:instance") + tags := make(map[string]interface{}) + tags["Environment"] = []string{"production"} + json.Set("tags", tags) + result, _ := executor.handleGetResourceArns(context.Background(), json, &tsdb.TsdbQuery{}) + + Convey("Should return all two instances", func() { + So(result[0].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567") + So(result[0].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567") + So(result[1].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321") + So(result[1].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321") + + }) + }) } func TestParseMultiSelectValue(t *testing.T) { 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/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx index 6b4b268eb13..d1fc506d54c 100644 --- a/public/app/core/components/AppNotifications/AppNotificationItem.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import { AppNotification } from 'app/types'; +import { AlertBox } from '../AlertBox/AlertBox'; interface Props { appNotification: AppNotification; @@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component { const { appNotification, onClearNotification } = this.props; return ( -
-
- -
-
-
{appNotification.title}
-
{appNotification.text}
-
- -
+ onClearNotification(appNotification.id)} + /> ); } } 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/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/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/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..1f289fc4b27 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -43,8 +43,6 @@ import { helpModal } from './components/help/help'; import { JsonExplorer } from './components/json_explorer/json_explorer'; import { NavModelSrv, NavModel } from './nav_model_srv'; import { geminiScrollbar } from './components/scroll/scroll'; -import { pageScrollbar } from './components/scroll/page_scroll'; -import { gfPageDirective } from './components/gf_page'; import { orgSwitcher } from './components/org_switcher'; import { profiler } from './profiler'; import { registerAngularDirectives } from './angular_wrappers'; @@ -79,8 +77,6 @@ export { NavModelSrv, NavModel, geminiScrollbar, - pageScrollbar, - gfPageDirective, orgSwitcher, manageDashboardsDirective, TimeSeries, diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index 6b39710dcca..c038ab53c9f 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -8,12 +8,13 @@ export const initialState: LocationState = { path: '', query: {}, routeParams: {}, + replace: false, }; export const locationReducer = (state = initialState, action: Action): LocationState => { switch (action.type) { case CoreActionTypes.UpdateLocation: { - const { path, routeParams } = action.payload; + const { path, routeParams, replace } = action.payload; let query = action.payload.query || state.query; if (action.payload.partial) { @@ -26,6 +27,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS path: path || state.path, query: { ...query }, routeParams: routeParams || state.routeParams, + replace: replace === true, }; } } 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..917d1801c0e 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; } @@ -174,7 +174,7 @@ export class KeybindingSrv { // edit panel this.bind('e', () => { if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { - this.$rootScope.appEvent('panel-change-view', { + appEvents.emit('panel-change-view', { fullscreen: true, edit: true, panelId: dashboard.meta.focusPanelId, @@ -186,7 +186,7 @@ export class KeybindingSrv { // view panel this.bind('v', () => { if (dashboard.meta.focusPanelId) { - this.$rootScope.appEvent('panel-change-view', { + appEvents.emit('panel-change-view', { fullscreen: true, edit: null, panelId: dashboard.meta.focusPanelId, @@ -212,9 +212,7 @@ export class KeybindingSrv { // delete panel this.bind('p r', () => { if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) { - this.$rootScope.appEvent('panel-remove', { - panelId: dashboard.meta.focusPanelId, - }); + appEvents.emit('remove-panel', dashboard.meta.focusPanelId); dashboard.meta.focusPanelId = 0; } }); 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/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/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/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/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx new file mode 100644 index 00000000000..91da066e4cc --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AddPanelWidget, Props } from './AddPanelWidget'; +import { DashboardModel, PanelModel } from '../../state'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dashboard: {} as DashboardModel, + panel: {} as PanelModel, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index dbd2fb1ffeb..135b04a8ac5 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -1,12 +1,20 @@ +// Libraries import React from 'react'; import _ from 'lodash'; + +// Utils import config from 'app/core/config'; -import { PanelModel } from '../../state/PanelModel'; -import { DashboardModel } from '../../state/DashboardModel'; import store from 'app/core/store'; -import { LS_PANEL_COPY_KEY } from 'app/core/constants'; -import { updateLocation } from 'app/core/actions'; + +// Store import { store as reduxStore } from 'app/store/store'; +import { updateLocation } from 'app/core/actions'; + +// Types +import { PanelModel } from '../../state'; +import { DashboardModel } from '../../state'; +import { LS_PANEL_COPY_KEY } from 'app/core/constants'; +import { LocationUpdate } from 'app/types'; export interface Props { panel: PanelModel; @@ -46,6 +54,7 @@ export class AddPanelWidget extends React.Component { copiedPanels.push(pluginCopy); } } + return _.sortBy(copiedPanels, 'sort'); } @@ -54,28 +63,7 @@ export class AddPanelWidget extends React.Component { this.props.dashboard.removePanel(this.props.dashboard.panels[0]); } - copyButton(panel) { - return ( - - ); - } - - moveToEdit(panel) { - reduxStore.dispatch( - updateLocation({ - query: { - panelId: panel.id, - edit: true, - fullscreen: true, - }, - partial: true, - }) - ); - } - - onCreateNewPanel = () => { + onCreateNewPanel = (tab = 'queries') => { const dashboard = this.props.dashboard; const { gridPos } = this.props.panel; @@ -88,7 +76,21 @@ export class AddPanelWidget extends React.Component { dashboard.addPanel(newPanel); dashboard.removePanel(this.props.panel); - this.moveToEdit(newPanel); + const location: LocationUpdate = { + query: { + panelId: newPanel.id, + edit: true, + fullscreen: true, + }, + partial: true, + }; + + if (tab === 'visualization') { + location.query.tab = 'visualization'; + location.query.openVizPicker = true; + } + + reduxStore.dispatch(updateLocation(location)); }; onPasteCopiedPanel = panelPluginInfo => { @@ -125,30 +127,50 @@ export class AddPanelWidget extends React.Component { dashboard.removePanel(this.props.panel); }; - render() { - let addCopyButton; + renderOptionLink = (icon, text, onClick) => { + return ( + + ); + }; - if (this.state.copiedPanelPlugins.length === 1) { - addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]); - } + render() { + const { copiedPanelPlugins } = this.state; return (
+ New Panel
- - {addCopyButton} - +
+ {this.renderOptionLink('queries', 'Add Query', this.onCreateNewPanel)} + {this.renderOptionLink('visualization', 'Choose Visualization', () => + this.onCreateNewPanel('visualization') + )} +
+
+ + {copiedPanelPlugins.length === 1 && ( + + )} +
diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss index 5a1cbee4b44..288b2e7a410 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -14,6 +14,9 @@ align-items: center; width: 100%; cursor: move; + background: $page-header-bg; + box-shadow: $page-header-shadow; + border-bottom: 1px solid $page-header-border-color; .gicon { font-size: 30px; @@ -26,6 +29,29 @@ } } +.add-panel-widget__title { + font-size: $font-size-md; + font-weight: $font-weight-semi-bold; + margin-right: $spacer*2; +} + +.add-panel-widget__link { + margin: 0 8px; + width: 154px; +} + +.add-panel-widget__icon { + margin-bottom: 8px; + + .gicon { + color: white; + height: 44px; + width: 53px; + position: relative; + left: 5px; + } +} + .add-panel-widget__close { margin-left: auto; background-color: transparent; @@ -34,14 +60,25 @@ margin-right: -10px; } +.add-panel-widget__create { + display: inherit; + margin-bottom: 24px; + // this is to have the big button appear centered + margin-top: 55px; +} + +.add-panel-widget__actions { + display: inherit; +} + +.add-panel-widget__action { + margin: 0 4px; +} + .add-panel-widget__btn-container { + height: 100%; display: flex; justify-content: center; align-items: center; - height: 100%; flex-direction: column; - - .btn { - margin-bottom: 10px; - } } diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap new file mode 100644 index 00000000000..00faf48d8df --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+
+
+ + + New Panel + + +
+
+ +
+ +
+
+
+
+`; diff --git a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts index 398ad757bf3..339c8e7de4c 100644 --- a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts +++ b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts @@ -1,5 +1,6 @@ import angular from 'angular'; import _ from 'lodash'; +import { DashboardModel } from 'app/features/dashboard/state'; export let iconMap = { 'external link': 'fa-external-link', @@ -12,7 +13,7 @@ export let iconMap = { }; export class DashLinksEditorCtrl { - dashboard: any; + dashboard: DashboardModel; iconMap: any; mode: any; link: any; @@ -40,6 +41,7 @@ export class DashLinksEditorCtrl { addLink() { this.dashboard.links.push(this.link); this.mode = 'list'; + this.dashboard.updateSubmenuVisibility(); } editLink(link) { diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx new file mode 100644 index 00000000000..297d7ca7ea7 --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -0,0 +1,253 @@ +// Libaries +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; + +// Utils & Services +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; +import { appEvents } from 'app/core/app_events'; +import { PlaylistSrv } from 'app/features/playlist/playlist_srv'; + +// Components +import { DashNavButton } from './DashNavButton'; + +// State +import { updateLocation } from 'app/core/actions'; + +// Types +import { DashboardModel } from '../../state/DashboardModel'; + +export interface Props { + dashboard: DashboardModel; + editview: string; + isEditing: boolean; + isFullscreen: boolean; + $injector: any; + updateLocation: typeof updateLocation; + onAddPanel: () => void; +} + +export class DashNav extends PureComponent { + timePickerEl: HTMLElement; + timepickerCmp: AngularComponent; + playlistSrv: PlaylistSrv; + + constructor(props: Props) { + super(props); + + this.playlistSrv = this.props.$injector.get('playlistSrv'); + } + + componentDidMount() { + const loader = getAngularLoader(); + + const template = + ''; + const scopeProps = { dashboard: this.props.dashboard }; + + this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template); + } + + componentWillUnmount() { + if (this.timepickerCmp) { + this.timepickerCmp.destroy(); + } + } + + onOpenSearch = () => { + appEvents.emit('show-dash-search'); + }; + + onClose = () => { + if (this.props.editview) { + this.props.updateLocation({ + query: { editview: null }, + partial: true, + }); + } else { + this.props.updateLocation({ + query: { panelId: null, edit: null, fullscreen: null }, + partial: true, + }); + } + }; + + onToggleTVMode = () => { + appEvents.emit('toggle-kiosk-mode'); + }; + + onSave = () => { + const { $injector } = this.props; + const dashboardSrv = $injector.get('dashboardSrv'); + dashboardSrv.saveDashboard(); + }; + + onOpenSettings = () => { + this.props.updateLocation({ + query: { editview: 'settings' }, + partial: true, + }); + }; + + onStarDashboard = () => { + const { dashboard, $injector } = this.props; + const dashboardSrv = $injector.get('dashboardSrv'); + + dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => { + dashboard.meta.isStarred = newState; + this.forceUpdate(); + }); + }; + + onPlaylistPrev = () => { + this.playlistSrv.prev(); + }; + + onPlaylistNext = () => { + this.playlistSrv.next(); + }; + + onPlaylistStop = () => { + this.playlistSrv.stop(); + this.forceUpdate(); + }; + + onOpenShare = () => { + const $rootScope = this.props.$injector.get('$rootScope'); + const modalScope = $rootScope.$new(); + modalScope.tabIndex = 0; + modalScope.dashboard = this.props.dashboard; + + appEvents.emit('show-modal', { + src: 'public/app/features/dashboard/components/ShareModal/template.html', + scope: modalScope, + }); + }; + + render() { + const { dashboard, isFullscreen, editview, onAddPanel } = this.props; + const { canStar, canSave, canShare, folderTitle, showSettings, isStarred } = dashboard.meta; + const { snapshot } = dashboard; + + const haveFolder = dashboard.meta.folderId > 0; + const snapshotUrl = snapshot && snapshot.originalUrl; + + return ( +
+ + +
+ + {this.playlistSrv.isPlaying && ( +
+ + + +
+ )} + +
+ {canSave && ( + + )} + + {canStar && ( + + )} + + {canShare && ( + + )} + + {canSave && ( + + )} + + {snapshotUrl && ( + + )} + + {showSettings && ( + + )} +
+ +
+ +
+ +
(this.timePickerEl = element)} /> + + {(isFullscreen || editview) && ( +
+ +
+ )} +
+ ); + } +} + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = { + updateLocation, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DashNav); diff --git a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx new file mode 100644 index 00000000000..505baaf1f5d --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx @@ -0,0 +1,33 @@ +// Libraries +import React, { FunctionComponent } from 'react'; + +// Components +import { Tooltip } from '@grafana/ui'; + +interface Props { + icon: string; + tooltip: string; + classSuffix: string; + onClick?: () => void; + href?: string; +} + +export const DashNavButton: FunctionComponent = ({ icon, tooltip, classSuffix, onClick, href }) => { + if (onClick) { + return ( + + + + ); + } + + return ( + + + + + + ); +}; diff --git a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts deleted file mode 100644 index e75c1468a1f..00000000000 --- a/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts +++ /dev/null @@ -1,119 +0,0 @@ -import moment from 'moment'; -import angular from 'angular'; -import { appEvents, NavModel } from 'app/core/core'; -import { DashboardModel } from '../../state/DashboardModel'; - -export class DashNavCtrl { - dashboard: DashboardModel; - navModel: NavModel; - titleTooltip: string; - - /** @ngInject */ - constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) { - appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope); - - if (this.dashboard.meta.isSnapshot) { - const meta = this.dashboard.meta; - this.titleTooltip = 'Created:  ' + moment(meta.created).calendar(); - if (meta.expires) { - this.titleTooltip += '
Expires:  ' + moment(meta.expires).fromNow() + '
'; - } - } - } - - toggleSettings() { - const search = this.$location.search(); - if (search.editview) { - delete search.editview; - } else { - search.editview = 'settings'; - } - this.$location.search(search); - } - - toggleViewMode() { - appEvents.emit('toggle-kiosk-mode'); - } - - close() { - const search = this.$location.search(); - if (search.editview) { - delete search.editview; - } else if (search.fullscreen) { - delete search.fullscreen; - delete search.edit; - delete search.tab; - delete search.panelId; - } - this.$location.search(search); - } - - starDashboard() { - this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => { - this.dashboard.meta.isStarred = newState; - }); - } - - shareDashboard(tabIndex) { - const modalScope = this.$scope.$new(); - modalScope.tabIndex = tabIndex; - modalScope.dashboard = this.dashboard; - - appEvents.emit('show-modal', { - src: 'public/app/features/dashboard/components/ShareModal/template.html', - scope: modalScope, - }); - } - - hideTooltip(evt) { - angular.element(evt.currentTarget).tooltip('hide'); - } - - saveDashboard() { - return this.dashboardSrv.saveDashboard(); - } - - showSearch() { - if (this.dashboard.meta.fullscreen) { - this.close(); - return; - } - - appEvents.emit('show-dash-search'); - } - - addPanel() { - appEvents.emit('dash-scroll', { animate: true, evt: 0 }); - - if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') { - return; // Return if the "Add panel" exists already - } - - this.dashboard.addPanel({ - type: 'add-panel', - gridPos: { x: 0, y: 0, w: 12, h: 8 }, - title: 'Panel Title', - }); - } - - navItemClicked(navItem, evt) { - if (navItem.clickHandler) { - navItem.clickHandler(); - evt.preventDefault(); - } - } -} - -export function dashNavDirective() { - return { - restrict: 'E', - templateUrl: 'public/app/features/dashboard/components/DashNav/template.html', - controller: DashNavCtrl, - bindToController: true, - controllerAs: 'ctrl', - transclude: true, - scope: { dashboard: '=' }, - }; -} - -angular.module('grafana.directives').directive('dashnav', dashNavDirective); diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts index 854e32b24d2..be07fd0d2a3 100644 --- a/public/app/features/dashboard/components/DashNav/index.ts +++ b/public/app/features/dashboard/components/DashNav/index.ts @@ -1 +1,2 @@ -export { DashNavCtrl } from './DashNavCtrl'; +import DashNav from './DashNav'; +export { DashNav }; diff --git a/public/app/features/dashboard/components/DashNav/template.html b/public/app/features/dashboard/components/DashNav/template.html deleted file mode 100644 index e50a8cd0bff..00000000000 --- a/public/app/features/dashboard/components/DashNav/template.html +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx index 9ac6a6b74e1..96b673242e4 100644 --- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx @@ -9,6 +9,7 @@ describe('DashboardRow', () => { beforeEach(() => { dashboardMock = { toggleRow: jest.fn(), + on: jest.fn(), meta: { canEdit: true, }, diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx index e7778a31fdb..bb63cea90ea 100644 --- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx @@ -18,11 +18,11 @@ export class DashboardRow extends React.Component { collapsed: this.props.panel.collapsed, }; - appEvents.on('template-variable-value-updated', this.onVariableUpdated); + this.props.dashboard.on('template-variable-value-updated', this.onVariableUpdated); } componentWillUnmount() { - appEvents.off('template-variable-value-updated', this.onVariableUpdated); + this.props.dashboard.off('template-variable-value-updated', this.onVariableUpdated); } onVariableUpdated = () => { diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx new file mode 100644 index 00000000000..8a92c0d69eb --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -0,0 +1,36 @@ +// Libaries +import React, { PureComponent } from 'react'; + +// Utils & Services +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; + +// Types +import { DashboardModel } from '../../state/DashboardModel'; + +export interface Props { + dashboard: DashboardModel | null; +} + +export class DashboardSettings extends PureComponent { + element: HTMLElement; + angularCmp: AngularComponent; + + componentDidMount() { + const loader = getAngularLoader(); + + const template = ''; + const scopeProps = { dashboard: this.props.dashboard }; + + this.angularCmp = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.angularCmp) { + this.angularCmp.destroy(); + } + } + + render() { + return
this.element = element} />; + } +} diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts index f81b8cdbc67..0a89feada33 100644 --- a/public/app/features/dashboard/components/DashboardSettings/index.ts +++ b/public/app/features/dashboard/components/DashboardSettings/index.ts @@ -1 +1,2 @@ export { SettingsCtrl } from './SettingsCtrl'; +export { DashboardSettings } from './DashboardSettings'; diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx new file mode 100644 index 00000000000..caef8f2de38 --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -0,0 +1,36 @@ +// Libaries +import React, { PureComponent } from 'react'; + +// Utils & Services +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; + +// Types +import { DashboardModel } from '../../state/DashboardModel'; + +export interface Props { + dashboard: DashboardModel | null; +} + +export class SubMenu extends PureComponent { + element: HTMLElement; + angularCmp: AngularComponent; + + componentDidMount() { + const loader = getAngularLoader(); + + const template = ''; + const scopeProps = { dashboard: this.props.dashboard }; + + this.angularCmp = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.angularCmp) { + this.angularCmp.destroy(); + } + } + + render() { + return
this.element = element} />; + } +} diff --git a/public/app/features/dashboard/components/SubMenu/index.ts b/public/app/features/dashboard/components/SubMenu/index.ts index 1790aa66782..ca113ab75d6 100644 --- a/public/app/features/dashboard/components/SubMenu/index.ts +++ b/public/app/features/dashboard/components/SubMenu/index.ts @@ -1 +1,2 @@ export { SubMenuCtrl } from './SubMenuCtrl'; +export { SubMenu } from './SubMenu'; diff --git a/public/app/features/dashboard/components/SubMenu/template.html b/public/app/features/dashboard/components/SubMenu/template.html index 5d0f200d862..1ccbfcc915c 100644 --- a/public/app/features/dashboard/components/SubMenu/template.html +++ b/public/app/features/dashboard/components/SubMenu/template.html @@ -7,7 +7,7 @@
- +
diff --git a/public/app/features/dashboard/containers/DashboardCtrl.ts b/public/app/features/dashboard/containers/DashboardCtrl.ts deleted file mode 100644 index 74795315504..00000000000 --- a/public/app/features/dashboard/containers/DashboardCtrl.ts +++ /dev/null @@ -1,156 +0,0 @@ -// Utils -import config from 'app/core/config'; -import appEvents from 'app/core/app_events'; -import coreModule from 'app/core/core_module'; -import { removePanel } from 'app/features/dashboard/utils/panel'; - -// Services -import { AnnotationsSrv } from '../../annotations/annotations_srv'; - -// Types -import { DashboardModel } from '../state/DashboardModel'; - -export class DashboardCtrl { - dashboard: DashboardModel; - dashboardViewState: any; - loadedFallbackDashboard: boolean; - editTab: number; - - /** @ngInject */ - constructor( - private $scope, - private keybindingSrv, - private timeSrv, - private variableSrv, - private dashboardSrv, - private unsavedChangesSrv, - private dashboardViewStateSrv, - private annotationsSrv: AnnotationsSrv, - public playlistSrv - ) { - // temp hack due to way dashboards are loaded - // can't use controllerAs on route yet - $scope.ctrl = this; - - // TODO: break out settings view to separate view & controller - this.editTab = 0; - - // funcs called from React component bindings and needs this binding - this.getPanelContainer = this.getPanelContainer.bind(this); - } - - setupDashboard(data) { - try { - this.setupDashboardInternal(data); - } catch (err) { - this.onInitFailed(err, 'Dashboard init failed', true); - } - } - - setupDashboardInternal(data) { - const dashboard = this.dashboardSrv.create(data.dashboard, data.meta); - this.dashboardSrv.setCurrent(dashboard); - - // init services - this.timeSrv.init(dashboard); - this.annotationsSrv.init(dashboard); - - // template values service needs to initialize completely before - // the rest of the dashboard can load - this.variableSrv - .init(dashboard) - // template values failes are non fatal - .catch(this.onInitFailed.bind(this, 'Templating init failed', false)) - // continue - .finally(() => { - this.dashboard = dashboard; - this.dashboard.processRepeats(); - this.dashboard.updateSubmenuVisibility(); - this.dashboard.autoFitPanels(window.innerHeight); - - this.unsavedChangesSrv.init(dashboard, this.$scope); - - // TODO refactor ViewStateSrv - this.$scope.dashboard = dashboard; - this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope); - - this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); - this.setWindowTitleAndTheme(); - - appEvents.emit('dashboard-initialized', dashboard); - }) - .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); - } - - onInitFailed(msg, fatal, err) { - console.log(msg, err); - - if (err.data && err.data.message) { - err.message = err.data.message; - } else if (!err.message) { - err = { message: err.toString() }; - } - - this.$scope.appEvent('alert-error', [msg, err.message]); - - // protect against recursive fallbacks - if (fatal && !this.loadedFallbackDashboard) { - this.loadedFallbackDashboard = true; - this.setupDashboard({ dashboard: { title: 'Dashboard Init failed' } }); - } - } - - templateVariableUpdated() { - this.dashboard.processRepeats(); - } - - setWindowTitleAndTheme() { - window.document.title = config.windowTitlePrefix + this.dashboard.title; - } - - showJsonEditor(evt, options) { - const model = { - object: options.object, - updateHandler: options.updateHandler, - }; - - this.$scope.appEvent('show-dash-editor', { - src: 'public/app/partials/edit_json.html', - model: model, - }); - } - - getDashboard() { - return this.dashboard; - } - - getPanelContainer() { - return this; - } - - onRemovingPanel(evt, options) { - options = options || {}; - if (!options.panelId) { - return; - } - - const panelInfo = this.dashboard.getPanelInfoById(options.panelId); - removePanel(this.dashboard, panelInfo.panel, true); - } - - onDestroy() { - if (this.dashboard) { - this.dashboard.destroy(); - } - } - - init(dashboard) { - this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this)); - this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this)); - this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this)); - this.$scope.$on('$destroy', this.onDestroy.bind(this)); - this.setupDashboard(dashboard); - } -} - -coreModule.controller('DashboardCtrl', DashboardCtrl); diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx new file mode 100644 index 00000000000..1e6f8bd888e --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { DashboardPage, Props, State } from './DashboardPage'; +import { DashboardModel } from '../state'; +import { cleanUpDashboard } from '../state/actions'; +import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux'; +import { DashboardRouteInfo, DashboardInitPhase } from 'app/types'; + +jest.mock('sass/_variables.scss', () => ({ + panelhorizontalpadding: 10, + panelVerticalPadding: 10, +})); + +jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); + +interface ScenarioContext { + cleanUpDashboardMock: NoPayloadActionCreatorMock; + dashboard?: DashboardModel; + setDashboardProp: (overrides?: any, metaOverrides?: any) => void; + wrapper?: ShallowWrapper; + mount: (propOverrides?: Partial) => void; + setup?: (fn: () => void) => void; +} + +function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel { + const data = Object.assign({ + title: 'My dashboard', + panels: [ + { + id: 1, + type: 'graph', + title: 'My graph', + gridPos: { x: 0, y: 0, w: 1, h: 1 }, + }, + ], + }, overrides); + + const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides); + return new DashboardModel(data, meta); +} + +function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) => void) { + describe(description, () => { + let setupFn: () => void; + + const ctx: ScenarioContext = { + cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard), + setup: fn => { + setupFn = fn; + }, + setDashboardProp: (overrides?: any, metaOverrides?: any) => { + ctx.dashboard = getTestDashboard(overrides, metaOverrides); + ctx.wrapper.setProps({ dashboard: ctx.dashboard }); + }, + mount: (propOverrides?: Partial) => { + const props: Props = { + urlSlug: 'my-dash', + $scope: {}, + urlUid: '11', + $injector: {}, + routeInfo: DashboardRouteInfo.Normal, + urlEdit: false, + urlFullscreen: false, + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, + initDashboard: jest.fn(), + updateLocation: jest.fn(), + notifyApp: jest.fn(), + cleanUpDashboard: ctx.cleanUpDashboardMock, + dashboard: null, + }; + + Object.assign(props, propOverrides); + + ctx.dashboard = props.dashboard; + ctx.wrapper = shallow(); + } + }; + + beforeEach(() => { + setupFn(); + }); + + scenarioFn(ctx); + }); +} + +describe('DashboardPage', () => { + + dashboardPageScenario("Given initial state", (ctx) => { + ctx.setup(() => { + ctx.mount(); + }); + + it('Should render nothing', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario("Dashboard is fetching slowly", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.wrapper.setProps({ + isInitSlow: true, + initPhase: DashboardInitPhase.Fetching, + }); + }); + + it('Should render slow init state', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario("Dashboard init completed ", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + }); + + it('Should update title', () => { + expect(document.title).toBe('My dashboard - Grafana'); + }); + + it('Should render dashboard grid', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + }); + + dashboardPageScenario("When user goes into panel edit", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setProps({ + urlFullscreen: true, + urlEdit: true, + urlPanelId: '1', + }); + }); + + it('Should update model state to fullscreen & edit', () => { + expect(ctx.dashboard.meta.fullscreen).toBe(true); + expect(ctx.dashboard.meta.isEditing).toBe(true); + }); + + it('Should update component state to fullscreen and edit', () => { + const state = ctx.wrapper.state(); + expect(state.isEditing).toBe(true); + expect(state.isFullscreen).toBe(true); + }); + }); + + dashboardPageScenario("When user goes back to dashboard from panel edit", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setState({ scrollTop: 100 }); + ctx.wrapper.setProps({ + urlFullscreen: true, + urlEdit: true, + urlPanelId: '1', + }); + ctx.wrapper.setProps({ + urlFullscreen: false, + urlEdit: false, + urlPanelId: null, + }); + }); + + it('Should update model state normal state', () => { + expect(ctx.dashboard.meta.fullscreen).toBe(false); + expect(ctx.dashboard.meta.isEditing).toBe(false); + }); + + it('Should update component state to normal and restore scrollTop', () => { + const state = ctx.wrapper.state(); + expect(state.isEditing).toBe(false); + expect(state.isFullscreen).toBe(false); + expect(state.scrollTop).toBe(100); + }); + }); + + dashboardPageScenario("When dashboard has editview url state", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setProps({ + editview: 'settings', + }); + }); + + it('should render settings view', () => { + expect(ctx.wrapper).toMatchSnapshot(); + }); + + it('should set animation state', () => { + expect(ctx.wrapper.state().isSettingsOpening).toBe(true); + }); + }); + + dashboardPageScenario("When adding panel", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp(); + ctx.wrapper.setState({ scrollTop: 100 }); + ctx.wrapper.instance().onAddPanel(); + }); + + it('should set scrollTop to 0', () => { + expect(ctx.wrapper.state().scrollTop).toBe(0); + }); + + it('should add panel widget to dashboard panels', () => { + expect(ctx.dashboard.panels[0].type).toBe('add-panel'); + }); + }); + + dashboardPageScenario("Given panel with id 0", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph'}], + schemaVersion: 17, + }); + ctx.wrapper.setProps({ + urlEdit: true, + urlFullscreen: true, + urlPanelId: '0' + }); + }); + + it('Should go into edit mode' , () => { + expect(ctx.wrapper.state().isEditing).toBe(true); + expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0); + }); + }); + + dashboardPageScenario("When dashboard unmounts", (ctx) => { + ctx.setup(() => { + ctx.mount(); + ctx.setDashboardProp({ + panels: [{ id: 0, type: 'graph'}], + schemaVersion: 17, + }); + ctx.wrapper.unmount(); + }); + + it('Should call clean up action' , () => { + expect(ctx.cleanUpDashboardMock.calls).toBe(1); + }); + }); +}); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx new file mode 100644 index 00000000000..27118e297b5 --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -0,0 +1,309 @@ +// Libraries +import $ from 'jquery'; +import React, { PureComponent, MouseEvent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; + +// Services & Utils +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { getMessageFromError } from 'app/core/utils/errors'; + +// Components +import { DashboardGrid } from '../dashgrid/DashboardGrid'; +import { DashNav } from '../components/DashNav'; +import { SubMenu } from '../components/SubMenu'; +import { DashboardSettings } from '../components/DashboardSettings'; +import { CustomScrollbar } from '@grafana/ui'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; + +// Redux +import { initDashboard } from '../state/initDashboard'; +import { cleanUpDashboard } from '../state/actions'; +import { updateLocation } from 'app/core/actions'; +import { notifyApp } from 'app/core/actions'; + +// Types +import { + StoreState, + DashboardInitPhase, + DashboardRouteInfo, + DashboardInitError, + AppNotificationSeverity, +} from 'app/types'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; + +export interface Props { + urlUid?: string; + urlSlug?: string; + urlType?: string; + editview?: string; + urlPanelId?: string; + urlFolderId?: string; + $scope: any; + $injector: any; + routeInfo: DashboardRouteInfo; + urlEdit: boolean; + urlFullscreen: boolean; + initPhase: DashboardInitPhase; + isInitSlow: boolean; + dashboard: DashboardModel | null; + initError?: DashboardInitError; + initDashboard: typeof initDashboard; + cleanUpDashboard: typeof cleanUpDashboard; + notifyApp: typeof notifyApp; + updateLocation: typeof updateLocation; +} + +export interface State { + isSettingsOpening: boolean; + isEditing: boolean; + isFullscreen: boolean; + fullscreenPanel: PanelModel | null; + scrollTop: number; + rememberScrollTop: number; + showLoadingState: boolean; +} + +export class DashboardPage extends PureComponent { + state: State = { + isSettingsOpening: false, + isEditing: false, + isFullscreen: false, + showLoadingState: false, + fullscreenPanel: null, + scrollTop: 0, + rememberScrollTop: 0, + }; + + async componentDidMount() { + this.props.initDashboard({ + $injector: this.props.$injector, + $scope: this.props.$scope, + urlSlug: this.props.urlSlug, + urlUid: this.props.urlUid, + urlType: this.props.urlType, + urlFolderId: this.props.urlFolderId, + routeInfo: this.props.routeInfo, + fixUrl: true, + }); + } + + componentWillUnmount() { + if (this.props.dashboard) { + this.props.cleanUpDashboard(); + } + } + + componentDidUpdate(prevProps: Props) { + const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId } = this.props; + + if (!dashboard) { + return; + } + + // if we just got dashboard update title + if (!prevProps.dashboard) { + document.title = dashboard.title + ' - Grafana'; + } + + // handle animation states when opening dashboard settings + if (!prevProps.editview && editview) { + this.setState({ isSettingsOpening: true }); + setTimeout(() => { + this.setState({ isSettingsOpening: false }); + }, 10); + } + + // Sync url state with model + if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) { + if (!isNaN(parseInt(urlPanelId, 10))) { + this.onEnterFullscreen(); + } else { + this.onLeaveFullscreen(); + } + } + } + + onEnterFullscreen() { + const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props; + + const panelId = parseInt(urlPanelId, 10); + + // need to expand parent row if this panel is inside a row + dashboard.expandParentRowFor(panelId); + + const panel = dashboard.getPanelById(panelId); + + if (panel) { + dashboard.setViewMode(panel, urlFullscreen, urlEdit); + this.setState({ + isEditing: urlEdit && dashboard.meta.canEdit, + isFullscreen: urlFullscreen, + fullscreenPanel: panel, + rememberScrollTop: this.state.scrollTop, + }); + this.setPanelFullscreenClass(urlFullscreen); + } else { + this.handleFullscreenPanelNotFound(urlPanelId); + } + } + + onLeaveFullscreen() { + const { dashboard } = this.props; + + if (this.state.fullscreenPanel) { + dashboard.setViewMode(this.state.fullscreenPanel, false, false); + } + + this.setState( + { + isEditing: false, + isFullscreen: false, + fullscreenPanel: null, + scrollTop: this.state.rememberScrollTop, + }, + () => { + dashboard.render(); + } + ); + + this.setPanelFullscreenClass(false); + } + + handleFullscreenPanelNotFound(urlPanelId: string) { + // Panel not found + this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`)); + // Clear url state + this.props.updateLocation({ + query: { + edit: null, + fullscreen: null, + panelId: null, + }, + partial: true, + }); + } + + setPanelFullscreenClass(isFullscreen: boolean) { + $('body').toggleClass('panel-in-fullscreen', isFullscreen); + } + + setScrollTop = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop }); + }; + + onAddPanel = () => { + const { dashboard } = this.props; + + // Return if the "Add panel" exists already + if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') { + return; + } + + dashboard.addPanel({ + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + title: 'Panel Title', + }); + + // scroll to top after adding panel + this.setState({ scrollTop: 0 }); + }; + + renderSlowInitState() { + return ( +
+
+ {this.props.initPhase} +
+
+ ); + } + + renderInitFailedState() { + const { initError } = this.props; + + return ( +
+ +
+ ); + } + + render() { + const { dashboard, editview, $injector, isInitSlow, initError } = this.props; + const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state; + + if (!dashboard) { + if (isInitSlow) { + return this.renderSlowInitState(); + } + return null; + } + + const classes = classNames({ + 'dashboard-page--settings-opening': isSettingsOpening, + 'dashboard-page--settings-open': !isSettingsOpening && editview, + }); + + const gridWrapperClasses = classNames({ + 'dashboard-container': true, + 'dashboard-container--has-submenu': dashboard.meta.submenuEnabled, + }); + + return ( +
+ +
+ + {editview && } + + {initError && this.renderInitFailedState()} + +
+ {dashboard.meta.submenuEnabled && } + +
+
+
+
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + urlUid: state.location.routeParams.uid, + urlSlug: state.location.routeParams.slug, + urlType: state.location.routeParams.type, + editview: state.location.query.editview, + urlPanelId: state.location.query.panelId, + urlFolderId: state.location.query.folderId, + urlFullscreen: state.location.query.fullscreen === true, + urlEdit: state.location.query.edit === true, + initPhase: state.dashboard.initPhase, + isInitSlow: state.dashboard.isInitSlow, + initError: state.dashboard.initError, + dashboard: state.dashboard.model as DashboardModel, +}); + +const mapDispatchToProps = { + initDashboard, + cleanUpDashboard, + notifyApp, + updateLocation, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DashboardPage)); diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx index 097c8015929..6dcf2775547 100644 --- a/public/app/features/dashboard/containers/SoloPanelPage.tsx +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -3,98 +3,84 @@ import React, { Component } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -// Utils & Services -import appEvents from 'app/core/app_events'; -import locationUtil from 'app/core/utils/location_util'; -import { getBackendSrv } from 'app/core/services/backend_srv'; - // Components import { DashboardPanel } from '../dashgrid/DashboardPanel'; // Redux -import { updateLocation } from 'app/core/actions'; +import { initDashboard } from '../state/initDashboard'; // Types -import { StoreState } from 'app/types'; +import { StoreState, DashboardRouteInfo } from 'app/types'; import { PanelModel, DashboardModel } from 'app/features/dashboard/state'; interface Props { - panelId: string; + urlPanelId: string; urlUid?: string; urlSlug?: string; urlType?: string; $scope: any; $injector: any; - updateLocation: typeof updateLocation; + routeInfo: DashboardRouteInfo; + initDashboard: typeof initDashboard; + dashboard: DashboardModel | null; } interface State { panel: PanelModel | null; - dashboard: DashboardModel | null; notFound: boolean; } export class SoloPanelPage extends Component { - state: State = { panel: null, - dashboard: null, notFound: false, }; componentDidMount() { - const { $injector, $scope, urlUid, urlType, urlSlug } = this.props; + const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props; - // handle old urls with no uid - if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) { - this.redirectToNewUrl(); - return; - } - - const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv'); - - // subscribe to event to know when dashboard controller is done with inititalization - appEvents.on('dashboard-initialized', this.onDashoardInitialized); - - dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => { - result.meta.soloMode = true; - $scope.initDashboard(result, $scope); + this.props.initDashboard({ + $injector: $injector, + $scope: $scope, + urlSlug: urlSlug, + urlUid: urlUid, + urlType: urlType, + routeInfo: routeInfo, + fixUrl: false, }); } - redirectToNewUrl() { - getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => { - if (res) { - const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/')); - this.props.updateLocation(url); + componentDidUpdate(prevProps: Props) { + const { urlPanelId, dashboard } = this.props; + + if (!dashboard) { + return; + } + + // we just got the dashboard! + if (!prevProps.dashboard) { + const panelId = parseInt(urlPanelId, 10); + + // need to expand parent row if this panel is inside a row + dashboard.expandParentRowFor(panelId); + + const panel = dashboard.getPanelById(panelId); + + if (!panel) { + this.setState({ notFound: true }); + return; } - }); - } - onDashoardInitialized = () => { - const { $scope, panelId } = this.props; - - const dashboard: DashboardModel = $scope.dashboard; - const panel = dashboard.getPanelById(parseInt(panelId, 10)); - - if (!panel) { - this.setState({ notFound: true }); - return; + this.setState({ panel }); } - - this.setState({ dashboard, panel }); - }; + } render() { - const { panelId } = this.props; - const { notFound, panel, dashboard } = this.state; + const { urlPanelId, dashboard } = this.props; + const { notFound, panel } = this.state; if (notFound) { - return ( -
- Panel with id { panelId } not found -
- ); + return
Panel with id {urlPanelId} not found
; } if (!panel) { @@ -113,11 +99,12 @@ const mapStateToProps = (state: StoreState) => ({ urlUid: state.location.routeParams.uid, urlSlug: state.location.routeParams.slug, urlType: state.location.routeParams.type, - panelId: state.location.query.panelId + urlPanelId: state.location.query.panelId, + dashboard: state.dashboard.model as DashboardModel, }); const mapDispatchToProps = { - updateLocation + initDashboard, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage)); diff --git a/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap new file mode 100644 index 00000000000..002cac2306e --- /dev/null +++ b/public/app/features/dashboard/containers/__snapshots__/DashboardPage.test.tsx.snap @@ -0,0 +1,546 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = ` +
+ +
+ +
+ +
+
+
+
+`; + +exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = ` +
+
+ + + Fetching +
+
+`; + +exports[`DashboardPage Given initial state Should render nothing 1`] = `""`; + +exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = ` +
+ +
+ + +
+ +
+
+
+
+`; diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 658bfad3816..27f699ff3e6 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -1,11 +1,14 @@ -import React from 'react'; +// Libaries +import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import ReactGridLayout, { ItemCallback } from 'react-grid-layout'; +import classNames from 'classnames'; +import sizeMe from 'react-sizeme'; + +// Types import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { DashboardPanel } from './DashboardPanel'; import { DashboardModel, PanelModel } from '../state'; -import classNames from 'classnames'; -import sizeMe from 'react-sizeme'; let lastGridWidth = 1200; let ignoreNextWidthChange = false; @@ -76,19 +79,18 @@ function GridWrapper({ const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); -export interface DashboardGridProps { +export interface Props { dashboard: DashboardModel; + isEditing: boolean; + isFullscreen: boolean; } -export class DashboardGrid extends React.Component { +export class DashboardGrid extends PureComponent { gridToPanelMap: any; panelMap: { [id: string]: PanelModel }; - constructor(props: DashboardGridProps) { - super(props); - - // subscribe to dashboard events - const dashboard = this.props.dashboard; + componentDidMount() { + const { dashboard } = this.props; dashboard.on('panel-added', this.triggerForceUpdate); dashboard.on('panel-removed', this.triggerForceUpdate); dashboard.on('repeats-processed', this.triggerForceUpdate); @@ -97,6 +99,16 @@ export class DashboardGrid extends React.Component { dashboard.on('row-expanded', this.triggerForceUpdate); } + componentWillUnmount() { + const { dashboard } = this.props; + dashboard.off('panel-added', this.triggerForceUpdate); + dashboard.off('panel-removed', this.triggerForceUpdate); + dashboard.off('repeats-processed', this.triggerForceUpdate); + dashboard.off('view-mode-changed', this.onViewModeChanged); + dashboard.off('row-collapsed', this.triggerForceUpdate); + dashboard.off('row-expanded', this.triggerForceUpdate); + } + buildLayout() { const layout = []; this.panelMap = {}; @@ -151,7 +163,6 @@ export class DashboardGrid extends React.Component { onViewModeChanged = () => { ignoreNextWidthChange = true; - this.forceUpdate(); } updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => { @@ -197,18 +208,20 @@ export class DashboardGrid extends React.Component { } render() { + const { dashboard, isFullscreen } = this.props; + return ( {this.renderPanels()} diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts index 9f2935660ef..d9a03b0aad6 100644 --- a/public/app/features/dashboard/index.ts +++ b/public/app/features/dashboard/index.ts @@ -1,8 +1,6 @@ -import './containers/DashboardCtrl'; import './dashgrid/DashboardGridDirective'; // Services -import './services/DashboardViewStateSrv'; import './services/UnsavedChangesSrv'; import './services/DashboardLoaderSrv'; import './services/DashboardSrv'; diff --git a/public/app/features/dashboard/panel_editor/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx index d7aafb89e55..bfdc13bc8f2 100644 --- a/public/app/features/dashboard/panel_editor/PanelEditor.tsx +++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { QueriesTab } from './QueriesTab'; -import { VisualizationTab } from './VisualizationTab'; +import VisualizationTab from './VisualizationTab'; import { GeneralTab } from './GeneralTab'; import { AlertTab } from '../../alerting/AlertTab'; @@ -38,7 +38,7 @@ export class PanelEditor extends PureComponent { onChangeTab = (tab: PanelEditorTab) => { store.dispatch( updateLocation({ - query: { tab: tab.id }, + query: { tab: tab.id, openVizPicker: null }, partial: true, }) ); diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx index eda10087d41..83ef70f62e7 100644 --- a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx +++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx @@ -7,10 +7,11 @@ import _ from 'lodash'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; import { Emitter } from 'app/core/utils/emitter'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; // Types import { PanelModel } from '../state/PanelModel'; -import { DataQuery, DataSourceApi } from '@grafana/ui'; +import { DataQuery, DataSourceApi, TimeRange } from '@grafana/ui'; interface Props { panel: PanelModel; @@ -43,8 +44,15 @@ export class QueryEditorRow extends PureComponent { componentDidMount() { this.loadDatasource(); + this.props.panel.events.on('refresh', this.onPanelRefresh); } + onPanelRefresh = () => { + if (this.state.angularScope) { + this.state.angularScope.range = getTimeSrv().timeRange(); + } + }; + getAngularQueryComponentScope(): AngularQueryComponentScope { const { panel, query } = this.props; const { datasource } = this.state; @@ -56,6 +64,7 @@ export class QueryEditorRow extends PureComponent { refresh: () => panel.refresh(), render: () => panel.render(), events: panel.events, + range: getTimeSrv().timeRange(), }; } @@ -97,6 +106,8 @@ export class QueryEditorRow extends PureComponent { } componentWillUnmount() { + this.props.panel.events.off('refresh', this.onPanelRefresh); + if (this.angularQueryEditor) { this.angularQueryEditor.destroy(); } @@ -250,4 +261,5 @@ export interface AngularQueryComponentScope { datasource: DataSourceApi; toggleEditorMode?: () => void; getCollapsedText?: () => string; + range: TimeRange; } diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx index 35b9b71112a..0aeb8af41d9 100644 --- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx +++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx @@ -3,6 +3,9 @@ import React, { PureComponent } from 'react'; // Utils & Services import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; +import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; +import { StoreState } from 'app/types'; +import { updateLocation } from 'app/core/actions'; // Components import { EditorTabBody, EditorToolbarView } from './EditorTabBody'; @@ -21,6 +24,8 @@ interface Props { plugin: PanelPlugin; angularPanel?: AngularComponent; onTypeChanged: (newType: PanelPlugin) => void; + updateLocation: typeof updateLocation; + urlOpenVizPicker: boolean; } interface State { @@ -38,7 +43,7 @@ export class VisualizationTab extends PureComponent { super(props); this.state = { - isVizPickerOpen: false, + isVizPickerOpen: this.props.urlOpenVizPicker, searchQuery: '', scrollTop: 0, }; @@ -114,7 +119,12 @@ export class VisualizationTab extends PureComponent { template += `
` + - (i > 0 ? `
{{ctrl.editorTabs[${i}].title}}
` : '') + + (i > 0 + ? `
+ {{ctrl.editorTabs[${i}].title}} + +
` + : '') + `
@@ -149,6 +159,10 @@ export class VisualizationTab extends PureComponent { }; onCloseVizPicker = () => { + if (this.props.urlOpenVizPicker) { + this.props.updateLocation({ query: { openVizPicker: null }, partial: true }); + } + this.setState({ isVizPickerOpen: false }); }; @@ -219,8 +233,13 @@ export class VisualizationTab extends PureComponent { }; return ( - + <> { ); } } + +const mapStateToProps = (state: StoreState) => ({ + urlOpenVizPicker: !!state.location.query.openVizPicker, +}); + +const mapDispatchToProps = { + updateLocation, +}; + +export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps); diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 03aeb34ed36..eccb1df9e43 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -1,25 +1,74 @@ import coreModule from 'app/core/core_module'; -import { DashboardModel } from '../state/DashboardModel'; +import { appEvents } from 'app/core/app_events'; import locationUtil from 'app/core/utils/location_util'; +import { DashboardModel } from '../state/DashboardModel'; +import { removePanel } from '../utils/panel'; export class DashboardSrv { - dash: any; + dashboard: DashboardModel; /** @ngInject */ - constructor(private backendSrv, private $rootScope, private $location) {} + constructor(private backendSrv, private $rootScope, private $location) { + appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope); + appEvents.on('panel-change-view', this.onPanelChangeView); + appEvents.on('remove-panel', this.onRemovePanel); + } create(dashboard, meta) { return new DashboardModel(dashboard, meta); } - setCurrent(dashboard) { - this.dash = dashboard; + setCurrent(dashboard: DashboardModel) { + this.dashboard = dashboard; } - getCurrent() { - return this.dash; + getCurrent(): DashboardModel { + return this.dashboard; } + onRemovePanel = (panelId: number) => { + const dashboard = this.getCurrent(); + removePanel(dashboard, dashboard.getPanelById(panelId), true); + }; + + onPanelChangeView = (options) => { + const urlParams = this.$location.search(); + + // handle toggle logic + if (options.fullscreen === urlParams.fullscreen) { + // I hate using these truthy converters (!!) but in this case + // I think it's appropriate. edit can be null/false/undefined and + // here i want all of those to compare the same + if (!!options.edit === !!urlParams.edit) { + delete urlParams.fullscreen; + delete urlParams.edit; + delete urlParams.panelId; + this.$location.search(urlParams); + return; + } + } + + if (options.fullscreen) { + urlParams.fullscreen = true; + } else { + delete urlParams.fullscreen; + } + + if (options.edit) { + urlParams.edit = true; + } else { + delete urlParams.edit; + } + + if (options.panelId || options.panelId === 0) { + urlParams.panelId = options.panelId; + } else { + delete urlParams.panelId; + } + + this.$location.search(urlParams); + }; + handleSaveDashboardError(clone, options, err) { options = options || {}; options.overwrite = true; @@ -75,10 +124,10 @@ export class DashboardSrv { } postSave(clone, data) { - this.dash.version = data.version; + this.dashboard.version = data.version; // important that these happens before location redirect below - this.$rootScope.appEvent('dashboard-saved', this.dash); + this.$rootScope.appEvent('dashboard-saved', this.dashboard); this.$rootScope.appEvent('alert-success', ['Dashboard saved']); const newUrl = locationUtil.stripBaseFromUrl(data.url); @@ -88,12 +137,12 @@ export class DashboardSrv { this.$location.url(newUrl).replace(); } - return this.dash; + return this.dashboard; } save(clone, options) { options = options || {}; - options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId; + options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId; return this.backendSrv .saveDashboard(clone, options) @@ -103,26 +152,26 @@ export class DashboardSrv { saveDashboard(options?, clone?) { if (clone) { - this.setCurrent(this.create(clone, this.dash.meta)); + this.setCurrent(this.create(clone, this.dashboard.meta)); } - if (this.dash.meta.provisioned) { + if (this.dashboard.meta.provisioned) { return this.showDashboardProvisionedModal(); } - if (!this.dash.meta.canSave && options.makeEditable !== true) { + if (!this.dashboard.meta.canSave && options.makeEditable !== true) { return Promise.resolve(); } - if (this.dash.title === 'New dashboard') { + if (this.dashboard.title === 'New dashboard') { return this.showSaveAsModal(); } - if (this.dash.version > 0) { + if (this.dashboard.version > 0) { return this.showSaveModal(); } - return this.save(this.dash.getSaveModelClone(), options); + return this.save(this.dashboard.getSaveModelClone(), options); } saveJSONDashboard(json: string) { @@ -163,8 +212,8 @@ export class DashboardSrv { } return promise.then(res => { - if (this.dash && this.dash.id === dashboardId) { - this.dash.meta.isStarred = res; + if (this.dashboard && this.dashboard.id === dashboardId) { + this.dashboard.meta.isStarred = res; } return res; }); diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts deleted file mode 100644 index 12bb11b7a08..00000000000 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import config from 'app/core/config'; -import { DashboardViewStateSrv } from './DashboardViewStateSrv'; -import { DashboardModel } from '../state/DashboardModel'; - -describe('when updating view state', () => { - const location = { - replace: jest.fn(), - search: jest.fn(), - }; - - const $scope = { - appEvent: jest.fn(), - onAppEvent: jest.fn(() => {}), - dashboard: new DashboardModel({ - panels: [{ id: 1 }], - }), - }; - - let viewState; - - beforeEach(() => { - config.bootData = { - user: { - orgId: 1, - }, - }; - }); - - describe('to fullscreen true and edit true', () => { - beforeEach(() => { - location.search = jest.fn(() => { - return { fullscreen: true, edit: true, panelId: 1 }; - }); - viewState = new DashboardViewStateSrv($scope, location, {}); - }); - - it('should update querystring and view state', () => { - const updateState = { fullscreen: true, edit: true, panelId: 1 }; - - viewState.update(updateState); - - expect(location.search).toHaveBeenCalledWith({ - edit: true, - editview: null, - fullscreen: true, - orgId: 1, - panelId: 1, - }); - expect(viewState.dashboard.meta.fullscreen).toBe(true); - expect(viewState.state.fullscreen).toBe(true); - }); - }); - - describe('to fullscreen false', () => { - beforeEach(() => { - viewState = new DashboardViewStateSrv($scope, location, {}); - }); - it('should remove params from query string', () => { - viewState.update({ fullscreen: true, panelId: 1, edit: true }); - viewState.update({ fullscreen: false }); - expect(viewState.state.fullscreen).toBe(null); - }); - }); -}); diff --git a/public/app/features/dashboard/services/DashboardViewStateSrv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts deleted file mode 100644 index fc38c3b241f..00000000000 --- a/public/app/features/dashboard/services/DashboardViewStateSrv.ts +++ /dev/null @@ -1,185 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash'; -import config from 'app/core/config'; -import appEvents from 'app/core/app_events'; -import { DashboardModel } from '../state/DashboardModel'; - -// represents the transient view state -// like fullscreen panel & edit -export class DashboardViewStateSrv { - state: any; - panelScopes: any; - $scope: any; - dashboard: DashboardModel; - fullscreenPanel: any; - oldTimeRange: any; - - /** @ngInject */ - constructor($scope, private $location, private $timeout) { - const self = this; - self.state = {}; - self.panelScopes = []; - self.$scope = $scope; - self.dashboard = $scope.dashboard; - - $scope.onAppEvent('$routeUpdate', () => { - const urlState = self.getQueryStringState(); - if (self.needsSync(urlState)) { - self.update(urlState, true); - } - }); - - $scope.onAppEvent('panel-change-view', (evt, payload) => { - self.update(payload); - }); - - // this marks changes to location during this digest cycle as not to add history item - // don't want url changes like adding orgId to add browser history - $location.replace(); - this.update(this.getQueryStringState()); - } - - needsSync(urlState) { - return _.isEqual(this.state, urlState) === false; - } - - getQueryStringState() { - const state = this.$location.search(); - state.panelId = parseInt(state.panelId, 10) || null; - state.fullscreen = state.fullscreen ? true : null; - state.edit = state.edit === 'true' || state.edit === true || null; - state.editview = state.editview || null; - state.orgId = config.bootData.user.orgId; - return state; - } - - serializeToUrl() { - const urlState = _.clone(this.state); - urlState.fullscreen = this.state.fullscreen ? true : null; - urlState.edit = this.state.edit ? true : null; - return urlState; - } - - update(state, fromRouteUpdated?) { - // implement toggle logic - if (state.toggle) { - delete state.toggle; - if (this.state.fullscreen && state.fullscreen) { - if (this.state.edit === state.edit) { - state.fullscreen = !state.fullscreen; - } - } - } - - _.extend(this.state, state); - - if (!this.state.fullscreen) { - this.state.fullscreen = null; - this.state.edit = null; - // clear panel id unless in solo mode - if (!this.dashboard.meta.soloMode) { - this.state.panelId = null; - } - } - - if ((this.state.fullscreen || this.dashboard.meta.soloMode) && this.state.panelId) { - // Trying to render panel in fullscreen when it's in the collapsed row causes an issue. - // So in this case expand collapsed row first. - this.toggleCollapsedPanelRow(this.state.panelId); - } - - // if no edit state cleanup tab parm - if (!this.state.edit) { - delete this.state.tab; - } - - // do not update url params if we are here - // from routeUpdated event - if (fromRouteUpdated !== true) { - this.$location.search(this.serializeToUrl()); - } - - this.syncState(); - } - - toggleCollapsedPanelRow(panelId) { - for (const panel of this.dashboard.panels) { - if (panel.collapsed) { - for (const rowPanel of panel.panels) { - if (rowPanel.id === panelId) { - this.dashboard.toggleRow(panel); - return; - } - } - } - } - } - - syncState() { - if (this.state.fullscreen) { - const panel = this.dashboard.getPanelById(this.state.panelId); - - if (!panel) { - this.state.fullscreen = null; - this.state.panelId = null; - this.state.edit = null; - - this.update(this.state); - - setTimeout(() => { - appEvents.emit('alert-error', ['Error', 'Panel not found']); - }, 100); - - return; - } - - if (!panel.fullscreen) { - this.enterFullscreen(panel); - } else if (this.dashboard.meta.isEditing !== this.state.edit) { - this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit); - } - } else if (this.fullscreenPanel) { - this.leaveFullscreen(); - } - } - - leaveFullscreen() { - const panel = this.fullscreenPanel; - - this.dashboard.setViewMode(panel, false, false); - - delete this.fullscreenPanel; - - this.$timeout(() => { - appEvents.emit('dash-scroll', { restore: true }); - - if (this.oldTimeRange !== this.dashboard.time) { - this.dashboard.startRefresh(); - } else { - this.dashboard.render(); - } - }); - } - - enterFullscreen(panel) { - const isEditing = this.state.edit && this.dashboard.meta.canEdit; - - this.oldTimeRange = this.dashboard.time; - this.fullscreenPanel = panel; - - // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode() - this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 }); - this.dashboard.setViewMode(panel, true, isEditing); - } -} - -/** @ngInject */ -export function dashboardViewStateSrv($location, $timeout) { - return { - create: $scope => { - return new DashboardViewStateSrv($scope, $location, $timeout); - }, - }; -} - -angular.module('grafana.services').factory('dashboardViewStateSrv', dashboardViewStateSrv); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 929b984a93f..aec6421d12a 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -1,20 +1,26 @@ +// Libaries import moment from 'moment'; import _ from 'lodash'; -import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; +// Constants +import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui'; import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; + +// Utils & Services import { Emitter } from 'app/core/utils/emitter'; import { contextSrv } from 'app/core/services/context_srv'; import sortByKeys from 'app/core/utils/sort_by_keys'; +// Types import { PanelModel } from './PanelModel'; import { DashboardMigrator } from './DashboardMigrator'; import { TimeRange } from '@grafana/ui/src'; +import { UrlQueryValue, KIOSK_MODE_TV, DashboardMeta } from 'app/types'; export class DashboardModel { id: any; - uid: any; - title: any; + uid: string; + title: string; autoUpdate: any; description: any; tags: any; @@ -43,7 +49,7 @@ export class DashboardModel { // repeat process cycles iteration: number; - meta: any; + meta: DashboardMeta; events: Emitter; static nonPersistedProperties: { [str: string]: boolean } = { @@ -127,6 +133,8 @@ export class DashboardModel { meta.canEdit = meta.canEdit !== false; meta.showSettings = meta.canEdit; meta.canMakeEditable = meta.canSave && !this.editable; + meta.fullscreen = false; + meta.isEditing = false; if (!this.editable) { meta.canEdit = false; @@ -860,11 +868,7 @@ export class DashboardModel { return !_.isEqual(updated, this.originalTemplating); } - autoFitPanels(viewHeight: number) { - if (!this.meta.autofitpanels) { - return; - } - + autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) { const currentGridHeight = Math.max( ...this.panels.map(panel => { return panel.gridPos.h + panel.gridPos.y; @@ -878,12 +882,12 @@ export class DashboardModel { let visibleHeight = viewHeight - navbarHeight - margin; // Remove submenu height if visible - if (this.meta.submenuEnabled && !this.meta.kiosk) { + if (this.meta.submenuEnabled && !kioskMode) { visibleHeight -= submenuHeight; } // add back navbar height - if (this.meta.kiosk === 'b') { + if (kioskMode === KIOSK_MODE_TV) { visibleHeight += 55; } @@ -895,4 +899,23 @@ export class DashboardModel { panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1; }); } + + templateVariableValueUpdated() { + this.processRepeats(); + this.events.emit('template-variable-value-updated'); + } + + expandParentRowFor(panelId: number) { + for (const panel of this.panels) { + if (panel.collapsed) { + for (const rowPanel of panel.panels) { + if (rowPanel.id === panelId) { + this.toggleRow(panel); + return; + } + } + } + } + } + } diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 4dcf0a925b7..f5911a233f7 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,39 +1,43 @@ -import { StoreState } from 'app/types'; -import { ThunkAction } from 'redux-thunk'; +// Services & Utils import { getBackendSrv } from 'app/core/services/backend_srv'; -import appEvents from 'app/core/app_events'; +import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; + +// Actions import { loadPluginDashboards } from '../../plugins/state/actions'; +import { notifyApp } from 'app/core/actions'; + +// Types import { + ThunkResult, DashboardAcl, DashboardAclDTO, PermissionLevel, DashboardAclUpdateDTO, NewDashboardAclItem, -} from 'app/types/acl'; + MutableDashboard, + DashboardInitError, +} from 'app/types'; -export enum ActionTypes { - LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS', - LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS', -} +export const loadDashboardPermissions = actionCreatorFactory('LOAD_DASHBOARD_PERMISSIONS').create(); -export interface LoadDashboardPermissionsAction { - type: ActionTypes.LoadDashboardPermissions; - payload: DashboardAcl[]; -} +export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create(); -export interface LoadStarredDashboardsAction { - type: ActionTypes.LoadStarredDashboards; - payload: DashboardAcl[]; -} +export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create(); -export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction; +export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create(); -type ThunkResult = ThunkAction; +export const dashboardInitCompleted = actionCreatorFactory('DASHBOARD_INIT_COMLETED').create(); -export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({ - type: ActionTypes.LoadDashboardPermissions, - payload: items, -}); +/* + * Unrecoverable init failure (fetch or model creation failed) + */ +export const dashboardInitFailed = actionCreatorFactory('DASHBOARD_INIT_FAILED').create(); + +/* + * When leaving dashboard, resets state + * */ +export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create(); export function getDashboardPermissions(id: number): ThunkResult { return async dispatch => { @@ -124,7 +128,7 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar export function importDashboard(data, dashboardTitle: string): ThunkResult { return async dispatch => { await getBackendSrv().post('/api/dashboards/import', data); - appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]); + dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle))); dispatch(loadPluginDashboards()); }; } @@ -135,3 +139,4 @@ export function removeDashboard(uri: string): ThunkResult { dispatch(loadPluginDashboards()); }; } + diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts new file mode 100644 index 00000000000..ebef6fd8494 --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -0,0 +1,152 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { initDashboard, InitDashboardArgs } from './initDashboard'; +import { DashboardRouteInfo } from 'app/types'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { + dashboardInitFetching, + dashboardInitCompleted, + dashboardInitServices, +} from './actions'; + +jest.mock('app/core/services/backend_srv'); + +const mockStore = configureMockStore([thunk]); + +interface ScenarioContext { + args: InitDashboardArgs; + timeSrv: any; + annotationsSrv: any; + unsavedChangesSrv: any; + variableSrv: any; + dashboardSrv: any; + keybindingSrv: any; + backendSrv: any; + setup: (fn: () => void) => void; + actions: any[]; + storeState: any; +} + +type ScenarioFn = (ctx: ScenarioContext) => void; + +function describeInitScenario(description: string, scenarioFn: ScenarioFn) { + describe(description, () => { + const timeSrv = { init: jest.fn() }; + const annotationsSrv = { init: jest.fn() }; + const unsavedChangesSrv = { init: jest.fn() }; + const variableSrv = { init: jest.fn() }; + const dashboardSrv = { setCurrent: jest.fn() }; + const keybindingSrv = { setupDashboardBindings: jest.fn() }; + + const injectorMock = { + get: (name: string) => { + switch (name) { + case 'timeSrv': + return timeSrv; + case 'annotationsSrv': + return annotationsSrv; + case 'unsavedChangesSrv': + return unsavedChangesSrv; + case 'dashboardSrv': + return dashboardSrv; + case 'variableSrv': + return variableSrv; + case 'keybindingSrv': + return keybindingSrv; + default: + throw { message: 'Unknown service ' + name }; + } + }, + }; + + let setupFn = () => {}; + + const ctx: ScenarioContext = { + args: { + $injector: injectorMock, + $scope: {}, + fixUrl: false, + routeInfo: DashboardRouteInfo.Normal, + }, + backendSrv: getBackendSrv(), + timeSrv, + annotationsSrv, + unsavedChangesSrv, + variableSrv, + dashboardSrv, + keybindingSrv, + actions: [], + storeState: { + location: { + query: {}, + }, + user: {}, + }, + setup: (fn: () => void) => { + setupFn = fn; + }, + }; + + beforeEach(async () => { + setupFn(); + + const store = mockStore(ctx.storeState); + + await store.dispatch(initDashboard(ctx.args)); + + ctx.actions = store.getActions(); + }); + + scenarioFn(ctx); + }); +} + +describeInitScenario('Initializing new dashboard', ctx => { + ctx.setup(() => { + ctx.storeState.user.orgId = 12; + ctx.args.routeInfo = DashboardRouteInfo.New; + }); + + it('Should send action dashboardInitFetching', () => { + expect(ctx.actions[0].type).toBe(dashboardInitFetching.type); + }); + + it('Should send action dashboardInitServices ', () => { + expect(ctx.actions[1].type).toBe(dashboardInitServices.type); + }); + + it('Should update location with orgId query param', () => { + expect(ctx.actions[2].type).toBe('UPDATE_LOCATION'); + expect(ctx.actions[2].payload.query.orgId).toBe(12); + }); + + it('Should send action dashboardInitCompleted', () => { + expect(ctx.actions[3].type).toBe(dashboardInitCompleted.type); + expect(ctx.actions[3].payload.title).toBe('New dashboard'); + }); + + it('Should Initializing services', () => { + expect(ctx.timeSrv.init).toBeCalled(); + expect(ctx.annotationsSrv.init).toBeCalled(); + expect(ctx.variableSrv.init).toBeCalled(); + expect(ctx.unsavedChangesSrv.init).toBeCalled(); + expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled(); + expect(ctx.dashboardSrv.setCurrent).toBeCalled(); + }); +}); + +describeInitScenario('Initializing home dashboard', ctx => { + ctx.setup(() => { + ctx.args.routeInfo = DashboardRouteInfo.Home; + ctx.backendSrv.get.mockReturnValue(Promise.resolve({ + redirectUri: '/u/123/my-home' + })); + }); + + it('Should redirect to custom home dashboard', () => { + expect(ctx.actions[1].type).toBe('UPDATE_LOCATION'); + expect(ctx.actions[1].payload.path).toBe('/u/123/my-home'); + }); +}); + + diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts new file mode 100644 index 00000000000..e6f83780430 --- /dev/null +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -0,0 +1,233 @@ +// Services & Utils +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; +import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { AnnotationsSrv } from 'app/features/annotations/annotations_srv'; +import { VariableSrv } from 'app/features/templating/variable_srv'; +import { KeybindingSrv } from 'app/core/services/keybindingSrv'; + +// Actions +import { updateLocation } from 'app/core/actions'; +import { notifyApp } from 'app/core/actions'; +import locationUtil from 'app/core/utils/location_util'; +import { + dashboardInitFetching, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitSlow, + dashboardInitServices, +} from './actions'; + +// Types +import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types'; +import { DashboardModel } from './DashboardModel'; + +export interface InitDashboardArgs { + $injector: any; + $scope: any; + urlUid?: string; + urlSlug?: string; + urlType?: string; + urlFolderId?: string; + routeInfo: DashboardRouteInfo; + fixUrl: boolean; +} + +async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) { + const res = await getBackendSrv().getDashboardBySlug(slug); + + if (res) { + let newUrl = res.meta.url; + + // fix solo route urls + if (currentPath.indexOf('dashboard-solo') !== -1) { + newUrl = newUrl.replace('/d/', '/d-solo/'); + } + + const url = locationUtil.stripBaseFromUrl(newUrl); + dispatch(updateLocation({ path: url, partial: true, replace: true })); + } +} + +async function fetchDashboard( + args: InitDashboardArgs, + dispatch: ThunkDispatch, + getState: () => StoreState +): Promise { + try { + switch (args.routeInfo) { + case DashboardRouteInfo.Home: { + // load home dash + const dashDTO: DashboardDTO = await getBackendSrv().get('/api/dashboards/home'); + + // if user specified a custom home dashboard redirect to that + if (dashDTO.redirectUri) { + const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); + dispatch(updateLocation({ path: newUrl, replace: true })); + return null; + } + + // disable some actions on the default home dashboard + dashDTO.meta.canSave = false; + dashDTO.meta.canShare = false; + dashDTO.meta.canStar = false; + return dashDTO; + } + case DashboardRouteInfo.Normal: { + // for old db routes we redirect + if (args.urlType === 'db') { + redirectToNewUrl(args.urlSlug, dispatch, getState().location.path); + return null; + } + + const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv'); + const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); + + if (args.fixUrl && dashDTO.meta.url) { + // check if the current url is correct (might be old slug) + const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url); + const currentPath = getState().location.path; + + if (dashboardUrl !== currentPath) { + // replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times. + dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true })); + return null; + } + } + return dashDTO; + } + case DashboardRouteInfo.New: { + return getNewDashboardModelData(args.urlFolderId); + } + default: + throw { message: 'Unknown route ' + args.routeInfo }; + } + } catch (err) { + dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err })); + console.log(err); + return null; + } +} + +/** + * This action (or saga) does everything needed to bootstrap a dashboard & dashboard model. + * First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates) + * + * This is used both for single dashboard & solo panel routes, home & new dashboard routes. + * + * Then it handles the initializing of the old angular services that the dashboard components & panels still depend on + * + */ +export function initDashboard(args: InitDashboardArgs): ThunkResult { + return async (dispatch, getState) => { + // set fetching state + dispatch(dashboardInitFetching()); + + // Detect slow loading / initializing and set state flag + // This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing + setTimeout(() => { + if (getState().dashboard.model === null) { + dispatch(dashboardInitSlow()); + } + }, 500); + + // fetch dashboard data + const dashDTO = await fetchDashboard(args, dispatch, getState); + + // returns null if there was a redirect or error + if (!dashDTO) { + return; + } + + // set initializing state + dispatch(dashboardInitServices()); + + // create model + let dashboard: DashboardModel; + try { + dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta); + } catch (err) { + dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err })); + console.log(err); + return; + } + + // add missing orgId query param + const storeState = getState(); + if (!storeState.location.query.orgId) { + dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true })); + } + + // init services + const timeSrv: TimeSrv = args.$injector.get('timeSrv'); + const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv'); + const variableSrv: VariableSrv = args.$injector.get('variableSrv'); + const keybindingSrv: KeybindingSrv = args.$injector.get('keybindingSrv'); + const unsavedChangesSrv = args.$injector.get('unsavedChangesSrv'); + const dashboardSrv: DashboardSrv = args.$injector.get('dashboardSrv'); + + timeSrv.init(dashboard); + annotationsSrv.init(dashboard); + + // template values service needs to initialize completely before + // the rest of the dashboard can load + try { + await variableSrv.init(dashboard); + } catch (err) { + dispatch(notifyApp(createErrorNotification('Templating init failed', err))); + console.log(err); + } + + try { + dashboard.processRepeats(); + dashboard.updateSubmenuVisibility(); + + // handle auto fix experimental feature + const queryParams = getState().location.query; + if (queryParams.autofitpanels) { + dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk); + } + + // init unsaved changes tracking + unsavedChangesSrv.init(dashboard, args.$scope); + keybindingSrv.setupDashboardBindings(args.$scope, dashboard); + } catch (err) { + dispatch(notifyApp(createErrorNotification('Dashboard init failed', err))); + console.log(err); + } + + // legacy srv state + dashboardSrv.setCurrent(dashboard); + // yay we are done + dispatch(dashboardInitCompleted(dashboard)); + }; +} + +function getNewDashboardModelData(urlFolderId?: string): any { + const data = { + meta: { + canStar: false, + canShare: false, + isNew: true, + folderId: 0, + }, + dashboard: { + title: 'New dashboard', + panels: [ + { + type: 'add-panel', + gridPos: { x: 0, y: 0, w: 12, h: 9 }, + title: 'Panel Title', + }, + ], + }, + }; + + if (urlFolderId) { + data.meta.folderId = parseInt(urlFolderId, 10); + } + + return data; +} diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts index ced8866aad8..800ba27e800 100644 --- a/public/app/features/dashboard/state/reducers.test.ts +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -1,19 +1,23 @@ -import { Action, ActionTypes } from './actions'; -import { OrgRole, PermissionLevel, DashboardState } from 'app/types'; +import { + loadDashboardPermissions, + dashboardInitFetching, + dashboardInitCompleted, + dashboardInitFailed, + dashboardInitSlow, +} from './actions'; +import { OrgRole, PermissionLevel, DashboardState, DashboardInitPhase } from 'app/types'; import { initialState, dashboardReducer } from './reducers'; +import { DashboardModel } from './DashboardModel'; describe('dashboard reducer', () => { describe('loadDashboardPermissions', () => { let state: DashboardState; beforeEach(() => { - const action: Action = { - type: ActionTypes.LoadDashboardPermissions, - payload: [ - { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, - { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, - ], - }; + const action = loadDashboardPermissions([ + { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, + { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, + ]); state = dashboardReducer(initialState, action); }); @@ -21,4 +25,47 @@ describe('dashboard reducer', () => { expect(state.permissions.length).toBe(2); }); }); + + describe('dashboardInitCompleted', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitSlow()); + state = dashboardReducer(state, dashboardInitCompleted(new DashboardModel({ title: 'My dashboard' }))); + }); + + it('should set model', async () => { + expect(state.model.title).toBe('My dashboard'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + }); + + describe('dashboardInitFailed', () => { + let state: DashboardState; + + beforeEach(() => { + state = dashboardReducer(initialState, dashboardInitFetching()); + state = dashboardReducer(state, dashboardInitFailed({message: 'Oh no', error: 'sad'})); + }); + + it('should set model', async () => { + expect(state.model.title).toBe('Dashboard init failed'); + }); + + it('should set reset isInitSlow', async () => { + expect(state.isInitSlow).toBe(false); + }); + + it('should set initError', async () => { + expect(state.initError.message).toBe('Oh no'); + }); + + it('should set phase failed', async () => { + expect(state.initPhase).toBe(DashboardInitPhase.Failed); + }); + }); }); diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts index 8a79a6c9f77..7a320beee93 100644 --- a/public/app/features/dashboard/state/reducers.ts +++ b/public/app/features/dashboard/state/reducers.ts @@ -1,21 +1,90 @@ -import { DashboardState } from 'app/types'; -import { Action, ActionTypes } from './actions'; +import { DashboardState, DashboardInitPhase } from 'app/types'; +import { + loadDashboardPermissions, + dashboardInitFetching, + dashboardInitSlow, + dashboardInitServices, + dashboardInitFailed, + dashboardInitCompleted, + cleanUpDashboard, +} from './actions'; +import { reducerFactory } from 'app/core/redux'; import { processAclItems } from 'app/core/utils/acl'; +import { DashboardModel } from './DashboardModel'; export const initialState: DashboardState = { + initPhase: DashboardInitPhase.NotStarted, + isInitSlow: false, + model: null, permissions: [], }; -export const dashboardReducer = (state = initialState, action: Action): DashboardState => { - switch (action.type) { - case ActionTypes.LoadDashboardPermissions: +export const dashboardReducer = reducerFactory(initialState) + .addMapper({ + filter: loadDashboardPermissions, + mapper: (state, action) => ({ + ...state, + permissions: processAclItems(action.payload), + }), + }) + .addMapper({ + filter: dashboardInitFetching, + mapper: state => ({ + ...state, + initPhase: DashboardInitPhase.Fetching, + }), + }) + .addMapper({ + filter: dashboardInitServices, + mapper: state => ({ + ...state, + initPhase: DashboardInitPhase.Services, + }), + }) + .addMapper({ + filter: dashboardInitSlow, + mapper: state => ({ + ...state, + isInitSlow: true, + }), + }) + .addMapper({ + filter: dashboardInitFailed, + mapper: (state, action) => ({ + ...state, + initPhase: DashboardInitPhase.Failed, + isInitSlow: false, + initError: action.payload, + model: new DashboardModel({ title: 'Dashboard init failed' }, { canSave: false, canEdit: false }), + }), + }) + .addMapper({ + filter: dashboardInitCompleted, + mapper: (state, action) => ({ + ...state, + initPhase: DashboardInitPhase.Completed, + model: action.payload, + isInitSlow: false, + }), + }) + .addMapper({ + filter: cleanUpDashboard, + mapper: (state, action) => { + + // Destroy current DashboardModel + // Very important as this removes all dashboard event listeners + state.model.destroy(); + return { ...state, - permissions: processAclItems(action.payload), + initPhase: DashboardInitPhase.NotStarted, + model: null, + isInitSlow: false, + initError: null, }; - } - return state; -}; + }, + }) + .create(); export default { dashboard: dashboardReducer, diff --git a/public/app/features/datasources/partials/http_settings.html b/public/app/features/datasources/partials/http_settings.html index 521e2d3cdc6..b6f2c4fc0dd 100644 --- a/public/app/features/datasources/partials/http_settings.html +++ b/public/app/features/datasources/partials/http_settings.html @@ -101,53 +101,5 @@
-
-
-
TLS Auth Details
- TLS Certs are encrypted and stored in the Grafana database. -
-
-
-
- -
-
- -
- -
- - reset -
-
-
- -
-
-
- -
-
- -
-
- - reset -
-
- -
-
- -
-
- -
-
- - reset -
-
-
-
- + + \ No newline at end of file diff --git a/public/app/features/datasources/partials/tls_auth_settings.html b/public/app/features/datasources/partials/tls_auth_settings.html new file mode 100644 index 00000000000..c852e8ec70c --- /dev/null +++ b/public/app/features/datasources/partials/tls_auth_settings.html @@ -0,0 +1,62 @@ +
+
+
TLS Auth Details
+ TLS Certs are encrypted and stored in the Grafana database. +
+
+
+
+
+ +
+ +
+ + reset +
+
+
+ +
+
+
+
+ +
+
+ + reset +
+
+ +
+
+
+ +
+
+ + reset +
+
+
+
diff --git a/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts new file mode 100644 index 00000000000..7c21fab404c --- /dev/null +++ b/public/app/features/datasources/settings/TlsAuthSettingsCtrl.ts @@ -0,0 +1,10 @@ +import { coreModule } from 'app/core/core'; + +coreModule.directive('datasourceTlsAuthSettings', () => { + return { + scope: { + current: '=', + }, + templateUrl: 'public/app/features/datasources/partials/tls_auth_settings.html', + }; +}); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index b210bcccc18..a28776d813a 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -205,28 +205,34 @@ export class Explore extends React.PureComponent {
- {({ width }) => ( -
- - {showingStartPage && } - {!showingStartPage && ( - <> - {supportsGraph && !supportsLogs && } - {supportsTable && } - {supportsLogs && ( - - )} - - )} - -
- )} + {({ width }) => { + if (width === 0) { + return null; + } + + return ( +
+ + {showingStartPage && } + {!showingStartPage && ( + <> + {supportsGraph && !supportsLogs && } + {supportsTable && } + {supportsLogs && ( + + )} + + )} + +
+ ); + }}
)} diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 35f06d11c81..786998c96c1 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -8,6 +8,7 @@ import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { StoreState } from 'app/types/store'; import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions'; import TimePicker from './TimePicker'; +import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper'; enum IconSide { left = 'left', @@ -79,6 +80,10 @@ export class UnConnectedExploreToolbar extends PureComponent { this.props.runQuery(this.props.exploreId); }; + onCloseTimePicker = () => { + this.props.timepickerRef.current.setState({ isOpen: false }); + }; + render() { const { datasourceMissing, @@ -97,10 +102,10 @@ export class UnConnectedExploreToolbar extends PureComponent {
{exploreId === 'left' && ( - + Explore - + )}
@@ -137,7 +142,9 @@ export class UnConnectedExploreToolbar extends PureComponent {
) : null}
- + + +
+ +
+
+ + +
+
+ +
+
+ + + Connection limits @@ -84,4 +99,3 @@

- diff --git a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx index 94521041416..c3bd9212b21 100644 --- a/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/stackdriver/components/QueryEditor.tsx @@ -10,21 +10,21 @@ import { Alignments } from './Alignments'; import { AlignmentPeriods } from './AlignmentPeriods'; import { AliasBy } from './AliasBy'; import { Help } from './Help'; -import { Target, MetricDescriptor } from '../types'; +import { StackdriverQuery, MetricDescriptor } from '../types'; import { getAlignmentPickerData } from '../functions'; import StackdriverDatasource from '../datasource'; import { SelectOptionItem } from '@grafana/ui'; export interface Props { - onQueryChange: (target: Target) => void; + onQueryChange: (target: StackdriverQuery) => void; onExecuteQuery: () => void; - target: Target; + target: StackdriverQuery; events: any; datasource: StackdriverDatasource; templateSrv: TemplateSrv; } -interface State extends Target { +interface State extends StackdriverQuery { alignOptions: SelectOptionItem[]; lastQuery: string; lastQueryError: string; diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index 025955105a7..4c1f07e0a06 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -2,9 +2,10 @@ import { stackdriverUnitMappings } from './constants'; import appEvents from 'app/core/app_events'; import _ from 'lodash'; import StackdriverMetricFindQuery from './StackdriverMetricFindQuery'; -import { MetricDescriptor } from './types'; +import { StackdriverQuery, MetricDescriptor } from './types'; +import { DataSourceApi, DataQueryOptions } from '@grafana/ui/src/types'; -export default class StackdriverDatasource { +export default class StackdriverDatasource implements DataSourceApi { id: number; url: string; baseUrl: string; @@ -39,9 +40,7 @@ export default class StackdriverDatasource { alignmentPeriod: this.templateSrv.replace(t.alignmentPeriod, options.scopedVars || {}), groupBys: this.interpolateGroupBys(t.groupBys, options.scopedVars), view: t.view || 'FULL', - filters: (t.filters || []).map(f => { - return this.templateSrv.replace(f, options.scopedVars || {}); - }), + filters: this.interpolateFilters(t.filters, options.scopedVars), aliasBy: this.templateSrv.replace(t.aliasBy, options.scopedVars || {}), type: 'timeSeriesQuery', }; @@ -63,7 +62,13 @@ export default class StackdriverDatasource { } } - async getLabels(metricType, refId) { + interpolateFilters(filters: string[], scopedVars: object) { + return (filters || []).map(f => { + return this.templateSrv.replace(f, scopedVars || {}, 'regex'); + }); + } + + async getLabels(metricType: string, refId: string) { const response = await this.getTimeSeries({ targets: [ { @@ -103,7 +108,7 @@ export default class StackdriverDatasource { return unit; } - async query(options) { + async query(options: DataQueryOptions) { const result = []; const data = await this.getTimeSeries(options); if (data.results) { diff --git a/public/app/plugins/datasource/stackdriver/query_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_ctrl.ts index c6a8a4d9782..3a2d0bb970a 100644 --- a/public/app/plugins/datasource/stackdriver/query_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/query_ctrl.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { QueryCtrl } from 'app/plugins/sdk'; -import { Target } from './types'; +import { StackdriverQuery } from './types'; import { TemplateSrv } from 'app/features/templating/template_srv'; export class StackdriverQueryCtrl extends QueryCtrl { @@ -16,7 +16,7 @@ export class StackdriverQueryCtrl extends QueryCtrl { this.onExecuteQuery = this.onExecuteQuery.bind(this); } - onQueryChange(target: Target) { + onQueryChange(target: StackdriverQuery) { Object.assign(this.target, target); } diff --git a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts index 46cdd77b7a9..032f10d8ca5 100644 --- a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts +++ b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts @@ -1,7 +1,8 @@ import StackdriverDataSource from '../datasource'; import { metricDescriptors } from './testData'; import moment from 'moment'; -import { TemplateSrvStub } from 'test/specs/helpers'; +import { TemplateSrv } from 'app/features/templating/template_srv'; +import { CustomVariable } from 'app/features/templating/all'; describe('StackdriverDataSource', () => { const instanceSettings = { @@ -9,7 +10,7 @@ describe('StackdriverDataSource', () => { defaultProject: 'testproject', }, }; - const templateSrv = new TemplateSrvStub(); + const templateSrv = new TemplateSrv(); const timeSrv = {}; describe('when performing testDataSource', () => { @@ -154,15 +155,41 @@ describe('StackdriverDataSource', () => { }); }); + describe('when interpolating a template variable for the filter', () => { + let interpolated; + describe('and is single value variable', () => { + beforeEach(() => { + const filterTemplateSrv = initTemplateSrv('filtervalue1'); + const ds = new StackdriverDataSource(instanceSettings, {}, filterTemplateSrv, timeSrv); + interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '${test}'], {}); + }); + + it('should replace the variable with the value', () => { + expect(interpolated.length).toBe(3); + expect(interpolated[2]).toBe('filtervalue1'); + }); + }); + + describe('and is multi value variable', () => { + beforeEach(() => { + const filterTemplateSrv = initTemplateSrv(['filtervalue1', 'filtervalue2'], true); + const ds = new StackdriverDataSource(instanceSettings, {}, filterTemplateSrv, timeSrv); + interpolated = ds.interpolateFilters(['resource.label.zone', '=~', '[[test]]'], {}); + }); + + it('should replace the variable with a regex expression', () => { + expect(interpolated[2]).toBe('(filtervalue1|filtervalue2)'); + }); + }); + }); + describe('when interpolating a template variable for group bys', () => { let interpolated; describe('and is single value variable', () => { beforeEach(() => { - templateSrv.data = { - test: 'groupby1', - }; - const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv); + const groupByTemplateSrv = initTemplateSrv('groupby1'); + const ds = new StackdriverDataSource(instanceSettings, {}, groupByTemplateSrv, timeSrv); interpolated = ds.interpolateGroupBys(['[[test]]'], {}); }); @@ -174,10 +201,8 @@ describe('StackdriverDataSource', () => { describe('and is multi value variable', () => { beforeEach(() => { - templateSrv.data = { - test: 'groupby1,groupby2', - }; - const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv); + const groupByTemplateSrv = initTemplateSrv(['groupby1', 'groupby2'], true); + const ds = new StackdriverDataSource(instanceSettings, {}, groupByTemplateSrv, timeSrv); interpolated = ds.interpolateGroupBys(['[[test]]'], {}); }); @@ -241,3 +266,19 @@ describe('StackdriverDataSource', () => { }); }); }); +function initTemplateSrv(values: any, multi = false) { + const templateSrv = new TemplateSrv(); + templateSrv.init([ + new CustomVariable( + { + name: 'test', + current: { + value: values, + }, + multi: multi, + }, + {} + ), + ]); + return templateSrv; +} diff --git a/public/app/plugins/datasource/stackdriver/types.ts b/public/app/plugins/datasource/stackdriver/types.ts index 29b12b4289d..b9a6893d4bd 100644 --- a/public/app/plugins/datasource/stackdriver/types.ts +++ b/public/app/plugins/datasource/stackdriver/types.ts @@ -1,3 +1,5 @@ +import { DataQuery } from '@grafana/ui/src/types'; + export enum MetricFindQueryTypes { Services = 'services', MetricTypes = 'metricTypes', @@ -20,20 +22,22 @@ export interface VariableQueryData { services: Array<{ value: string; name: string }>; } -export interface Target { - defaultProject: string; - unit: string; +export interface StackdriverQuery extends DataQuery { + defaultProject?: string; + unit?: string; metricType: string; - service: string; + service?: string; refId: string; crossSeriesReducer: string; - alignmentPeriod: string; + alignmentPeriod?: string; perSeriesAligner: string; - groupBys: string[]; - filters: string[]; - aliasBy: string; + groupBys?: string[]; + filters?: string[]; + aliasBy?: string; metricKind: string; valueType: string; + datasourceId?: number; + view?: string; } export interface AnnotationTarget { diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index b6f37dde94f..5cb256ee1aa 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; // Services & Utils -import { processTimeSeries } from '@grafana/ui'; +import { processTimeSeries, ThemeContext } from '@grafana/ui'; // Components import { Gauge } from '@grafana/ui'; @@ -10,7 +10,6 @@ import { Gauge } from '@grafana/ui'; // Types import { GaugeOptions } from './types'; import { PanelProps, NullValueMode, TimeSeriesValue } from '@grafana/ui/src/types'; -import { ThemeProvider } from 'app/core/utils/ConfigProvider'; interface Props extends PanelProps {} @@ -38,7 +37,7 @@ export class GaugePanel extends PureComponent { } return ( - + {theme => ( { theme={theme} /> )} - + ); } } diff --git a/public/app/plugins/panel/gauge/GaugePanelOptions.tsx b/public/app/plugins/panel/gauge/GaugePanelOptions.tsx index 655c596ce84..84726ac88bf 100644 --- a/public/app/plugins/panel/gauge/GaugePanelOptions.tsx +++ b/public/app/plugins/panel/gauge/GaugePanelOptions.tsx @@ -11,7 +11,6 @@ import { import ValueOptions from 'app/plugins/panel/gauge/ValueOptions'; import GaugeOptionsEditor from './GaugeOptionsEditor'; import { GaugeOptions } from './types'; -import { ThemeProvider } from 'app/core/utils/ConfigProvider'; export const defaultProps = { options: { @@ -46,24 +45,17 @@ export default class GaugePanelOptions extends PureComponent - {(theme) => ( - <> - - - - - - - - )} - + return ( + <> + + + + + + + + ); } } diff --git a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx index d62613319b2..2cf45727c4a 100644 --- a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx +++ b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx @@ -2,7 +2,6 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { TimeSeries } from 'app/core/core'; import { SeriesColorPicker } from '@grafana/ui'; -import { ThemeProvider } from 'app/core/utils/ConfigProvider'; export const LEGEND_STATS = ['min', 'max', 'avg', 'current', 'total']; @@ -168,24 +167,17 @@ class LegendSeriesIcon extends PureComponent - {theme => { - return ( - - - - - - ); - }} - + + + + + ); } } diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts index 4141d36e273..2966bb33eb4 100644 --- a/public/app/plugins/panel/graph/data_processor.ts +++ b/public/app/plugins/panel/graph/data_processor.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import { colors, GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui'; +import { colors, getColorFromHexRgbOrName } from '@grafana/ui'; import TimeSeries from 'app/core/time_series2'; import config from 'app/core/config'; @@ -113,7 +113,7 @@ export class DataProcessor { const series = new TimeSeries({ datapoints: datapoints, alias: alias, - color: getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark), + color: getColorFromHexRgbOrName(color, config.theme.type), unit: seriesData.unit, }); diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index aeb540551b8..54ba4ed1e6f 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -25,7 +25,10 @@ import ReactDOM from 'react-dom'; import { Legend, GraphLegendProps } from './Legend/Legend'; import { GraphCtrl } from './module'; -import { GrafanaTheme, getValueFormat } from '@grafana/ui'; +import { getValueFormat } from '@grafana/ui'; +import { provideTheme } from 'app/core/utils/ConfigProvider'; + +const LegendWithThemeProvider = provideTheme(Legend); class GraphElement { ctrl: GraphCtrl; @@ -43,6 +46,7 @@ class GraphElement { legendElem: HTMLElement; constructor(private scope, private elem, private timeSrv) { + this.ctrl = scope.ctrl; this.dashboard = this.ctrl.dashboard; this.panel = this.ctrl.panel; @@ -51,10 +55,7 @@ class GraphElement { this.panelWidth = 0; this.eventManager = new EventManager(this.ctrl); this.thresholdManager = new ThresholdManager(this.ctrl); - this.timeRegionManager = new TimeRegionManager( - this.ctrl, - config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark - ); + this.timeRegionManager = new TimeRegionManager(this.ctrl, config.theme.type); this.tooltip = new GraphTooltip(this.elem, this.ctrl.dashboard, this.scope, () => { return this.sortedSeries; }); @@ -109,7 +110,7 @@ class GraphElement { onToggleAxis: this.ctrl.onToggleAxis, }; - const legendReactElem = React.createElement(Legend, legendProps); + const legendReactElem = React.createElement(LegendWithThemeProvider, legendProps); ReactDOM.render(legendReactElem, this.legendElem, () => this.renderPanel()); } diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 68d982eab13..3919c4f69a9 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -10,7 +10,7 @@ import { MetricsPanelCtrl } from 'app/plugins/sdk'; import { DataProcessor } from './data_processor'; import { axesEditorComponent } from './axes_editor'; import config from 'app/core/config'; -import { GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui'; +import { getColorFromHexRgbOrName } from '@grafana/ui'; class GraphCtrl extends MetricsPanelCtrl { static template = template; @@ -244,7 +244,7 @@ class GraphCtrl extends MetricsPanelCtrl { } onColorChange = (series, color) => { - series.setColor(getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark)); + series.setColor(getColorFromHexRgbOrName(color, config.theme.type)); this.panel.aliasColors[series.alias] = color; this.render(); }; diff --git a/public/app/plugins/panel/graph/time_region_manager.ts b/public/app/plugins/panel/graph/time_region_manager.ts index 2917583ff36..ea39927bf57 100644 --- a/public/app/plugins/panel/graph/time_region_manager.ts +++ b/public/app/plugins/panel/graph/time_region_manager.ts @@ -1,7 +1,7 @@ import 'vendor/flot/jquery.flot'; import _ from 'lodash'; import moment from 'moment'; -import { GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui'; +import { GrafanaThemeType, getColorFromHexRgbOrName } from '@grafana/ui'; type TimeRegionColorDefinition = { fill: string; @@ -43,7 +43,7 @@ export function getColorModes() { }); } -function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition { +function getColor(timeRegion, theme: GrafanaThemeType): TimeRegionColorDefinition { if (Object.keys(colorModes).indexOf(timeRegion.colorMode) === -1) { timeRegion.colorMode = 'red'; } @@ -58,7 +58,7 @@ function getColor(timeRegion, theme: GrafanaTheme): TimeRegionColorDefinition { const colorMode = colorModes[timeRegion.colorMode]; if (colorMode.themeDependent === true) { - return theme === GrafanaTheme.Light ? colorMode.lightColor : colorMode.darkColor; + return theme === GrafanaThemeType.Light ? colorMode.lightColor : colorMode.darkColor; } return { @@ -71,7 +71,7 @@ export class TimeRegionManager { plot: any; timeRegions: any; - constructor(private panelCtrl, private theme: GrafanaTheme = GrafanaTheme.Dark) {} + constructor(private panelCtrl, private theme: GrafanaThemeType = GrafanaThemeType.Dark) {} draw(plot) { this.timeRegions = this.panelCtrl.panel.timeRegions; diff --git a/public/app/plugins/panel/heatmap/color_legend.ts b/public/app/plugins/panel/heatmap/color_legend.ts index 81329fe297b..dea250abf74 100644 --- a/public/app/plugins/panel/heatmap/color_legend.ts +++ b/public/app/plugins/panel/heatmap/color_legend.ts @@ -5,7 +5,7 @@ import { contextSrv } from 'app/core/core'; import { tickStep } from 'app/core/utils/ticks'; import { getColorScale, getOpacityScale } from './color_scale'; import coreModule from 'app/core/core_module'; -import { GrafanaTheme, getColorFromHexRgbOrName } from '@grafana/ui'; +import { GrafanaThemeType, getColorFromHexRgbOrName } from '@grafana/ui'; const LEGEND_HEIGHT_PX = 6; const LEGEND_WIDTH_PX = 100; @@ -250,7 +250,7 @@ function drawSimpleOpacityLegend(elem, options) { .attr('stroke-width', 0) .attr( 'fill', - getColorFromHexRgbOrName(options.cardColor, contextSrv.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark) + getColorFromHexRgbOrName(options.cardColor, contextSrv.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark) ) .style('opacity', d => legendOpacityScale(d)); } diff --git a/public/app/plugins/panel/heatmap/rendering.ts b/public/app/plugins/panel/heatmap/rendering.ts index 6489c9e9895..63604382432 100644 --- a/public/app/plugins/panel/heatmap/rendering.ts +++ b/public/app/plugins/panel/heatmap/rendering.ts @@ -7,7 +7,7 @@ import * as ticksUtils from 'app/core/utils/ticks'; import { HeatmapTooltip } from './heatmap_tooltip'; import { mergeZeroBuckets } from './heatmap_data_converter'; import { getColorScale, getOpacityScale } from './color_scale'; -import { GrafanaTheme, getColorFromHexRgbOrName, getValueFormat } from '@grafana/ui'; +import { GrafanaThemeType, getColorFromHexRgbOrName, getValueFormat } from '@grafana/ui'; const MIN_CARD_SIZE = 1, CARD_PADDING = 1, @@ -663,7 +663,7 @@ export class HeatmapRenderer { if (this.panel.color.mode === 'opacity') { return getColorFromHexRgbOrName( this.panel.color.cardColor, - contextSrv.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark + contextSrv.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark ); } else { return this.colorScale(d.count); diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 2768951d2ba..21ab32278f8 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -8,7 +8,7 @@ import kbn from 'app/core/utils/kbn'; import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; import { MetricsPanelCtrl } from 'app/plugins/sdk'; -import { GrafanaTheme, getValueFormat, getColorFromHexRgbOrName } from '@grafana/ui'; +import { GrafanaThemeType, getValueFormat, getColorFromHexRgbOrName } from '@grafana/ui'; class SingleStatCtrl extends MetricsPanelCtrl { static templateUrl = 'module.html'; @@ -588,10 +588,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { fill: 1, zero: false, lineWidth: 1, - fillColor: getColorFromHexRgbOrName( - panel.sparkline.fillColor, - config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark - ), + fillColor: getColorFromHexRgbOrName(panel.sparkline.fillColor, config.theme.type), }, }, yaxes: { show: false }, @@ -608,10 +605,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { const plotSeries = { data: data.flotpairs, - color: getColorFromHexRgbOrName( - panel.sparkline.lineColor, - config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark - ), + color: getColorFromHexRgbOrName(panel.sparkline.lineColor, config.theme.type), }; $.plot(plotCanvas, [plotSeries], options); @@ -630,7 +624,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { // Map panel colors to hex or rgb/a values data.colorMap = panel.colors.map(color => - getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark) + getColorFromHexRgbOrName(color, config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark) ); const body = panel.gauge.show ? '' : getBigValueHtml(); diff --git a/public/app/plugins/panel/table/module.ts b/public/app/plugins/panel/table/module.ts index 82763e1839a..268f5aa7ac4 100644 --- a/public/app/plugins/panel/table/module.ts +++ b/public/app/plugins/panel/table/module.ts @@ -6,7 +6,6 @@ import { transformDataToTable } from './transformers'; import { tablePanelEditor } from './editor'; import { columnOptionsTab } from './column_options'; import { TableRenderer } from './renderer'; -import { GrafanaTheme } from '@grafana/ui'; class TablePanelCtrl extends MetricsPanelCtrl { static templateUrl = 'module.html'; @@ -131,7 +130,7 @@ class TablePanelCtrl extends MetricsPanelCtrl { this.dashboard.isTimezoneUtc(), this.$sanitize, this.templateSrv, - config.bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark, + config.theme.type ); return super.render(this.table); diff --git a/public/app/plugins/panel/table/renderer.ts b/public/app/plugins/panel/table/renderer.ts index 90479a67602..e9bf89f45fe 100644 --- a/public/app/plugins/panel/table/renderer.ts +++ b/public/app/plugins/panel/table/renderer.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import moment from 'moment'; import kbn from 'app/core/utils/kbn'; -import { GrafanaTheme, getValueFormat, getColorFromHexRgbOrName } from '@grafana/ui'; +import { getValueFormat, getColorFromHexRgbOrName, GrafanaThemeType } from '@grafana/ui'; export class TableRenderer { formatters: any[]; @@ -13,7 +13,7 @@ export class TableRenderer { private isUtc, private sanitize, private templateSrv, - private theme?: GrafanaTheme + private theme?: GrafanaThemeType ) { this.initColumns(); } diff --git a/public/app/routes/GrafanaCtrl.ts b/public/app/routes/GrafanaCtrl.ts index 70bdf49e5e4..a6d97856e74 100644 --- a/public/app/routes/GrafanaCtrl.ts +++ b/public/app/routes/GrafanaCtrl.ts @@ -1,9 +1,11 @@ -import config from 'app/core/config'; +// Libraries import _ from 'lodash'; import $ from 'jquery'; import Drop from 'tether-drop'; -import { colors } from '@grafana/ui'; +// Utils and servies +import { colors } from '@grafana/ui'; +import config from 'app/core/config'; import coreModule from 'app/core/core_module'; import { profiler } from 'app/core/profiler'; import appEvents from 'app/core/app_events'; @@ -13,6 +15,9 @@ import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader'; import { configureStore } from 'app/store/configureStore'; +// Types +import { KioskUrlValue } from 'app/types'; + export class GrafanaCtrl { /** @ngInject */ constructor( @@ -46,11 +51,6 @@ export class GrafanaCtrl { $rootScope.colors = colors; - $scope.initDashboard = (dashboardData, viewScope) => { - $scope.appEvent('dashboard-fetch-end', dashboardData); - $controller('DashboardCtrl', { $scope: viewScope }).init(dashboardData); - }; - $rootScope.onAppEvent = function(name, callback, localScope) { const unbind = $rootScope.$on(name, callback); let callerScope = this; @@ -72,7 +72,7 @@ export class GrafanaCtrl { } } -function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) { +function setViewModeBodyClass(body, mode: KioskUrlValue, sidemenuOpen: boolean) { body.removeClass('view-mode--tv'); body.removeClass('view-mode--kiosk'); body.removeClass('view-mode--inactive'); @@ -126,12 +126,13 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop body.toggleClass('sidemenu-hidden'); }); - scope.$watch( - () => playlistSrv.isPlaying, - newValue => { - elem.toggleClass('view-mode--playlist', newValue === true); - } - ); + appEvents.on('playlist-started', () => { + elem.toggleClass('view-mode--playlist', true); + }); + + appEvents.on('playlist-stopped', () => { + elem.toggleClass('view-mode--playlist', false); + }); // check if we are in server side render if (document.cookie.indexOf('renderKey') !== -1) { @@ -165,6 +166,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop for (const drop of Drop.drops) { drop.destroy(); } + + appEvents.emit('hide-dash-search'); }); // handle kiosk mode @@ -262,10 +265,6 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop }, 100); } - if (target.parents('.navbar-buttons--playlist').length === 0) { - playlistSrv.stop(); - } - // hide search if (body.find('.search-container').length > 0) { if (target.parents('.search-results-container, .search-field-wrapper').length === 0) { @@ -280,6 +279,28 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop if (popover.length > 0 && target.parents('.graph-legend').length === 0) { popover.hide(); } + + // hide time picker + const timePickerDropDownIsOpen = elem.find('.gf-timepicker-dropdown').length > 0; + if (timePickerDropDownIsOpen) { + const targetIsInTimePickerDropDown = target.parents('.gf-timepicker-dropdown').length > 0; + const targetIsInTimePickerNav = target.parents('.gf-timepicker-nav').length > 0; + const targetIsDatePickerRowBtn = target.parents('td[id^="datepicker-"]').length > 0; + const targetIsDatePickerHeaderBtn = target.parents('button[id^="datepicker-"]').length > 0; + + if ( + targetIsInTimePickerNav || + targetIsInTimePickerDropDown || + targetIsDatePickerRowBtn || + targetIsDatePickerHeaderBtn + ) { + return; + } + + scope.$apply(() => { + scope.appEvent('closeTimepicker'); + }); + } }); }, }; diff --git a/public/app/routes/ReactContainer.tsx b/public/app/routes/ReactContainer.tsx index 2cad3d828bf..35c23239561 100644 --- a/public/app/routes/ReactContainer.tsx +++ b/public/app/routes/ReactContainer.tsx @@ -5,6 +5,7 @@ import { Provider } from 'react-redux'; import coreModule from 'app/core/core_module'; import { store } from 'app/store/store'; import { ContextSrv } from 'app/core/services/context_srv'; +import { provideTheme } from 'app/core/utils/ConfigProvider'; function WrapInProvider(store, Component, props) { return ( @@ -44,11 +45,15 @@ export function reactContainer( $injector: $injector, $rootScope: $rootScope, $scope: scope, + routeInfo: $route.current.$$route.routeInfo, }; - ReactDOM.render(WrapInProvider(store, component, props), elem[0]); + document.body.classList.add('is-react'); + + ReactDOM.render(WrapInProvider(store, provideTheme(component), props), elem[0]); scope.$on('$destroy', () => { + document.body.classList.remove('is-react'); ReactDOM.unmountComponentAtNode(elem[0]); }); }, diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 0f4c09a9c77..e0029cf2464 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -2,6 +2,7 @@ import './dashboard_loaders'; import './ReactContainer'; import { applyRouteRegistrationHandlers } from './registry'; +// Pages import ServerStats from 'app/features/admin/ServerStats'; import AlertRuleList from 'app/features/alerting/AlertRuleList'; import TeamPages from 'app/features/teams/TeamPages'; @@ -20,40 +21,66 @@ import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards' import DataSourceSettingsPage from '../features/datasources/settings/DataSourceSettingsPage'; import OrgDetailsPage from '../features/org/OrgDetailsPage'; import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage'; +import DashboardPage from '../features/dashboard/containers/DashboardPage'; import config from 'app/core/config'; +// Types +import { DashboardRouteInfo } from 'app/types'; + /** @ngInject */ export function setupAngularRoutes($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); $routeProvider .when('/', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.Home, + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, }) .when('/d/:uid/:slug', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.Normal, + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, }) .when('/d/:uid', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + reloadOnSearch: false, + routeInfo: DashboardRouteInfo.Normal, + resolve: { + component: () => DashboardPage, + }, }) .when('/dashboard/:type/:slug', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'LoadDashboardCtrl', - reloadOnSearch: false, + template: '', pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.Normal, + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, + }) + .when('/dashboard/new', { + template: '', + pageClass: 'page-dashboard', + routeInfo: DashboardRouteInfo.New, + reloadOnSearch: false, + resolve: { + component: () => DashboardPage, + }, }) .when('/d-solo/:uid/:slug', { template: '', pageClass: 'dashboard-solo', + routeInfo: DashboardRouteInfo.Normal, resolve: { component: () => SoloPanelPage, }, @@ -61,16 +88,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/dashboard-solo/:type/:slug', { template: '', pageClass: 'dashboard-solo', + routeInfo: DashboardRouteInfo.Normal, resolve: { component: () => SoloPanelPage, }, }) - .when('/dashboard/new', { - templateUrl: 'public/app/partials/dashboard.html', - controller: 'NewDashboardCtrl', - reloadOnSearch: false, - pageClass: 'page-dashboard', - }) .when('/dashboard/import', { templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_import.html', controller: DashboardImportCtrl, diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 570a387cd74..e2c33523271 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -11,6 +11,7 @@ import exploreReducers from 'app/features/explore/state/reducers'; import pluginReducers from 'app/features/plugins/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; +import userReducers from 'app/features/profile/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; import { setStore } from './store'; @@ -25,6 +26,7 @@ const rootReducers = { ...pluginReducers, ...dataSourcesReducers, ...usersReducers, + ...userReducers, ...organizationReducers, }; diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index d33405c985e..d81f9e2bcba 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -1,5 +1,71 @@ import { DashboardAcl } from './acl'; -export interface DashboardState { - permissions: DashboardAcl[]; +export interface MutableDashboard { + title: string; + meta: DashboardMeta; + destroy: () => void; +} + +export interface DashboardDTO { + redirectUri?: string; + dashboard: DashboardDataDTO; + meta: DashboardMeta; +} + +export interface DashboardMeta { + canSave?: boolean; + canEdit?: boolean; + canShare?: boolean; + canStar?: boolean; + canAdmin?: boolean; + url?: string; + folderId?: number; + fullscreen?: boolean; + isEditing?: boolean; + canMakeEditable?: boolean; + submenuEnabled?: boolean; + provisioned?: boolean; + focusPanelId?: boolean; + isStarred?: boolean; + showSettings?: boolean; + expires?: string; + isSnapshot?: boolean; + folderTitle?: string; + folderUrl?: string; + created?: string; +} + +export interface DashboardDataDTO { + title: string; +} + +export enum DashboardRouteInfo { + Home = 'home-dashboard', + New = 'new-dashboard', + Normal = 'normal-dashboard', + Scripted = 'scripted-dashboard', +} + +export enum DashboardInitPhase { + NotStarted = 'Not started', + Fetching = 'Fetching', + Services = 'Services', + Failed = 'Failed', + Completed = 'Completed', +} + +export interface DashboardInitError { + message: string; + error: any; +} + +export const KIOSK_MODE_TV = 'tv'; +export type KioskUrlValue = 'tv' | '1' | true; + +export interface DashboardState { + model: MutableDashboard | null; + initPhase: DashboardInitPhase; + isInitSlow: boolean; + initError?: DashboardInitError; + permissions: DashboardAcl[] | null; } diff --git a/public/app/types/location.ts b/public/app/types/location.ts index 7dcf57f7e02..a47ef05d2be 100644 --- a/public/app/types/location.ts +++ b/public/app/types/location.ts @@ -3,6 +3,10 @@ export interface LocationUpdate { query?: UrlQueryMap; routeParams?: UrlQueryMap; partial?: boolean; + /* + * If true this will replace url state (ie cause no new browser history) + */ + replace?: boolean; } export interface LocationState { @@ -10,6 +14,7 @@ export interface LocationState { path: string; query: UrlQueryMap; routeParams: UrlQueryMap; + replace: boolean; } export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[]; diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 78832052e96..72a8cf066f1 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -1,3 +1,6 @@ +import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk'; +import { ActionOf } from 'app/core/redux'; + import { NavIndex } from './navModel'; import { LocationState } from './location'; import { AlertRulesState } from './alerting'; @@ -27,3 +30,10 @@ export interface StoreState { user: UserState; plugins: PluginsState; } + +/* + * Utility type to get strongly types thunks + */ +export type ThunkResult = ThunkAction>; + +export type ThunkDispatch = GenericThunkDispatch; diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 37c80074dca..7691558ce90 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -1,5 +1,3 @@ -import { DashboardSearchHit } from './search'; - export interface OrgUser { avatarUrl: string; email: string; @@ -47,5 +45,5 @@ export interface UsersState { } export interface UserState { - starredDashboards: DashboardSearchHit[]; + orgId: number; } diff --git a/public/img/icons_dark_theme/icon_advanced.svg b/public/img/icons_dark_theme/icon_advanced.svg index 5fd18a86dd5..dea3ddff685 100644 --- a/public/img/icons_dark_theme/icon_advanced.svg +++ b/public/img/icons_dark_theme/icon_advanced.svg @@ -4,7 +4,7 @@ diff --git a/public/img/icons_dark_theme/icon_advanced_active.svg b/public/img/icons_dark_theme/icon_advanced_active.svg index 80672a2595b..1227ddc868c 100644 --- a/public/img/icons_dark_theme/icon_advanced_active.svg +++ b/public/img/icons_dark_theme/icon_advanced_active.svg @@ -5,7 +5,7 @@ width="121px" height="100px" viewBox="0 0 121 100" style="enable-background:new 0 0 121 100;" xml:space="preserve">
-
+