mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into ui-new-red-green-blue
This commit is contained in:
commit
c34021344a
1
.gitignore
vendored
1
.gitignore
vendored
@ -46,6 +46,7 @@ devenv/docker-compose.yaml
|
|||||||
/conf/provisioning/**/custom.yaml
|
/conf/provisioning/**/custom.yaml
|
||||||
/conf/provisioning/**/dev.yaml
|
/conf/provisioning/**/dev.yaml
|
||||||
/conf/ldap_dev.toml
|
/conf/ldap_dev.toml
|
||||||
|
/conf/ldap_freeipa.toml
|
||||||
profile.cov
|
profile.cov
|
||||||
/grafana
|
/grafana
|
||||||
/local
|
/local
|
||||||
|
12
CHANGELOG.md
12
CHANGELOG.md
@ -2,7 +2,19 @@
|
|||||||
|
|
||||||
### Minor
|
### Minor
|
||||||
* **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
|
* **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/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)
|
# 6.0.0-beta1 (2019-01-30)
|
||||||
|
|
||||||
|
12
Gopkg.lock
generated
12
Gopkg.lock
generated
@ -37,6 +37,7 @@
|
|||||||
"aws/credentials",
|
"aws/credentials",
|
||||||
"aws/credentials/ec2rolecreds",
|
"aws/credentials/ec2rolecreds",
|
||||||
"aws/credentials/endpointcreds",
|
"aws/credentials/endpointcreds",
|
||||||
|
"aws/credentials/processcreds",
|
||||||
"aws/credentials/stscreds",
|
"aws/credentials/stscreds",
|
||||||
"aws/csm",
|
"aws/csm",
|
||||||
"aws/defaults",
|
"aws/defaults",
|
||||||
@ -45,13 +46,18 @@
|
|||||||
"aws/request",
|
"aws/request",
|
||||||
"aws/session",
|
"aws/session",
|
||||||
"aws/signer/v4",
|
"aws/signer/v4",
|
||||||
|
"internal/ini",
|
||||||
|
"internal/s3err",
|
||||||
"internal/sdkio",
|
"internal/sdkio",
|
||||||
"internal/sdkrand",
|
"internal/sdkrand",
|
||||||
|
"internal/sdkuri",
|
||||||
"internal/shareddefaults",
|
"internal/shareddefaults",
|
||||||
"private/protocol",
|
"private/protocol",
|
||||||
"private/protocol/ec2query",
|
"private/protocol/ec2query",
|
||||||
"private/protocol/eventstream",
|
"private/protocol/eventstream",
|
||||||
"private/protocol/eventstream/eventstreamapi",
|
"private/protocol/eventstream/eventstreamapi",
|
||||||
|
"private/protocol/json/jsonutil",
|
||||||
|
"private/protocol/jsonrpc",
|
||||||
"private/protocol/query",
|
"private/protocol/query",
|
||||||
"private/protocol/query/queryutil",
|
"private/protocol/query/queryutil",
|
||||||
"private/protocol/rest",
|
"private/protocol/rest",
|
||||||
@ -60,11 +66,13 @@
|
|||||||
"service/cloudwatch",
|
"service/cloudwatch",
|
||||||
"service/ec2",
|
"service/ec2",
|
||||||
"service/ec2/ec2iface",
|
"service/ec2/ec2iface",
|
||||||
|
"service/resourcegroupstaggingapi",
|
||||||
|
"service/resourcegroupstaggingapi/resourcegroupstaggingapiiface",
|
||||||
"service/s3",
|
"service/s3",
|
||||||
"service/sts"
|
"service/sts"
|
||||||
]
|
]
|
||||||
revision = "fde4ded7becdeae4d26bf1212916aabba79349b4"
|
revision = "62936e15518acb527a1a9cb4a39d96d94d0fd9a2"
|
||||||
version = "v1.14.12"
|
version = "v1.16.15"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
branch = "master"
|
branch = "master"
|
||||||
|
@ -106,25 +106,6 @@ path = grafana.db
|
|||||||
# For "sqlite3" only. cache mode setting used for connecting to the database
|
# For "sqlite3" only. cache mode setting used for connecting to the database
|
||||||
cache_mode = private
|
cache_mode = private
|
||||||
|
|
||||||
#################################### Login ###############################
|
|
||||||
|
|
||||||
[login]
|
|
||||||
|
|
||||||
# Login cookie name
|
|
||||||
cookie_name = grafana_session
|
|
||||||
|
|
||||||
# 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 #############################
|
||||||
[session]
|
[session]
|
||||||
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
|
# 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 protection against brute force login attempts
|
||||||
disable_brute_force_login_protection = false
|
disable_brute_force_login_protection = false
|
||||||
|
|
||||||
# set cookies as https only. default is false
|
# set to true if you host Grafana behind HTTPS. default is false.
|
||||||
https_flag_cookies = false
|
cookie_secure = false
|
||||||
|
|
||||||
|
# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none"
|
||||||
|
cookie_samesite = lax
|
||||||
|
|
||||||
#################################### Snapshots ###########################
|
#################################### Snapshots ###########################
|
||||||
[snapshots]
|
[snapshots]
|
||||||
@ -260,6 +244,18 @@ external_manage_info =
|
|||||||
viewers_can_edit = false
|
viewers_can_edit = false
|
||||||
|
|
||||||
[auth]
|
[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
|
# Set to true to disable (hide) the login form, useful if you use OAuth
|
||||||
disable_login_form = false
|
disable_login_form = false
|
||||||
|
|
||||||
|
@ -102,25 +102,6 @@ log_queries =
|
|||||||
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
|
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
|
||||||
;cache_mode = private
|
;cache_mode = private
|
||||||
|
|
||||||
#################################### Login ###############################
|
|
||||||
|
|
||||||
[login]
|
|
||||||
|
|
||||||
# Login cookie name
|
|
||||||
;cookie_name = grafana_session
|
|
||||||
|
|
||||||
# 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 ####################################
|
||||||
[session]
|
[session]
|
||||||
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
|
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
|
||||||
@ -193,8 +174,11 @@ log_queries =
|
|||||||
# disable protection against brute force login attempts
|
# disable protection against brute force login attempts
|
||||||
;disable_brute_force_login_protection = false
|
;disable_brute_force_login_protection = false
|
||||||
|
|
||||||
# set cookies as https only. default is false
|
# set to true if you host Grafana behind HTTPS. default is false.
|
||||||
;https_flag_cookies = false
|
;cookie_secure = false
|
||||||
|
|
||||||
|
# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict" and "none"
|
||||||
|
;cookie_samesite = lax
|
||||||
|
|
||||||
#################################### Snapshots ###########################
|
#################################### Snapshots ###########################
|
||||||
[snapshots]
|
[snapshots]
|
||||||
@ -240,6 +224,18 @@ log_queries =
|
|||||||
;viewers_can_edit = false
|
;viewers_can_edit = false
|
||||||
|
|
||||||
[auth]
|
[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
|
# Set to true to disable (hide) the login form, useful if you use OAuth, defaults to false
|
||||||
;disable_login_form = false
|
;disable_login_form = false
|
||||||
|
|
||||||
@ -253,7 +249,7 @@ log_queries =
|
|||||||
# This setting is ignored if multiple OAuth providers are configured.
|
# This setting is ignored if multiple OAuth providers are configured.
|
||||||
;oauth_auto_login = false
|
;oauth_auto_login = false
|
||||||
|
|
||||||
#################################### Anonymous Auth ##########################
|
#################################### Anonymous Auth ######################
|
||||||
[auth.anonymous]
|
[auth.anonymous]
|
||||||
# enable anonymous access
|
# enable anonymous access
|
||||||
;enabled = false
|
;enabled = false
|
||||||
|
54
devenv/docker/blocks/freeipa/docker-compose.yaml
Normal file
54
devenv/docker/blocks/freeipa/docker-compose.yaml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
freeipa_data: {}
|
||||||
|
|
||||||
|
services:
|
||||||
|
freeipa:
|
||||||
|
image: freeipa/freeipa-server:fedora-29
|
||||||
|
container_name: freeipa
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
sysctls:
|
||||||
|
- net.ipv6.conf.all.disable_ipv6=0
|
||||||
|
hostname: ipa.example.test
|
||||||
|
environment:
|
||||||
|
# - DEBUG_TRACE=1
|
||||||
|
- IPA_SERVER_IP=172.17.0.2
|
||||||
|
- DEBUG_NO_EXIT=1
|
||||||
|
- IPA_SERVER_HOSTNAME=ipa.example.test
|
||||||
|
- PASSWORD=Secret123
|
||||||
|
- HOSTNAME=ipa.example.test
|
||||||
|
command:
|
||||||
|
- --admin-password=Secret123
|
||||||
|
- --ds-password=Secret123
|
||||||
|
- -U
|
||||||
|
- --realm=EXAMPLE.TEST
|
||||||
|
ports:
|
||||||
|
# FreeIPA WebUI
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
# Kerberos
|
||||||
|
- "88:88/udp"
|
||||||
|
- "88:88"
|
||||||
|
- "464:464/udp"
|
||||||
|
- "464:464"
|
||||||
|
# LDAP
|
||||||
|
- "389:389"
|
||||||
|
- "636:636"
|
||||||
|
# DNS
|
||||||
|
# - "53:53/udp"
|
||||||
|
# - "53:53"
|
||||||
|
# NTP
|
||||||
|
- "123:123/udp"
|
||||||
|
# other
|
||||||
|
- "7389:7389"
|
||||||
|
- "9443:9443"
|
||||||
|
- "9444:9444"
|
||||||
|
- "9445:9445"
|
||||||
|
tmpfs:
|
||||||
|
- /run
|
||||||
|
- /tmp
|
||||||
|
volumes:
|
||||||
|
- freeipa_data:/data:Z
|
||||||
|
- /sys/fs/cgroup:/sys/fs/cgroup:ro
|
74
devenv/docker/blocks/freeipa/ldap_freeipa.toml
Normal file
74
devenv/docker/blocks/freeipa/ldap_freeipa.toml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# To troubleshoot and get more log info enable ldap debug logging in grafana.ini
|
||||||
|
# [log]
|
||||||
|
# filters = ldap:debug
|
||||||
|
|
||||||
|
[[servers]]
|
||||||
|
# Ldap server host (specify multiple hosts space separated)
|
||||||
|
host = "172.17.0.1"
|
||||||
|
# Default port is 389 or 636 if use_ssl = true
|
||||||
|
port = 389
|
||||||
|
# Set to true if ldap server supports TLS
|
||||||
|
use_ssl = false
|
||||||
|
# Set to true if connect ldap server with STARTTLS pattern (create connection in insecure, then upgrade to secure connection with TLS)
|
||||||
|
start_tls = false
|
||||||
|
# set to true if you want to skip ssl cert validation
|
||||||
|
ssl_skip_verify = false
|
||||||
|
# set to the path to your root CA certificate or leave unset to use system defaults
|
||||||
|
# root_ca_cert = "/path/to/certificate.crt"
|
||||||
|
|
||||||
|
# Search user bind dn
|
||||||
|
bind_dn = "uid=admin,cn=users,cn=accounts,dc=example,dc=test"
|
||||||
|
# Search user bind password
|
||||||
|
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
||||||
|
bind_password = 'Secret123'
|
||||||
|
|
||||||
|
# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)"
|
||||||
|
search_filter = "(uid=%s)"
|
||||||
|
|
||||||
|
# An array of base dns to search through
|
||||||
|
search_base_dns = ["cn=users,cn=accounts,dc=example,dc=test"]
|
||||||
|
|
||||||
|
# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups.
|
||||||
|
# This is done by enabling group_search_filter below. You must also set member_of= "cn"
|
||||||
|
# in [servers.attributes] below.
|
||||||
|
|
||||||
|
# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN
|
||||||
|
# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of
|
||||||
|
# below in such a way that the user's recursive group membership is considered.
|
||||||
|
#
|
||||||
|
# Nested Groups + Active Directory (AD) Example:
|
||||||
|
#
|
||||||
|
# AD groups store the Distinguished Names (DNs) of members, so your filter must
|
||||||
|
# recursively search your groups for the authenticating user's DN. For example:
|
||||||
|
#
|
||||||
|
# group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)"
|
||||||
|
# group_search_filter_user_attribute = "distinguishedName"
|
||||||
|
# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
|
||||||
|
#
|
||||||
|
# [servers.attributes]
|
||||||
|
# ...
|
||||||
|
# member_of = "distinguishedName"
|
||||||
|
|
||||||
|
## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available)
|
||||||
|
# group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))"
|
||||||
|
## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter.
|
||||||
|
## Defaults to the value of username in [server.attributes]
|
||||||
|
## Valid options are any of your values in [servers.attributes]
|
||||||
|
## If you are using nested groups you probably want to set this and member_of in
|
||||||
|
## [servers.attributes] to "distinguishedName"
|
||||||
|
# group_search_filter_user_attribute = "distinguishedName"
|
||||||
|
## An array of the base DNs to search through for groups. Typically uses ou=groups
|
||||||
|
# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"]
|
||||||
|
|
||||||
|
# Specify names of the ldap attributes your ldap uses
|
||||||
|
[servers.attributes]
|
||||||
|
name = "givenName"
|
||||||
|
username = "uid"
|
||||||
|
member_of = "memberOf"
|
||||||
|
# surname = "sn"
|
||||||
|
# email = "mail"
|
||||||
|
|
||||||
|
[[servers.group_mappings]]
|
||||||
|
# If you want to match all (or no ldap groups) then you can use wildcard
|
||||||
|
group_dn = "*"
|
||||||
|
org_role = "Viewer"
|
32
devenv/docker/blocks/freeipa/notes.md
Normal file
32
devenv/docker/blocks/freeipa/notes.md
Normal file
@ -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
|
||||||
|
```
|
@ -15,6 +15,7 @@ services:
|
|||||||
MYSQL_DATABASE: grafana
|
MYSQL_DATABASE: grafana
|
||||||
MYSQL_USER: grafana
|
MYSQL_USER: grafana
|
||||||
MYSQL_PASSWORD: password
|
MYSQL_PASSWORD: password
|
||||||
|
command: [mysqld, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --innodb_monitor_enable=all, --max-connections=1001]
|
||||||
ports:
|
ports:
|
||||||
- 3306
|
- 3306
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -22,6 +23,16 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
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:
|
# db:
|
||||||
# image: postgres:9.3
|
# image: postgres:9.3
|
||||||
# environment:
|
# environment:
|
||||||
@ -47,6 +58,7 @@ services:
|
|||||||
- GF_DATABASE_PASSWORD=password
|
- GF_DATABASE_PASSWORD=password
|
||||||
- GF_DATABASE_TYPE=mysql
|
- GF_DATABASE_TYPE=mysql
|
||||||
- GF_DATABASE_HOST=db:3306
|
- GF_DATABASE_HOST=db:3306
|
||||||
|
- GF_DATABASE_MAX_OPEN_CONN=300
|
||||||
- GF_SESSION_PROVIDER=mysql
|
- GF_SESSION_PROVIDER=mysql
|
||||||
- GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true
|
- GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true
|
||||||
# - GF_DATABASE_TYPE=postgres
|
# - GF_DATABASE_TYPE=postgres
|
||||||
@ -55,7 +67,7 @@ services:
|
|||||||
# - GF_SESSION_PROVIDER=postgres
|
# - GF_SESSION_PROVIDER=postgres
|
||||||
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
|
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
|
||||||
- GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
|
- 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:
|
ports:
|
||||||
- 3000
|
- 3000
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -70,10 +82,3 @@ services:
|
|||||||
- VIRTUAL_HOST=prometheus.loc
|
- VIRTUAL_HOST=prometheus.loc
|
||||||
ports:
|
ports:
|
||||||
- 9090
|
- 9090
|
||||||
|
|
||||||
# mysqld-exporter:
|
|
||||||
# image: prom/mysqld-exporter
|
|
||||||
# environment:
|
|
||||||
# - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
|
|
||||||
# ports:
|
|
||||||
# - 9104
|
|
||||||
|
@ -6,3 +6,9 @@ providers:
|
|||||||
type: file
|
type: file
|
||||||
options:
|
options:
|
||||||
path: /etc/grafana/provisioning/dashboards/alerts
|
path: /etc/grafana/provisioning/dashboards/alerts
|
||||||
|
|
||||||
|
- name: 'MySQL'
|
||||||
|
folder: 'MySQL'
|
||||||
|
type: file
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards/mysql
|
File diff suppressed because it is too large
Load Diff
@ -30,10 +30,10 @@ scrape_configs:
|
|||||||
port: 3000
|
port: 3000
|
||||||
refresh_interval: 10s
|
refresh_interval: 10s
|
||||||
|
|
||||||
# - job_name: 'mysql'
|
- job_name: 'mysql'
|
||||||
# dns_sd_configs:
|
dns_sd_configs:
|
||||||
# - names:
|
- names:
|
||||||
# - 'mysqld-exporter'
|
- 'mysqld-exporter'
|
||||||
# type: 'A'
|
type: 'A'
|
||||||
# port: 9104
|
port: 9104
|
||||||
# refresh_interval: 10s
|
refresh_interval: 10s
|
@ -8,7 +8,7 @@ Docker
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
Run load test for 15 minutes:
|
Run load test for 15 minutes using 2 virtual users and targeting http://localhost:3000.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ ./run.sh
|
$ ./run.sh
|
||||||
@ -20,6 +20,18 @@ Run load test for custom duration:
|
|||||||
$ ./run.sh -d 10s
|
$ ./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:
|
Example output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -65,7 +65,7 @@ export default (data) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sleep(1)
|
sleep(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const teardown = (data) => {}
|
export const teardown = (data) => {}
|
||||||
|
@ -5,8 +5,9 @@ PWD=$(pwd)
|
|||||||
run() {
|
run() {
|
||||||
duration='15m'
|
duration='15m'
|
||||||
url='http://localhost:3000'
|
url='http://localhost:3000'
|
||||||
|
vus='2'
|
||||||
|
|
||||||
while getopts ":d:u:" o; do
|
while getopts ":d:u:v:" o; do
|
||||||
case "${o}" in
|
case "${o}" in
|
||||||
d)
|
d)
|
||||||
duration=${OPTARG}
|
duration=${OPTARG}
|
||||||
@ -14,11 +15,14 @@ run() {
|
|||||||
u)
|
u)
|
||||||
url=${OPTARG}
|
url=${OPTARG}
|
||||||
;;
|
;;
|
||||||
|
v)
|
||||||
|
vus=${OPTARG}
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
shift $((OPTIND-1))
|
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 "$@"
|
run "$@"
|
||||||
|
@ -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
|
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.
|
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
|
### Anonymous authentication
|
||||||
|
|
||||||
You can make Grafana accessible without any login required by enabling anonymous access in the configuration file.
|
You can make Grafana accessible without any login required by enabling anonymous access in the configuration file.
|
||||||
|
@ -74,6 +74,12 @@ Here is a minimal policy example:
|
|||||||
"ec2:DescribeRegions"
|
"ec2:DescribeRegions"
|
||||||
],
|
],
|
||||||
"Resource": "*"
|
"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.
|
*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`.
|
*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`.
|
*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).
|
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/RDS,CPUUtilization,DBInstanceIdentifier)* | RDS
|
||||||
*dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)* | S3
|
*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
|
*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
|
## ec2_instance_attribute examples
|
||||||
|
|
||||||
@ -205,6 +214,16 @@ Example `ec2_instance_attribute()` query
|
|||||||
ec2_instance_attribute(us-east-1, Tags.Name, { "tag:Team": [ "sysops" ] })
|
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
|
## Cost
|
||||||
|
|
||||||
Amazon provides 1 million CloudWatch API requests each month at no additional charge. Past this,
|
Amazon provides 1 million CloudWatch API requests each month at no additional charge. Past this,
|
||||||
|
@ -97,7 +97,7 @@ Creates an annotation in the Grafana database. The `dashboardId` and `panelId` f
|
|||||||
|
|
||||||
**Example Request**:
|
**Example Request**:
|
||||||
|
|
||||||
```json
|
```http
|
||||||
POST /api/annotations HTTP/1.1
|
POST /api/annotations HTTP/1.1
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
@ -115,7 +115,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**Example Response**:
|
**Example Response**:
|
||||||
|
|
||||||
```json
|
```http
|
||||||
HTTP/1.1 200
|
HTTP/1.1 200
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
@ -135,7 +135,7 @@ format (string with multiple tags being separated by a space).
|
|||||||
|
|
||||||
**Example Request**:
|
**Example Request**:
|
||||||
|
|
||||||
```json
|
```http
|
||||||
POST /api/annotations/graphite HTTP/1.1
|
POST /api/annotations/graphite HTTP/1.1
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
@ -150,7 +150,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**Example Response**:
|
**Example Response**:
|
||||||
|
|
||||||
```json
|
```http
|
||||||
HTTP/1.1 200
|
HTTP/1.1 200
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
@ -164,11 +164,14 @@ Content-Type: application/json
|
|||||||
|
|
||||||
`PUT /api/annotations/:id`
|
`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**:
|
**Example Request**:
|
||||||
|
|
||||||
```json
|
```http
|
||||||
PUT /api/annotations/1141 HTTP/1.1
|
PUT /api/annotations/1141 HTTP/1.1
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
||||||
Content-Type: application/json
|
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 Annotation By Id
|
||||||
|
|
||||||
`DELETE /api/annotations/:id`
|
`DELETE /api/annotations/:id`
|
||||||
@ -201,7 +248,9 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
|||||||
HTTP/1.1 200
|
HTTP/1.1 200
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{"message":"Annotation deleted"}
|
{
|
||||||
|
"message":"Annotation deleted"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Delete Annotation By RegionId
|
## Delete Annotation By RegionId
|
||||||
@ -225,5 +274,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
|
|||||||
HTTP/1.1 200
|
HTTP/1.1 200
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{"message":"Annotation region deleted"}
|
{
|
||||||
|
"message":"Annotation region deleted"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
@ -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
|
Define a white list of allowed ips/domains to use in data sources. Format: `ip_or_domain:port` separated by spaces
|
||||||
|
|
||||||
|
### cookie_secure
|
||||||
|
|
||||||
|
Set to `true` if you host Grafana behind HTTPS. Default is `false`.
|
||||||
|
|
||||||
|
### cookie_samesite
|
||||||
|
|
||||||
|
Sets the `SameSite` cookie attribute and prevents the browser from sending this cookie along with cross-site requests. The main goal is mitigate the risk of cross-origin information leakage. It also provides some protection against cross-site request forgery attacks (CSRF), [read more here](https://www.owasp.org/index.php/SameSite). Valid values are `lax`, `strict` and `none`. Default is `lax`.
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
## [users]
|
## [users]
|
||||||
@ -393,9 +401,7 @@ Analytics ID here. By default this feature is disabled.
|
|||||||
|
|
||||||
### check_for_updates
|
### 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
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
@ -50,6 +50,7 @@ Filter Option | Example | Raw | Interpolated | Description
|
|||||||
`regex` | ${servers:regex} | `'test.', 'test2'` | <code>(test\.|test2)</code> | Formats multi-value variable into a regex string
|
`regex` | ${servers:regex} | `'test.', 'test2'` | <code>(test\.|test2)</code> | Formats multi-value variable into a regex string
|
||||||
`pipe` | ${servers:pipe} | `'test.', 'test2'` | <code>test.|test2</code> | Formats multi-value variable into a pipe-separated string
|
`pipe` | ${servers:pipe} | `'test.', 'test2'` | <code>test.|test2</code> | 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
|
`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.
|
`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.
|
`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.
|
`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
|
||||||
|
@ -85,6 +85,7 @@
|
|||||||
"prettier": "1.9.2",
|
"prettier": "1.9.2",
|
||||||
"react-hot-loader": "^4.3.6",
|
"react-hot-loader": "^4.3.6",
|
||||||
"react-test-renderer": "^16.5.0",
|
"react-test-renderer": "^16.5.0",
|
||||||
|
"redux-mock-store": "^1.5.3",
|
||||||
"regexp-replace-loader": "^1.0.1",
|
"regexp-replace-loader": "^1.0.1",
|
||||||
"sass-lint": "^1.10.2",
|
"sass-lint": "^1.10.2",
|
||||||
"sass-loader": "^7.0.1",
|
"sass-loader": "^7.0.1",
|
||||||
|
@ -8,6 +8,6 @@
|
|||||||
"tslint": "echo \"Nothing to do\"",
|
"tslint": "echo \"Nothing to do\"",
|
||||||
"typecheck": "echo \"Nothing to do\""
|
"typecheck": "echo \"Nothing to do\""
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "Grafana Labs",
|
||||||
"license": "ISC"
|
"license": "Apache-2.0"
|
||||||
}
|
}
|
||||||
|
@ -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';
|
import '../../../public/sass/grafana.light.scss';
|
||||||
|
|
||||||
// automatically import all files ending in *.stories.tsx
|
// automatically import all files ending in *.stories.tsx
|
||||||
const req = require.context('../src/components', true, /.story.tsx$/);
|
const req = require.context('../src/components', true, /.story.tsx$/);
|
||||||
|
|
||||||
|
addDecorator(withKnobs);
|
||||||
|
addDecorator(withTheme);
|
||||||
|
|
||||||
function loadStories() {
|
function loadStories() {
|
||||||
req.keys().forEach(req);
|
req.keys().forEach(req);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
module.exports = (baseConfig, env, config) => {
|
module.exports = (baseConfig, env, config) => {
|
||||||
|
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.(ts|tsx)$/,
|
test: /\.(ts|tsx)$/,
|
||||||
use: [
|
use: [
|
||||||
@ -33,7 +32,12 @@ module.exports = (baseConfig, env, config) => {
|
|||||||
config: { path: __dirname + '../../../../scripts/webpack/postcss.config.js' },
|
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');
|
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;
|
return config;
|
||||||
};
|
};
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"storybook": "start-storybook -p 9001 -c .storybook -s ../../public"
|
"storybook": "start-storybook -p 9001 -c .storybook -s ../../public"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "Grafana Labs",
|
||||||
"license": "ISC",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@torkelo/react-select": "2.1.1",
|
"@torkelo/react-select": "2.1.1",
|
||||||
"@types/react-color": "^2.14.0",
|
"@types/react-color": "^2.14.0",
|
||||||
|
@ -1,46 +1,43 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { storiesOf } from '@storybook/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 { SeriesColorPicker, ColorPicker } from './ColorPicker';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
import { UseState } from '../../utils/storybook/UseState';
|
import { UseState } from '../../utils/storybook/UseState';
|
||||||
import { getThemeKnob } from '../../utils/storybook/themeKnob';
|
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||||
|
|
||||||
const getColorPickerKnobs = () => {
|
const getColorPickerKnobs = () => {
|
||||||
return {
|
return {
|
||||||
selectedTheme: getThemeKnob(),
|
|
||||||
enableNamedColors: boolean('Enable named colors', false),
|
enableNamedColors: boolean('Enable named colors', false),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ColorPickerStories = storiesOf('UI/ColorPicker/Pickers', module);
|
const ColorPickerStories = storiesOf('UI/ColorPicker/Pickers', module);
|
||||||
|
|
||||||
ColorPickerStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
|
ColorPickerStories.addDecorator(withCenteredStory);
|
||||||
|
|
||||||
ColorPickerStories.add('default', () => {
|
ColorPickerStories.add('default', () => {
|
||||||
const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
|
const { enableNamedColors } = getColorPickerKnobs();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UseState initialState="#00ff00">
|
<UseState initialState="#00ff00">
|
||||||
{(selectedColor, updateSelectedColor) => {
|
{(selectedColor, updateSelectedColor) => {
|
||||||
return (
|
return renderComponentWithTheme(ColorPicker, {
|
||||||
<ColorPicker
|
enableNamedColors,
|
||||||
enableNamedColors={enableNamedColors}
|
color: selectedColor,
|
||||||
color={selectedColor}
|
onChange: (color: any) => {
|
||||||
onChange={color => {
|
action('Color changed')(color);
|
||||||
action('Color changed')(color);
|
updateSelectedColor(color);
|
||||||
updateSelectedColor(color);
|
},
|
||||||
}}
|
});
|
||||||
theme={selectedTheme || undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
</UseState>
|
</UseState>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ColorPickerStories.add('Series color picker', () => {
|
ColorPickerStories.add('Series color picker', () => {
|
||||||
const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
|
const { enableNamedColors } = getColorPickerKnobs();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UseState initialState="#00ff00">
|
<UseState initialState="#00ff00">
|
||||||
@ -52,7 +49,6 @@ ColorPickerStories.add('Series color picker', () => {
|
|||||||
onToggleAxis={() => {}}
|
onToggleAxis={() => {}}
|
||||||
color={selectedColor}
|
color={selectedColor}
|
||||||
onChange={color => updateSelectedColor(color)}
|
onChange={color => updateSelectedColor(color)}
|
||||||
theme={selectedTheme || undefined}
|
|
||||||
>
|
>
|
||||||
<div style={{ color: selectedColor, cursor: 'pointer' }}>Open color picker</div>
|
<div style={{ color: selectedColor, cursor: 'pointer' }}>Open color picker</div>
|
||||||
</SeriesColorPicker>
|
</SeriesColorPicker>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React, { Component, createRef } from 'react';
|
import React, { Component, createRef } from 'react';
|
||||||
import PopperController from '../Tooltip/PopperController';
|
import PopperController from '../Tooltip/PopperController';
|
||||||
import Popper, { RenderPopperArrowFn } from '../Tooltip/Popper';
|
import Popper from '../Tooltip/Popper';
|
||||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||||
import { Themeable, GrafanaTheme } from '../../types';
|
import { Themeable } from '../../types';
|
||||||
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||||
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
||||||
import propDeprecationWarning from '../../utils/propDeprecationWarning';
|
import propDeprecationWarning from '../../utils/propDeprecationWarning';
|
||||||
|
import { withTheme } from '../../themes/ThemeContext';
|
||||||
type ColorPickerChangeHandler = (color: string) => void;
|
type ColorPickerChangeHandler = (color: string) => void;
|
||||||
|
|
||||||
export interface ColorPickerProps extends Themeable {
|
export interface ColorPickerProps extends Themeable {
|
||||||
@ -18,7 +18,6 @@ export interface ColorPickerProps extends Themeable {
|
|||||||
*/
|
*/
|
||||||
onColorChange?: ColorPickerChangeHandler;
|
onColorChange?: ColorPickerChangeHandler;
|
||||||
enableNamedColors?: boolean;
|
enableNamedColors?: boolean;
|
||||||
withArrow?: boolean;
|
|
||||||
children?: JSX.Element;
|
children?: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +31,6 @@ export const warnAboutColorPickerPropsDeprecation = (componentName: string, prop
|
|||||||
export const colorPickerFactory = <T extends ColorPickerProps>(
|
export const colorPickerFactory = <T extends ColorPickerProps>(
|
||||||
popover: React.ComponentType<T>,
|
popover: React.ComponentType<T>,
|
||||||
displayName = 'ColorPicker',
|
displayName = 'ColorPicker',
|
||||||
renderPopoverArrowFunction?: RenderPopperArrowFn
|
|
||||||
) => {
|
) => {
|
||||||
return class ColorPicker extends Component<T, any> {
|
return class ColorPicker extends Component<T, any> {
|
||||||
static displayName = displayName;
|
static displayName = displayName;
|
||||||
@ -50,17 +48,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
|||||||
...this.props,
|
...this.props,
|
||||||
onChange: this.handleColorChange,
|
onChange: this.handleColorChange,
|
||||||
});
|
});
|
||||||
const { theme, withArrow, children } = this.props;
|
const { theme, children } = this.props;
|
||||||
|
|
||||||
const renderArrow: RenderPopperArrowFn = ({ arrowProps, placement }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...arrowProps}
|
|
||||||
data-placement={placement}
|
|
||||||
className={`ColorPicker__arrow ColorPicker__arrow--${theme === GrafanaTheme.Light ? 'light' : 'dark'}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopperController content={popoverElement} hideAfter={300}>
|
<PopperController content={popoverElement} hideAfter={300}>
|
||||||
@ -72,7 +60,6 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
|||||||
{...popperProps}
|
{...popperProps}
|
||||||
referenceElement={this.pickerTriggerRef.current}
|
referenceElement={this.pickerTriggerRef.current}
|
||||||
wrapperClassName="ColorPicker"
|
wrapperClassName="ColorPicker"
|
||||||
renderArrow={withArrow && (renderPopoverArrowFunction || renderArrow)}
|
|
||||||
onMouseLeave={hidePopper}
|
onMouseLeave={hidePopper}
|
||||||
onMouseEnter={showPopper}
|
onMouseEnter={showPopper}
|
||||||
/>
|
/>
|
||||||
@ -95,7 +82,7 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
|||||||
<div
|
<div
|
||||||
className="sp-preview-inner"
|
className="sp-preview-inner"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: getColorFromHexRgbOrName(this.props.color || '#000000', theme),
|
backgroundColor: getColorFromHexRgbOrName(this.props.color || '#000000', theme.type),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -110,5 +97,5 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker');
|
export const ColorPicker = withTheme(colorPickerFactory(ColorPickerPopover, 'ColorPicker'));
|
||||||
export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker');
|
export const SeriesColorPicker = withTheme(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker'));
|
||||||
|
@ -1,40 +1,27 @@
|
|||||||
import React from 'react';
|
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||||
import { withKnobs } from '@storybook/addon-knobs';
|
|
||||||
|
|
||||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
import { getThemeKnob } from '../../utils/storybook/themeKnob';
|
|
||||||
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
|
||||||
|
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||||
const ColorPickerPopoverStories = storiesOf('UI/ColorPicker/Popovers', module);
|
const ColorPickerPopoverStories = storiesOf('UI/ColorPicker/Popovers', module);
|
||||||
|
|
||||||
ColorPickerPopoverStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
|
ColorPickerPopoverStories.addDecorator(withCenteredStory);
|
||||||
|
|
||||||
ColorPickerPopoverStories.add('default', () => {
|
ColorPickerPopoverStories.add('default', () => {
|
||||||
const selectedTheme = getThemeKnob();
|
return renderComponentWithTheme(ColorPickerPopover, {
|
||||||
|
color: '#BC67E6',
|
||||||
return (
|
onChange: (color: any) => {
|
||||||
<ColorPickerPopover
|
console.log(color);
|
||||||
color="#BC67E6"
|
},
|
||||||
onChange={color => {
|
});
|
||||||
console.log(color);
|
|
||||||
}}
|
|
||||||
theme={selectedTheme || undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => {
|
ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => {
|
||||||
const selectedTheme = getThemeKnob();
|
return renderComponentWithTheme(SeriesColorPickerPopover, {
|
||||||
|
color: '#BC67E6',
|
||||||
return (
|
onChange: (color: any) => {
|
||||||
<SeriesColorPickerPopover
|
console.log(color);
|
||||||
color="#BC67E6"
|
},
|
||||||
onChange={color => {
|
});
|
||||||
console.log(color);
|
|
||||||
}}
|
|
||||||
theme={selectedTheme || undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
@ -4,7 +4,8 @@ import { ColorPickerPopover } from './ColorPickerPopover';
|
|||||||
import { getColorDefinitionByName, getNamedColorPalette } from '../../utils/namedColorsPalette';
|
import { getColorDefinitionByName, getNamedColorPalette } from '../../utils/namedColorsPalette';
|
||||||
import { ColorSwatch } from './NamedColorsGroup';
|
import { ColorSwatch } from './NamedColorsGroup';
|
||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
import { GrafanaTheme } from '../../types';
|
import { GrafanaThemeType } from '../../types';
|
||||||
|
import { getTheme } from '../../themes';
|
||||||
|
|
||||||
const allColors = flatten(Array.from(getNamedColorPalette().values()));
|
const allColors = flatten(Array.from(getNamedColorPalette().values()));
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ describe('ColorPickerPopover', () => {
|
|||||||
|
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('should render provided color as selected if color provided by name', () => {
|
it('should render provided color as selected if color provided by name', () => {
|
||||||
const wrapper = mount(<ColorPickerPopover color={BasicGreen.name} onChange={() => {}} />);
|
const wrapper = mount(<ColorPickerPopover color={BasicGreen.name} onChange={() => {}} theme={getTheme()}/>);
|
||||||
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||||
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
|
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', () => {
|
it('should render provided color as selected if color provided by hex', () => {
|
||||||
const wrapper = mount(<ColorPickerPopover color={BasicGreen.variants.dark} onChange={() => {}} />);
|
const wrapper = mount(<ColorPickerPopover color={BasicGreen.variants.dark} onChange={() => {}} theme={getTheme()} />);
|
||||||
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||||
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
|
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', () => {
|
it('should pass hex color value to onChange prop by default', () => {
|
||||||
wrapper = mount(
|
wrapper = mount(
|
||||||
<ColorPickerPopover color={BasicGreen.variants.dark} onChange={onChangeSpy} theme={GrafanaTheme.Light} />
|
<ColorPickerPopover color={BasicGreen.variants.dark} onChange={onChangeSpy} theme={getTheme(GrafanaThemeType.Light)} />
|
||||||
);
|
);
|
||||||
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
|
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
|
||||||
|
|
||||||
@ -61,7 +62,7 @@ describe('ColorPickerPopover', () => {
|
|||||||
enableNamedColors
|
enableNamedColors
|
||||||
color={BasicGreen.variants.dark}
|
color={BasicGreen.variants.dark}
|
||||||
onChange={onChangeSpy}
|
onChange={onChangeSpy}
|
||||||
theme={GrafanaTheme.Light}
|
theme={getTheme(GrafanaThemeType.Light)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
|
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
|
||||||
|
@ -2,9 +2,9 @@ import React from 'react';
|
|||||||
import { NamedColorsPalette } from './NamedColorsPalette';
|
import { NamedColorsPalette } from './NamedColorsPalette';
|
||||||
import { getColorName, getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
import { getColorName, getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
|
||||||
import { ColorPickerProps, warnAboutColorPickerPropsDeprecation } from './ColorPicker';
|
import { ColorPickerProps, warnAboutColorPickerPropsDeprecation } from './ColorPicker';
|
||||||
import { GrafanaTheme } from '../../types';
|
|
||||||
import { PopperContentProps } from '../Tooltip/PopperController';
|
import { PopperContentProps } from '../Tooltip/PopperController';
|
||||||
import SpectrumPalette from './SpectrumPalette';
|
import SpectrumPalette from './SpectrumPalette';
|
||||||
|
import { GrafanaThemeType } from '@grafana/ui';
|
||||||
|
|
||||||
export interface Props<T> extends ColorPickerProps, PopperContentProps {
|
export interface Props<T> extends ColorPickerProps, PopperContentProps {
|
||||||
customPickers?: T;
|
customPickers?: T;
|
||||||
@ -43,7 +43,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
|||||||
if (enableNamedColors) {
|
if (enableNamedColors) {
|
||||||
return changeHandler(color);
|
return changeHandler(color);
|
||||||
}
|
}
|
||||||
changeHandler(getColorFromHexRgbOrName(color, theme));
|
changeHandler(getColorFromHexRgbOrName(color, theme.type));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleTabChange = (tab: PickerType | keyof T) => {
|
handleTabChange = (tab: PickerType | keyof T) => {
|
||||||
@ -58,7 +58,9 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
|||||||
case 'spectrum':
|
case 'spectrum':
|
||||||
return <SpectrumPalette color={color} onChange={this.handleChange} theme={theme} />;
|
return <SpectrumPalette color={color} onChange={this.handleChange} theme={theme} />;
|
||||||
case 'palette':
|
case 'palette':
|
||||||
return <NamedColorsPalette color={getColorName(color, theme)} onChange={this.handleChange} theme={theme} />;
|
return (
|
||||||
|
<NamedColorsPalette color={getColorName(color, theme.type)} onChange={this.handleChange} theme={theme} />
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return this.renderCustomPicker(activePicker);
|
return this.renderCustomPicker(activePicker);
|
||||||
}
|
}
|
||||||
@ -88,11 +90,7 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
|||||||
<>
|
<>
|
||||||
{Object.keys(customPickers).map(key => {
|
{Object.keys(customPickers).map(key => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={this.getTabClassName(key)} onClick={this.handleTabChange(key)} key={key}>
|
||||||
className={this.getTabClassName(key)}
|
|
||||||
onClick={this.handleTabChange(key)}
|
|
||||||
key={key}
|
|
||||||
>
|
|
||||||
{customPickers[key].name}
|
{customPickers[key].name}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -103,21 +101,14 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { theme } = this.props;
|
const { theme } = this.props;
|
||||||
const colorPickerTheme = theme || GrafanaTheme.Dark;
|
const colorPickerTheme = theme.type || GrafanaThemeType.Dark;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
|
<div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
|
||||||
<div className="ColorPickerPopover__tabs">
|
<div className="ColorPickerPopover__tabs">
|
||||||
<div
|
<div className={this.getTabClassName('palette')} onClick={this.handleTabChange('palette')}>
|
||||||
className={this.getTabClassName('palette')}
|
|
||||||
onClick={this.handleTabChange('palette')}
|
|
||||||
>
|
|
||||||
Colors
|
Colors
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={this.getTabClassName('spectrum')} onClick={this.handleTabChange('spectrum')}>
|
||||||
className={this.getTabClassName('spectrum')}
|
|
||||||
onClick={this.handleTabChange('spectrum')}
|
|
||||||
>
|
|
||||||
Custom
|
Custom
|
||||||
</div>
|
</div>
|
||||||
{this.renderCustomPickerTabs()}
|
{this.renderCustomPickerTabs()}
|
||||||
@ -128,3 +119,4 @@ export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import React, { FunctionComponent } from 'react';
|
import React, { FunctionComponent } from 'react';
|
||||||
import { Themeable, GrafanaTheme } from '../../types';
|
import { Themeable } from '../../types';
|
||||||
import { ColorDefinition, getColorForTheme } from '../../utils/namedColorsPalette';
|
import { ColorDefinition, getColorForTheme } from '../../utils/namedColorsPalette';
|
||||||
import { Color } from 'csstype';
|
import { Color } from 'csstype';
|
||||||
import { find, upperFirst } from 'lodash';
|
import { find, upperFirst } from 'lodash';
|
||||||
|
import { selectThemeVariant } from '../../themes/selectThemeVariant';
|
||||||
|
|
||||||
type ColorChangeHandler = (color: ColorDefinition) => void;
|
type ColorChangeHandler = (color: ColorDefinition) => void;
|
||||||
|
|
||||||
@ -28,7 +29,15 @@ export const ColorSwatch: FunctionComponent<ColorSwatchProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const isSmall = variant === ColorSwatchVariant.Small;
|
const isSmall = variant === ColorSwatchVariant.Small;
|
||||||
const swatchSize = isSmall ? '16px' : '32px';
|
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 = {
|
const swatchStyles = {
|
||||||
width: swatchSize,
|
width: swatchSize,
|
||||||
height: swatchSize,
|
height: swatchSize,
|
||||||
@ -76,7 +85,7 @@ const NamedColorsGroup: FunctionComponent<NamedColorsGroupProps> = ({
|
|||||||
key={primaryColor.name}
|
key={primaryColor.name}
|
||||||
isSelected={primaryColor.name === selectedColor}
|
isSelected={primaryColor.name === selectedColor}
|
||||||
variant={ColorSwatchVariant.Large}
|
variant={ColorSwatchVariant.Large}
|
||||||
color={getColorForTheme(primaryColor, theme)}
|
color={getColorForTheme(primaryColor, theme.type)}
|
||||||
label={upperFirst(primaryColor.hue)}
|
label={upperFirst(primaryColor.hue)}
|
||||||
onClick={() => onColorSelect(primaryColor)}
|
onClick={() => onColorSelect(primaryColor)}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@ -95,7 +104,7 @@ const NamedColorsGroup: FunctionComponent<NamedColorsGroupProps> = ({
|
|||||||
<ColorSwatch
|
<ColorSwatch
|
||||||
key={color.name}
|
key={color.name}
|
||||||
isSelected={color.name === selectedColor}
|
isSelected={color.name === selectedColor}
|
||||||
color={getColorForTheme(color, theme)}
|
color={getColorForTheme(color, theme.type)}
|
||||||
onClick={() => onColorSelect(color)}
|
onClick={() => onColorSelect(color)}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
|
@ -2,8 +2,9 @@ import React from 'react';
|
|||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { NamedColorsPalette } from './NamedColorsPalette';
|
import { NamedColorsPalette } from './NamedColorsPalette';
|
||||||
import { getColorName, getColorDefinitionByName } from '../../utils/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 { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
|
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||||
import { UseState } from '../../utils/storybook/UseState';
|
import { UseState } from '../../utils/storybook/UseState';
|
||||||
|
|
||||||
const BasicGreen = getColorDefinitionByName('green');
|
const BasicGreen = getColorDefinitionByName('green');
|
||||||
@ -12,7 +13,7 @@ const LightBlue = getColorDefinitionByName('light-blue');
|
|||||||
|
|
||||||
const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module);
|
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', () => {
|
NamedColorsPaletteStories.add('Named colors swatch - support for named colors', () => {
|
||||||
const selectedColor = select(
|
const selectedColor = select(
|
||||||
@ -28,7 +29,10 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors',
|
|||||||
return (
|
return (
|
||||||
<UseState initialState={selectedColor}>
|
<UseState initialState={selectedColor}>
|
||||||
{(selectedColor, updateSelectedColor) => {
|
{(selectedColor, updateSelectedColor) => {
|
||||||
return <NamedColorsPalette color={selectedColor} onChange={updateSelectedColor} />;
|
return renderComponentWithTheme(NamedColorsPalette, {
|
||||||
|
color: selectedColor,
|
||||||
|
onChange: updateSelectedColor,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
</UseState>
|
</UseState>
|
||||||
);
|
);
|
||||||
@ -45,7 +49,10 @@ NamedColorsPaletteStories.add('Named colors swatch - support for named colors',
|
|||||||
return (
|
return (
|
||||||
<UseState initialState={selectedColor}>
|
<UseState initialState={selectedColor}>
|
||||||
{(selectedColor, updateSelectedColor) => {
|
{(selectedColor, updateSelectedColor) => {
|
||||||
return <NamedColorsPalette color={getColorName(selectedColor)} onChange={updateSelectedColor} />;
|
return renderComponentWithTheme(NamedColorsPalette, {
|
||||||
|
color: getColorName(selectedColor),
|
||||||
|
onChange: updateSelectedColor,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
</UseState>
|
</UseState>
|
||||||
);
|
);
|
||||||
|
@ -3,7 +3,8 @@ import { mount, ReactWrapper } from 'enzyme';
|
|||||||
import { NamedColorsPalette } from './NamedColorsPalette';
|
import { NamedColorsPalette } from './NamedColorsPalette';
|
||||||
import { ColorSwatch } from './NamedColorsGroup';
|
import { ColorSwatch } from './NamedColorsGroup';
|
||||||
import { getColorDefinitionByName } from '../../utils';
|
import { getColorDefinitionByName } from '../../utils';
|
||||||
import { GrafanaTheme } from '../../types';
|
import { getTheme } from '../../themes';
|
||||||
|
import { GrafanaThemeType } from '../../types';
|
||||||
|
|
||||||
describe('NamedColorsPalette', () => {
|
describe('NamedColorsPalette', () => {
|
||||||
|
|
||||||
@ -17,18 +18,18 @@ describe('NamedColorsPalette', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render provided color variant specific for theme', () => {
|
it('should render provided color variant specific for theme', () => {
|
||||||
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={GrafanaTheme.Dark} onChange={() => {}} />);
|
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={getTheme()} onChange={() => {}} />);
|
||||||
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||||
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
|
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
|
||||||
|
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={GrafanaTheme.Light} onChange={() => {}} />);
|
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={getTheme(GrafanaThemeType.Light)} onChange={() => {}} />);
|
||||||
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||||
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light);
|
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render dar variant of provided color when theme not provided', () => {
|
it('should render dar variant of provided color when theme not provided', () => {
|
||||||
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} onChange={() => {}} />);
|
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} onChange={() => {}} theme={getTheme()}/>);
|
||||||
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
|
||||||
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
|
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
|
||||||
});
|
});
|
||||||
|
@ -4,6 +4,7 @@ import { ColorPickerPopover } from './ColorPickerPopover';
|
|||||||
import { ColorPickerProps } from './ColorPicker';
|
import { ColorPickerProps } from './ColorPicker';
|
||||||
import { PopperContentProps } from '../Tooltip/PopperController';
|
import { PopperContentProps } from '../Tooltip/PopperController';
|
||||||
import { Switch } from '../Switch/Switch';
|
import { Switch } from '../Switch/Switch';
|
||||||
|
import { withTheme } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperContentProps {
|
export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperContentProps {
|
||||||
yaxis?: number;
|
yaxis?: number;
|
||||||
@ -12,7 +13,6 @@ export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperC
|
|||||||
|
|
||||||
export const SeriesColorPickerPopover: FunctionComponent<SeriesColorPickerPopoverProps> = props => {
|
export const SeriesColorPickerPopover: FunctionComponent<SeriesColorPickerPopoverProps> = props => {
|
||||||
const { yaxis, onToggleAxis, color, ...colorPickerProps } = props;
|
const { yaxis, onToggleAxis, color, ...colorPickerProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ColorPickerPopover
|
<ColorPickerPopover
|
||||||
{...colorPickerProps}
|
{...colorPickerProps}
|
||||||
@ -85,3 +85,6 @@ export class AxisSelector extends React.PureComponent<AxisSelectorProps, AxisSel
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This component is to enable SeriecColorPickerPopover usage via series-color-picker-popover directive
|
||||||
|
export const SeriesColorPickerPopoverWithTheme = withTheme(SeriesColorPickerPopover);
|
||||||
|
@ -1,22 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { storiesOf } from '@storybook/react';
|
import { storiesOf } from '@storybook/react';
|
||||||
import { withKnobs } from '@storybook/addon-knobs';
|
|
||||||
import SpectrumPalette from './SpectrumPalette';
|
import SpectrumPalette from './SpectrumPalette';
|
||||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
import { UseState } from '../../utils/storybook/UseState';
|
import { UseState } from '../../utils/storybook/UseState';
|
||||||
import { getThemeKnob } from '../../utils/storybook/themeKnob';
|
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
|
||||||
|
|
||||||
const SpectrumPaletteStories = storiesOf('UI/ColorPicker/Palettes/SpectrumPalette', module);
|
const SpectrumPaletteStories = storiesOf('UI/ColorPicker/Palettes/SpectrumPalette', module);
|
||||||
|
|
||||||
SpectrumPaletteStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
|
SpectrumPaletteStories.addDecorator(withCenteredStory);
|
||||||
|
|
||||||
SpectrumPaletteStories.add('default', () => {
|
SpectrumPaletteStories.add('default', () => {
|
||||||
const selectedTheme = getThemeKnob();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UseState initialState="red">
|
<UseState initialState="red">
|
||||||
{(selectedColor, updateSelectedColor) => {
|
{(selectedColor, updateSelectedColor) => {
|
||||||
return <SpectrumPalette theme={selectedTheme} color={selectedColor} onChange={updateSelectedColor} />;
|
return renderComponentWithTheme(SpectrumPalette, { color: selectedColor, onChange: updateSelectedColor });
|
||||||
}}
|
}}
|
||||||
</UseState>
|
</UseState>
|
||||||
);
|
);
|
||||||
|
@ -13,7 +13,7 @@ export interface SpectrumPaletteProps extends Themeable {
|
|||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPointer = (theme?: GrafanaTheme) => (props: SpectrumPalettePointerProps) => (
|
const renderPointer = (theme: GrafanaTheme) => (props: SpectrumPalettePointerProps) => (
|
||||||
<SpectrumPalettePointer {...props} theme={theme} />
|
<SpectrumPalettePointer {...props} theme={theme} />
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -92,7 +92,7 @@ const SpectrumPalette: React.FunctionComponent<SpectrumPaletteProps> = ({ color,
|
|||||||
}}
|
}}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
<ColorInput color={color} onChange={onChange} style={{ marginTop: '16px' }} />
|
<ColorInput theme={theme} color={color} onChange={onChange} style={{ marginTop: '16px' }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { GrafanaTheme, Themeable } from '../../types';
|
import { Themeable } from '../../types';
|
||||||
|
import { selectThemeVariant } from '../../themes/selectThemeVariant';
|
||||||
|
|
||||||
export interface SpectrumPalettePointerProps extends Themeable {
|
export interface SpectrumPalettePointerProps extends Themeable {
|
||||||
direction?: string;
|
direction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpectrumPalettePointer: React.FunctionComponent<SpectrumPalettePointerProps> = ({
|
const SpectrumPalettePointer: React.FunctionComponent<SpectrumPalettePointerProps> = ({ theme, direction }) => {
|
||||||
theme,
|
|
||||||
direction,
|
|
||||||
}) => {
|
|
||||||
const styles = {
|
const styles = {
|
||||||
picker: {
|
picker: {
|
||||||
width: '16px',
|
width: '16px',
|
||||||
@ -17,7 +15,14 @@ const SpectrumPalettePointer: React.FunctionComponent<SpectrumPalettePointerProp
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const pointerColor = theme === GrafanaTheme.Light ? '#3F444D' : '#8E8E8E';
|
|
||||||
|
const pointerColor = selectThemeVariant(
|
||||||
|
{
|
||||||
|
light: theme.colors.dark3,
|
||||||
|
dark: theme.colors.gray2,
|
||||||
|
},
|
||||||
|
theme.type
|
||||||
|
);
|
||||||
|
|
||||||
let pointerStyles: React.CSSProperties = {
|
let pointerStyles: React.CSSProperties = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
|
|||||||
|
|
||||||
import { Gauge, Props } from './Gauge';
|
import { Gauge, Props } from './Gauge';
|
||||||
import { ValueMapping, MappingType } from '../../types';
|
import { ValueMapping, MappingType } from '../../types';
|
||||||
|
import { getTheme } from '../../themes';
|
||||||
|
|
||||||
jest.mock('jquery', () => ({
|
jest.mock('jquery', () => ({
|
||||||
plot: jest.fn(),
|
plot: jest.fn(),
|
||||||
@ -24,6 +25,7 @@ const setup = (propOverrides?: object) => {
|
|||||||
width: 300,
|
width: 300,
|
||||||
value: 25,
|
value: 25,
|
||||||
decimals: 0,
|
decimals: 0,
|
||||||
|
theme: getTheme()
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
Object.assign(props, propOverrides);
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
|
|
||||||
import { ValueMapping, Threshold, BasicGaugeColor, GrafanaTheme } from '../../types';
|
import { ValueMapping, Threshold, BasicGaugeColor, GrafanaThemeType } from '../../types';
|
||||||
import { getMappedValue } from '../../utils/valueMappings';
|
import { getMappedValue } from '../../utils/valueMappings';
|
||||||
import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
|
import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
|
||||||
|
import { Themeable } from '../../index';
|
||||||
|
|
||||||
type TimeSeriesValue = string | number | null;
|
type TimeSeriesValue = string | number | null;
|
||||||
|
|
||||||
export interface Props {
|
export interface Props extends Themeable {
|
||||||
decimals: number;
|
decimals: number;
|
||||||
height: number;
|
height: number;
|
||||||
valueMappings: ValueMapping[];
|
valueMappings: ValueMapping[];
|
||||||
@ -22,7 +23,6 @@ export interface Props {
|
|||||||
unit: string;
|
unit: string;
|
||||||
width: number;
|
width: number;
|
||||||
value: number;
|
value: number;
|
||||||
theme?: GrafanaTheme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT_SCALE = 1;
|
const FONT_SCALE = 1;
|
||||||
@ -41,7 +41,7 @@ export class Gauge extends PureComponent<Props> {
|
|||||||
thresholds: [],
|
thresholds: [],
|
||||||
unit: 'none',
|
unit: 'none',
|
||||||
stat: 'avg',
|
stat: 'avg',
|
||||||
theme: GrafanaTheme.Dark,
|
theme: GrafanaThemeType.Dark,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@ -77,19 +77,19 @@ export class Gauge extends PureComponent<Props> {
|
|||||||
const { thresholds, theme } = this.props;
|
const { thresholds, theme } = this.props;
|
||||||
|
|
||||||
if (thresholds.length === 1) {
|
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];
|
const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
|
||||||
if (atThreshold) {
|
if (atThreshold) {
|
||||||
return getColorFromHexRgbOrName(atThreshold.color, theme);
|
return getColorFromHexRgbOrName(atThreshold.color, theme.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
|
||||||
|
|
||||||
if (belowThreshold.length > 0) {
|
if (belowThreshold.length > 0) {
|
||||||
const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[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;
|
return BasicGaugeColor.Red;
|
||||||
@ -104,13 +104,13 @@ export class Gauge extends PureComponent<Props> {
|
|||||||
return [
|
return [
|
||||||
...thresholdsSortedByIndex.map(threshold => {
|
...thresholdsSortedByIndex.map(threshold => {
|
||||||
if (threshold.index === 0) {
|
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];
|
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<Props> {
|
|||||||
|
|
||||||
const formattedValue = this.formatValue(value) as string;
|
const formattedValue = this.formatValue(value) as string;
|
||||||
const dimension = Math.min(width, height * 1.3);
|
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 gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
|
||||||
const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
|
const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
|
||||||
const thresholdMarkersWidth = gaugeWidth / 5;
|
const thresholdMarkersWidth = gaugeWidth / 5;
|
||||||
|
@ -29,13 +29,14 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.panel-options-group__add-circle {
|
.panel-options-group__add-circle {
|
||||||
background-color: $btn-primary-bg;
|
background-color: $btn-primary-bg;;
|
||||||
color: $text-color-strong;
|
color: $white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-options-group__add-circle {
|
.panel-options-group__add-circle {
|
||||||
|
|
||||||
@include gradientBar($btn-primary-bg, $btn-primary-bg-hl, #fff);
|
@include gradientBar($btn-primary-bg, $btn-primary-bg-hl, #fff);
|
||||||
|
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { Threshold, Themeable } from '../../types';
|
import { Threshold } from '../../types';
|
||||||
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
||||||
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
|
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
|
||||||
import { colors } from '../../utils';
|
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[];
|
thresholds: Threshold[];
|
||||||
onChange: (thresholds: Threshold[]) => void;
|
onChange: (thresholds: Threshold[]) => void;
|
||||||
}
|
}
|
||||||
@ -164,7 +164,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
|||||||
<div className="thresholds-row-input-inner-color">
|
<div className="thresholds-row-input-inner-color">
|
||||||
{threshold.color && (
|
{threshold.color && (
|
||||||
<div className="thresholds-row-input-inner-color-colorpicker">
|
<div className="thresholds-row-input-inner-color-colorpicker">
|
||||||
<ColorPicker color={threshold.color} onChange={color => this.onChangeThresholdColor(threshold, color)} />
|
<ColorPicker
|
||||||
|
color={threshold.color}
|
||||||
|
onChange={color => this.onChangeThresholdColor(threshold, color)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -188,27 +191,35 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { thresholds } = this.state;
|
const { thresholds } = this.state;
|
||||||
const { theme } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelOptionsGroup title="Thresholds">
|
<ThemeContext.Consumer>
|
||||||
<div className="thresholds">
|
{theme => {
|
||||||
{thresholds.map((threshold, index) => {
|
return (
|
||||||
return (
|
<PanelOptionsGroup title="Thresholds">
|
||||||
<div className="thresholds-row" key={`${threshold.index}-${index}`}>
|
<div className="thresholds">
|
||||||
<div className="thresholds-row-add-button" onClick={() => this.onAddThreshold(threshold.index + 1)}>
|
{thresholds.map((threshold, index) => {
|
||||||
<i className="fa fa-plus" />
|
return (
|
||||||
</div>
|
<div className="thresholds-row" key={`${threshold.index}-${index}`}>
|
||||||
<div
|
<div
|
||||||
className="thresholds-row-color-indicator"
|
className="thresholds-row-add-button"
|
||||||
style={{ backgroundColor: getColorFromHexRgbOrName(threshold.color, theme) }}
|
onClick={() => this.onAddThreshold(threshold.index + 1)}
|
||||||
/>
|
>
|
||||||
<div className="thresholds-row-input">{this.renderInput(threshold)}</div>
|
<i className="fa fa-plus" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="thresholds-row-color-indicator"
|
||||||
|
style={{ backgroundColor: getColorFromHexRgbOrName(threshold.color, theme.type) }}
|
||||||
|
/>
|
||||||
|
<div className="thresholds-row-input">{this.renderInput(threshold)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
</PanelOptionsGroup>
|
||||||
})}
|
);
|
||||||
</div>
|
}}
|
||||||
</PanelOptionsGroup>
|
</ThemeContext.Consumer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $text-color-strong;
|
color: $white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,8 +14,8 @@ export { FormLabel } from './FormLabel/FormLabel';
|
|||||||
export { FormField } from './FormField/FormField';
|
export { FormField } from './FormField/FormField';
|
||||||
|
|
||||||
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||||
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
||||||
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
|
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
|
||||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||||
export { Graph } from './Graph/Graph';
|
export { Graph } from './Graph/Graph';
|
||||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
export * from './components';
|
export * from './components';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
export * from './themes';
|
||||||
|
export * from './themes/ThemeContext';
|
||||||
|
20
packages/grafana-ui/src/themes/ThemeContext.tsx
Normal file
20
packages/grafana-ui/src/themes/ThemeContext.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { GrafanaThemeType, Themeable } from '../types';
|
||||||
|
import { getTheme } from './index';
|
||||||
|
|
||||||
|
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
type Subtract<T, K> = Omit<T, keyof K>;
|
||||||
|
|
||||||
|
// Use Grafana Dark theme by default
|
||||||
|
export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark));
|
||||||
|
|
||||||
|
export const withTheme = <P extends Themeable>(Component: React.ComponentType<P>) => {
|
||||||
|
const WithTheme: React.FunctionComponent<Subtract<P, Themeable>> = props => {
|
||||||
|
// @ts-ignore
|
||||||
|
return <ThemeContext.Consumer>{theme => <Component {...props} theme={theme} />}</ThemeContext.Consumer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
WithTheme.displayName = `WithTheme(${Component.displayName})`;
|
||||||
|
|
||||||
|
return WithTheme;
|
||||||
|
};
|
69
packages/grafana-ui/src/themes/dark.ts
Normal file
69
packages/grafana-ui/src/themes/dark.ts
Normal file
@ -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;
|
62
packages/grafana-ui/src/themes/default.ts
Normal file
62
packages/grafana-ui/src/themes/default.ts
Normal file
@ -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;
|
14
packages/grafana-ui/src/themes/index.ts
Normal file
14
packages/grafana-ui/src/themes/index.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
|
};
|
70
packages/grafana-ui/src/themes/light.ts
Normal file
70
packages/grafana-ui/src/themes/light.ts
Normal file
@ -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;
|
52
packages/grafana-ui/src/themes/selectThemeVariant.test.ts
Normal file
52
packages/grafana-ui/src/themes/selectThemeVariant.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
9
packages/grafana-ui/src/themes/selectThemeVariant.ts
Normal file
9
packages/grafana-ui/src/themes/selectThemeVariant.ts
Normal file
@ -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];
|
||||||
|
};
|
@ -1,14 +1,7 @@
|
|||||||
|
|
||||||
export * from './data';
|
export * from './data';
|
||||||
export * from './time';
|
export * from './time';
|
||||||
export * from './panel';
|
export * from './panel';
|
||||||
export * from './plugin';
|
export * from './plugin';
|
||||||
export * from './datasource';
|
export * from './datasource';
|
||||||
|
export * from './theme';
|
||||||
export enum GrafanaTheme {
|
|
||||||
Light = 'light',
|
|
||||||
Dark = 'dark',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Themeable {
|
|
||||||
theme?: GrafanaTheme;
|
|
||||||
}
|
|
||||||
|
129
packages/grafana-ui/src/types/theme.ts
Normal file
129
packages/grafana-ui/src/types/theme.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -5,20 +5,20 @@ import {
|
|||||||
getColorFromHexRgbOrName,
|
getColorFromHexRgbOrName,
|
||||||
getColorDefinitionByName,
|
getColorDefinitionByName,
|
||||||
} from './namedColorsPalette';
|
} from './namedColorsPalette';
|
||||||
import { GrafanaTheme } from '../types/index';
|
import { GrafanaThemeType } from '../types/index';
|
||||||
|
|
||||||
describe('colors', () => {
|
describe('colors', () => {
|
||||||
const SemiDarkBlue = getColorDefinitionByName('semi-dark-blue');
|
const SemiDarkBlue = getColorDefinitionByName('semi-dark-blue');
|
||||||
|
|
||||||
describe('getColorDefinition', () => {
|
describe('getColorDefinition', () => {
|
||||||
it('returns undefined for unknown hex', () => {
|
it('returns undefined for unknown hex', () => {
|
||||||
expect(getColorDefinition('#ff0000', GrafanaTheme.Light)).toBeUndefined();
|
expect(getColorDefinition('#ff0000', GrafanaThemeType.Light)).toBeUndefined();
|
||||||
expect(getColorDefinition('#ff0000', GrafanaTheme.Dark)).toBeUndefined();
|
expect(getColorDefinition('#ff0000', GrafanaThemeType.Dark)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns definition for known hex', () => {
|
it('returns definition for known hex', () => {
|
||||||
expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue);
|
expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaThemeType.Light)).toEqual(SemiDarkBlue);
|
||||||
expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue);
|
expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaThemeType.Dark)).toEqual(SemiDarkBlue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -28,8 +28,8 @@ describe('colors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns name for known hex', () => {
|
it('returns name for known hex', () => {
|
||||||
expect(getColorName(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue.name);
|
expect(getColorName(SemiDarkBlue.variants.light, GrafanaThemeType.Light)).toEqual(SemiDarkBlue.name);
|
||||||
expect(getColorName(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).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", () => {
|
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', () => {
|
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('#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('rgb(0,0,0)')).toBe('rgb(0,0,0)');
|
||||||
expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)');
|
expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)');
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
import { GrafanaTheme } from '../types';
|
import { GrafanaThemeType } from '../types';
|
||||||
|
|
||||||
type Hue = 'green' | 'yellow' | 'red' | 'blue' | 'orange' | 'purple';
|
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];
|
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];
|
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.variants[theme] === hex)[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHex = (color: string) => {
|
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);
|
return hexRegex.test(color);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getColorName = (color?: string, theme?: GrafanaTheme): Color | undefined => {
|
export const getColorName = (color?: string, theme?: GrafanaThemeType): Color | undefined => {
|
||||||
if (!color) {
|
if (!color) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -86,7 +86,7 @@ export const getColorName = (color?: string, theme?: GrafanaTheme): Color | unde
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (isHex(color)) {
|
if (isHex(color)) {
|
||||||
const definition = getColorDefinition(color, theme || GrafanaTheme.Dark);
|
const definition = getColorDefinition(color, theme || GrafanaThemeType.Dark);
|
||||||
return definition ? definition.name : undefined;
|
return definition ? definition.name : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +98,7 @@ export const getColorByName = (colorName: string) => {
|
|||||||
return definition.length > 0 ? definition[0] : undefined;
|
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)) {
|
if (color.indexOf('rgb') > -1 || isHex(color)) {
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
@ -112,14 +112,14 @@ export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): s
|
|||||||
return theme ? colorDefinition.variants[theme] : colorDefinition.variants.dark;
|
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;
|
return theme ? color.variants[theme] : color.variants.dark;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildNamedColorsPalette = () => {
|
const buildNamedColorsPalette = () => {
|
||||||
const palette = new Map<Hue, ColorDefinition[]>();
|
const palette = new Map<Hue, ColorDefinition[]>();
|
||||||
|
|
||||||
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 DarkGreen = buildColorDefinition('green', 'dark-green', ['#19730E', '#37872D']);
|
||||||
const SemiDarkGreen = buildColorDefinition('green', 'semi-dark-green', ['#37872D', '#56A64B']);
|
const SemiDarkGreen = buildColorDefinition('green', 'semi-dark-green', ['#37872D', '#56A64B']);
|
||||||
const LightGreen = buildColorDefinition('green', 'light-green', ['#73BF69', '#96D98D']);
|
const LightGreen = buildColorDefinition('green', 'light-green', ['#73BF69', '#96D98D']);
|
||||||
|
@ -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
|
|
||||||
);
|
|
||||||
};
|
|
41
packages/grafana-ui/src/utils/storybook/withTheme.tsx
Normal file
41
packages/grafana-ui/src/utils/storybook/withTheme.tsx
Normal file
@ -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 (
|
||||||
|
<ThemeContext.Provider value={getTheme(themeKnob)}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<any>, props: any) => {
|
||||||
|
return (
|
||||||
|
<ThemeContext.Consumer>
|
||||||
|
{theme => {
|
||||||
|
return React.createElement(component, {
|
||||||
|
...props,
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
</ThemeContext.Consumer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withTheme = (story: RenderFunction) => <ThemableStory>{story()}</ThemableStory>;
|
@ -210,6 +210,65 @@ func UpdateAnnotation(c *m.ReqContext, cmd dtos.UpdateAnnotationsCmd) Response {
|
|||||||
return Success("Annotation updated")
|
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 = <item.RegionId> AND id != <item.RegionId> ...
|
||||||
|
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 {
|
func DeleteAnnotations(c *m.ReqContext, cmd dtos.DeleteAnnotationsCmd) Response {
|
||||||
repo := annotations.GetRepository()
|
repo := annotations.GetRepository()
|
||||||
|
|
||||||
|
@ -27,6 +27,12 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
|
|||||||
IsRegion: false,
|
IsRegion: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
patchCmd := dtos.PatchAnnotationsCmd{
|
||||||
|
Time: 1000,
|
||||||
|
Text: "annotation text",
|
||||||
|
Tags: []string{"tag1", "tag2"},
|
||||||
|
}
|
||||||
|
|
||||||
Convey("When user is an Org Viewer", func() {
|
Convey("When user is an Org Viewer", func() {
|
||||||
role := m.ROLE_VIEWER
|
role := m.ROLE_VIEWER
|
||||||
Convey("Should not be allowed to save an annotation", func() {
|
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)
|
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) {
|
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||||
sc.handlerFunc = DeleteAnnotationByID
|
sc.handlerFunc = DeleteAnnotationByID
|
||||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||||
@ -67,6 +78,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
|
|||||||
So(sc.resp.Code, ShouldEqual, 200)
|
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) {
|
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||||
sc.handlerFunc = DeleteAnnotationByID
|
sc.handlerFunc = DeleteAnnotationByID
|
||||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||||
@ -100,6 +116,13 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
|
|||||||
Id: 1,
|
Id: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
patchCmd := dtos.PatchAnnotationsCmd{
|
||||||
|
Time: 8000,
|
||||||
|
Text: "annotation text 50",
|
||||||
|
Tags: []string{"foo", "bar"},
|
||||||
|
Id: 1,
|
||||||
|
}
|
||||||
|
|
||||||
deleteCmd := dtos.DeleteAnnotationsCmd{
|
deleteCmd := dtos.DeleteAnnotationsCmd{
|
||||||
DashboardId: 1,
|
DashboardId: 1,
|
||||||
PanelId: 1,
|
PanelId: 1,
|
||||||
@ -136,6 +159,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
|
|||||||
So(sc.resp.Code, ShouldEqual, 403)
|
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) {
|
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||||
sc.handlerFunc = DeleteAnnotationByID
|
sc.handlerFunc = DeleteAnnotationByID
|
||||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
||||||
@ -163,6 +191,11 @@ func TestAnnotationsApiEndpoint(t *testing.T) {
|
|||||||
So(sc.resp.Code, ShouldEqual, 200)
|
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) {
|
loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/annotations/1", "/api/annotations/:annotationId", role, func(sc *scenarioContext) {
|
||||||
sc.handlerFunc = DeleteAnnotationByID
|
sc.handlerFunc = DeleteAnnotationByID
|
||||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
|
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()
|
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
|
||||||
So(sc.resp.Code, ShouldEqual, 200)
|
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) {
|
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()
|
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||||
So(sc.resp.Code, ShouldEqual, 200)
|
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) {
|
func deleteAnnotationsScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.DeleteAnnotationsCmd, fn scenarioFunc) {
|
||||||
Convey(desc+" "+url, func() {
|
Convey(desc+" "+url, func() {
|
||||||
defer bus.ClearBusHandlers()
|
defer bus.ClearBusHandlers()
|
||||||
|
@ -354,6 +354,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation))
|
annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), Wrap(PostAnnotation))
|
||||||
annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID))
|
annotationsRoute.Delete("/:annotationId", Wrap(DeleteAnnotationByID))
|
||||||
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation))
|
annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), Wrap(UpdateAnnotation))
|
||||||
|
annotationsRoute.Patch("/:annotationId", bind(dtos.PatchAnnotationsCmd{}), Wrap(PatchAnnotation))
|
||||||
annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion))
|
annotationsRoute.Delete("/region/:regionId", Wrap(DeleteAnnotationRegion))
|
||||||
annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation))
|
annotationsRoute.Post("/graphite", reqEditorRole, bind(dtos.PostGraphiteAnnotationsCmd{}), Wrap(PostGraphiteAnnotation))
|
||||||
})
|
})
|
||||||
|
@ -94,14 +94,13 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
|
|||||||
}
|
}
|
||||||
|
|
||||||
type scenarioContext struct {
|
type scenarioContext struct {
|
||||||
m *macaron.Macaron
|
m *macaron.Macaron
|
||||||
context *m.ReqContext
|
context *m.ReqContext
|
||||||
resp *httptest.ResponseRecorder
|
resp *httptest.ResponseRecorder
|
||||||
handlerFunc handlerFunc
|
handlerFunc handlerFunc
|
||||||
defaultHandler macaron.Handler
|
defaultHandler macaron.Handler
|
||||||
req *http.Request
|
req *http.Request
|
||||||
url string
|
url string
|
||||||
userAuthTokenService *fakeUserAuthTokenService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sc *scenarioContext) exec() {
|
func (sc *scenarioContext) exec() {
|
||||||
@ -123,30 +122,7 @@ func setupScenarioContext(url string) *scenarioContext {
|
|||||||
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
Delims: macaron.Delims{Left: "[[", Right: "]]"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
sc.userAuthTokenService = newFakeUserAuthTokenService()
|
sc.m.Use(middleware.GetContextHandler(nil))
|
||||||
sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
|
|
||||||
|
|
||||||
return sc
|
return sc
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeUserAuthTokenService struct {
|
|
||||||
initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
|
|
||||||
return &fakeUserAuthTokenService{
|
|
||||||
initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
|
|
||||||
return s.initContextWithTokenProvider(ctx, orgID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }
|
|
||||||
|
@ -22,6 +22,14 @@ type UpdateAnnotationsCmd struct {
|
|||||||
TimeEnd int64 `json:"timeEnd"`
|
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 {
|
type DeleteAnnotationsCmd struct {
|
||||||
AlertId int64 `json:"alertId"`
|
AlertId int64 `json:"alertId"`
|
||||||
DashboardId int64 `json:"dashboardId"`
|
DashboardId int64 `json:"dashboardId"`
|
||||||
|
@ -5,6 +5,7 @@ type PlaylistDashboard struct {
|
|||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
|
Url string `json:"url"`
|
||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,7 +21,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"github.com/grafana/grafana/pkg/registry"
|
||||||
"github.com/grafana/grafana/pkg/services/auth"
|
|
||||||
"github.com/grafana/grafana/pkg/services/cache"
|
"github.com/grafana/grafana/pkg/services/cache"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/hooks"
|
"github.com/grafana/grafana/pkg/services/hooks"
|
||||||
@ -48,14 +47,14 @@ type HTTPServer struct {
|
|||||||
streamManager *live.StreamManager
|
streamManager *live.StreamManager
|
||||||
httpSrv *http.Server
|
httpSrv *http.Server
|
||||||
|
|
||||||
RouteRegister routing.RouteRegister `inject:""`
|
RouteRegister routing.RouteRegister `inject:""`
|
||||||
Bus bus.Bus `inject:""`
|
Bus bus.Bus `inject:""`
|
||||||
RenderService rendering.Service `inject:""`
|
RenderService rendering.Service `inject:""`
|
||||||
Cfg *setting.Cfg `inject:""`
|
Cfg *setting.Cfg `inject:""`
|
||||||
HooksService *hooks.HooksService `inject:""`
|
HooksService *hooks.HooksService `inject:""`
|
||||||
CacheService *cache.CacheService `inject:""`
|
CacheService *cache.CacheService `inject:""`
|
||||||
DatasourceCache datasources.CacheService `inject:""`
|
DatasourceCache datasources.CacheService `inject:""`
|
||||||
AuthTokenService auth.UserAuthTokenService `inject:""`
|
AuthTokenService models.UserTokenService `inject:""`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) Init() error {
|
func (hs *HTTPServer) Init() error {
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
"github.com/grafana/grafana/pkg/login"
|
"github.com/grafana/grafana/pkg/login"
|
||||||
"github.com/grafana/grafana/pkg/metrics"
|
"github.com/grafana/grafana/pkg/metrics"
|
||||||
|
"github.com/grafana/grafana/pkg/middleware"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"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) {
|
func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
|
||||||
if user == nil {
|
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 {
|
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) {
|
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 != "" {
|
if setting.SignoutRedirectUrl != "" {
|
||||||
c.Redirect(setting.SignoutRedirectUrl)
|
c.Redirect(setting.SignoutRedirectUrl)
|
||||||
@ -176,7 +183,8 @@ func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string
|
|||||||
Value: hex.EncodeToString(encryptedError),
|
Value: hex.EncodeToString(encryptedError),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Path: setting.AppSubUrl + "/",
|
Path: setting.AppSubUrl + "/",
|
||||||
Secure: hs.Cfg.SecurityHTTPSCookies,
|
Secure: hs.Cfg.CookieSecure,
|
||||||
|
SameSite: hs.Cfg.CookieSameSite,
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -214,7 +214,8 @@ func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value stri
|
|||||||
Value: value,
|
Value: value,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Path: setting.AppSubUrl + "/",
|
Path: setting.AppSubUrl + "/",
|
||||||
Secure: hs.Cfg.SecurityHTTPSCookies,
|
Secure: hs.Cfg.CookieSecure,
|
||||||
|
SameSite: hs.Cfg.CookieSameSite,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ func populateDashboardsByID(dashboardByIDs []int64, dashboardIDOrder map[int64]i
|
|||||||
Slug: item.Slug,
|
Slug: item.Slug,
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
Uri: "db/" + item.Slug,
|
Uri: "db/" + item.Slug,
|
||||||
|
Url: m.GetDashboardUrl(item.Uid, item.Slug),
|
||||||
Order: dashboardIDOrder[item.Id],
|
Order: dashboardIDOrder[item.Id],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,7 @@ import (
|
|||||||
_ "github.com/grafana/grafana/pkg/metrics"
|
_ "github.com/grafana/grafana/pkg/metrics"
|
||||||
_ "github.com/grafana/grafana/pkg/plugins"
|
_ "github.com/grafana/grafana/pkg/plugins"
|
||||||
_ "github.com/grafana/grafana/pkg/services/alerting"
|
_ "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/cleanup"
|
||||||
_ "github.com/grafana/grafana/pkg/services/notifications"
|
_ "github.com/grafana/grafana/pkg/services/notifications"
|
||||||
_ "github.com/grafana/grafana/pkg/services/provisioning"
|
_ "github.com/grafana/grafana/pkg/services/provisioning"
|
||||||
|
@ -273,23 +273,35 @@ func (a *ldapAuther) initialBind(username, userPassword string) error {
|
|||||||
return nil
|
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) {
|
func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
|
||||||
var searchResult *ldap.SearchResult
|
var searchResult *ldap.SearchResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
for _, searchBase := range a.server.SearchBaseDNs {
|
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{
|
searchReq := ldap.SearchRequest{
|
||||||
BaseDN: searchBase,
|
BaseDN: searchBase,
|
||||||
Scope: ldap.ScopeWholeSubtree,
|
Scope: ldap.ScopeWholeSubtree,
|
||||||
DerefAliases: ldap.NeverDerefAliases,
|
DerefAliases: ldap.NeverDerefAliases,
|
||||||
Attributes: []string{
|
Attributes: attributes,
|
||||||
a.server.Attr.Username,
|
Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))
|
a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
|
"github.com/grafana/grafana/pkg/log"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
"gopkg.in/ldap.v3"
|
"gopkg.in/ldap.v3"
|
||||||
@ -322,11 +323,51 @@ func TestLdapAuther(t *testing.T) {
|
|||||||
So(sc.addOrgUserCmd.Role, ShouldEqual, "Admin")
|
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 {
|
type mockLdapConn struct {
|
||||||
result *ldap.SearchResult
|
result *ldap.SearchResult
|
||||||
searchCalled bool
|
searchCalled bool
|
||||||
|
searchAttributes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *mockLdapConn) Bind(username, password string) error {
|
func (c *mockLdapConn) Bind(username, password string) error {
|
||||||
@ -339,8 +380,9 @@ func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) {
|
|||||||
c.result = result
|
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.searchCalled = true
|
||||||
|
c.searchAttributes = sr.Attributes
|
||||||
return c.result, nil
|
return c.result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/components/apikeygen"
|
"github.com/grafana/grafana/pkg/components/apikeygen"
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/auth"
|
|
||||||
"github.com/grafana/grafana/pkg/services/session"
|
"github.com/grafana/grafana/pkg/services/session"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@ -21,7 +23,7 @@ var (
|
|||||||
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
|
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
|
func GetContextHandler(ats m.UserTokenService) macaron.Handler {
|
||||||
return func(c *macaron.Context) {
|
return func(c *macaron.Context) {
|
||||||
ctx := &m.ReqContext{
|
ctx := &m.ReqContext{
|
||||||
Context: c,
|
Context: c,
|
||||||
@ -49,7 +51,7 @@ func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
|
|||||||
case initContextWithApiKey(ctx):
|
case initContextWithApiKey(ctx):
|
||||||
case initContextWithBasicAuth(ctx, orgId):
|
case initContextWithBasicAuth(ctx, orgId):
|
||||||
case initContextWithAuthProxy(ctx, orgId):
|
case initContextWithAuthProxy(ctx, orgId):
|
||||||
case ats.InitContextWithToken(ctx, orgId):
|
case initContextWithToken(ats, ctx, orgId):
|
||||||
case initContextWithAnonymousUser(ctx):
|
case initContextWithAnonymousUser(ctx):
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,6 +168,69 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
|
|||||||
return true
|
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 {
|
func AddDefaultResponseHeaders() macaron.Handler {
|
||||||
return func(ctx *m.ReqContext) {
|
return func(ctx *m.ReqContext) {
|
||||||
if ctx.IsApiRequest() && ctx.Req.Method == "GET" {
|
if ctx.IsApiRequest() && ctx.Req.Method == "GET" {
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
msession "github.com/go-macaron/session"
|
msession "github.com/go-macaron/session"
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
@ -146,17 +147,95 @@ func TestMiddlewareContext(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
middlewareScenario("Auth token service", func(sc *scenarioContext) {
|
middlewareScenario("Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) {
|
||||||
var wasCalled bool
|
sc.withTokenSessionCookie("token")
|
||||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
|
||||||
wasCalled = true
|
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
||||||
return false
|
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()
|
sc.fakeReq("GET", "/").exec()
|
||||||
|
|
||||||
Convey("should call middleware", func() {
|
Convey("should init context with user info", func() {
|
||||||
So(wasCalled, ShouldBeTrue)
|
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() {
|
Convey(desc, func() {
|
||||||
defer bus.ClearBusHandlers()
|
defer bus.ClearBusHandlers()
|
||||||
|
|
||||||
|
setting.LoginCookieName = "grafana_session"
|
||||||
|
setting.LoginMaxLifetimeDays = 30
|
||||||
|
|
||||||
sc := &scenarioContext{}
|
sc := &scenarioContext{}
|
||||||
|
|
||||||
viewsPath, _ := filepath.Abs("../../public/views")
|
viewsPath, _ := filepath.Abs("../../public/views")
|
||||||
@ -508,6 +590,7 @@ type scenarioContext struct {
|
|||||||
resp *httptest.ResponseRecorder
|
resp *httptest.ResponseRecorder
|
||||||
apiKey string
|
apiKey string
|
||||||
authHeader string
|
authHeader string
|
||||||
|
tokenSessionCookie string
|
||||||
respJson map[string]interface{}
|
respJson map[string]interface{}
|
||||||
handlerFunc handlerFunc
|
handlerFunc handlerFunc
|
||||||
defaultHandler macaron.Handler
|
defaultHandler macaron.Handler
|
||||||
@ -522,6 +605,11 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext {
|
|||||||
return sc
|
return sc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sc *scenarioContext) withTokenSessionCookie(unhashedToken string) *scenarioContext {
|
||||||
|
sc.tokenSessionCookie = unhashedToken
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
|
||||||
func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
|
func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
|
||||||
sc.authHeader = authHeader
|
sc.authHeader = authHeader
|
||||||
return sc
|
return sc
|
||||||
@ -571,6 +659,13 @@ func (sc *scenarioContext) exec() {
|
|||||||
sc.req.Header.Add("Authorization", sc.authHeader)
|
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)
|
sc.m.ServeHTTP(sc.resp, sc.req)
|
||||||
|
|
||||||
if sc.resp.Header().Get("Content-Type") == "application/json; charset=UTF-8" {
|
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 handlerFunc func(c *m.ReqContext)
|
||||||
|
|
||||||
type fakeUserAuthTokenService struct {
|
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 {
|
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
|
||||||
return &fakeUserAuthTokenService{
|
return &fakeUserAuthTokenService{
|
||||||
initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
|
createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
|
||||||
return false
|
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 {
|
func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
|
||||||
return s.initContextWithTokenProvider(ctx, orgID)
|
return s.createTokenProvider(userId, clientIP, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
|
func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) {
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
@ -14,14 +14,21 @@ func TestOrgRedirectMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
Convey("Can redirect to correct org", t, func() {
|
Convey("Can redirect to correct org", t, func() {
|
||||||
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
|
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
|
||||||
|
sc.withTokenSessionCookie("token")
|
||||||
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
||||||
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
|
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||||
ctx.IsSignedIn = true
|
return nil
|
||||||
return true
|
})
|
||||||
|
|
||||||
|
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
|
||||||
|
return &m.UserToken{
|
||||||
|
UserId: 0,
|
||||||
|
UnhashedToken: "",
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
sc.m.Get("/", sc.defaultHandler)
|
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) {
|
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
|
||||||
|
sc.withTokenSessionCookie("token")
|
||||||
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
|
||||||
return fmt.Errorf("")
|
return fmt.Errorf("")
|
||||||
})
|
})
|
||||||
|
|
||||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
|
||||||
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
|
|
||||||
ctx.IsSignedIn = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
||||||
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
|
||||||
|
return &m.UserToken{
|
||||||
|
UserId: 12,
|
||||||
|
UnhashedToken: "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
sc.m.Get("/", sc.defaultHandler)
|
sc.m.Get("/", sc.defaultHandler)
|
||||||
sc.fakeReq("GET", "/?orgId=3").exec()
|
sc.fakeReq("GET", "/?orgId=3").exec()
|
||||||
|
|
||||||
|
@ -74,10 +74,17 @@ func TestMiddlewareQuota(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
middlewareScenario("with user logged in", func(sc *scenarioContext) {
|
middlewareScenario("with user logged in", func(sc *scenarioContext) {
|
||||||
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
|
sc.withTokenSessionCookie("token")
|
||||||
ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
|
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
|
||||||
ctx.IsSignedIn = true
|
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
|
||||||
return true
|
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 {
|
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
type ReqContext struct {
|
type ReqContext struct {
|
||||||
*macaron.Context
|
*macaron.Context
|
||||||
*SignedInUser
|
*SignedInUser
|
||||||
|
UserToken *UserToken
|
||||||
|
|
||||||
// This should only be used by the auth_proxy
|
// This should only be used by the auth_proxy
|
||||||
Session session.SessionStore
|
Session session.SessionStore
|
||||||
|
@ -46,19 +46,16 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
|
|||||||
return t.Transport, nil
|
return t.Transport, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool
|
tlsConfig, err := ds.GetTLSConfig()
|
||||||
if ds.JsonData != nil {
|
if err != nil {
|
||||||
tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
|
return nil, err
|
||||||
tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
|
|
||||||
tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tlsConfig.Renegotiation = tls.RenegotiateFreelyAsClient
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{
|
TLSClientConfig: tlsConfig,
|
||||||
InsecureSkipVerify: tlsSkipVerify,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
Renegotiation: tls.RenegotiateFreelyAsClient,
|
|
||||||
},
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
Dial: (&net.Dialer{
|
Dial: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
@ -70,6 +67,26 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
|
|||||||
IdleConnTimeout: 90 * time.Second,
|
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 {
|
if tlsClientAuth || tlsAuthWithCACert {
|
||||||
decrypted := ds.SecureJsonData.Decrypt()
|
decrypted := ds.SecureJsonData.Decrypt()
|
||||||
if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 {
|
if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 {
|
||||||
@ -78,7 +95,7 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("Failed to parse TLS CA PEM certificate")
|
return nil, errors.New("Failed to parse TLS CA PEM certificate")
|
||||||
}
|
}
|
||||||
transport.TLSClientConfig.RootCAs = caPool
|
tlsConfig.RootCAs = caPool
|
||||||
}
|
}
|
||||||
|
|
||||||
if tlsClientAuth {
|
if tlsClientAuth {
|
||||||
@ -86,14 +103,9 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
|
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ptc.cache[ds.Id] = cachedTransport{
|
return tlsConfig, nil
|
||||||
Transport: transport,
|
|
||||||
updated: ds.Updated,
|
|
||||||
}
|
|
||||||
|
|
||||||
return transport, nil
|
|
||||||
}
|
}
|
||||||
|
32
pkg/models/user_token.go
Normal file
32
pkg/models/user_token.go
Normal file
@ -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
|
||||||
|
}
|
@ -3,13 +3,10 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
|
||||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"github.com/grafana/grafana/pkg/registry"
|
||||||
@ -19,116 +16,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
registry.RegisterService(&UserAuthTokenServiceImpl{})
|
registry.RegisterService(&UserAuthTokenService{})
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var getTime = time.Now
|
||||||
getTime = time.Now
|
|
||||||
UrgentRotateTime = 1 * time.Minute
|
|
||||||
oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
|
|
||||||
)
|
|
||||||
|
|
||||||
// UserAuthTokenService are used for generating and validating user auth tokens
|
const urgentRotateTime = 1 * time.Minute
|
||||||
type UserAuthTokenService interface {
|
|
||||||
InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
|
|
||||||
UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
|
|
||||||
SignOutUser(c *models.ReqContext) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserAuthTokenServiceImpl struct {
|
type UserAuthTokenService struct {
|
||||||
SQLStore *sqlstore.SqlStore `inject:""`
|
SQLStore *sqlstore.SqlStore `inject:""`
|
||||||
ServerLockService *serverlock.ServerLockService `inject:""`
|
ServerLockService *serverlock.ServerLockService `inject:""`
|
||||||
Cfg *setting.Cfg `inject:""`
|
Cfg *setting.Cfg `inject:""`
|
||||||
log log.Logger
|
log log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init this service
|
func (s *UserAuthTokenService) Init() error {
|
||||||
func (s *UserAuthTokenServiceImpl) Init() error {
|
|
||||||
s.log = log.New("auth")
|
s.log = log.New("auth")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool {
|
func (s *UserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
|
||||||
//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) {
|
|
||||||
clientIP = util.ParseIPAddress(clientIP)
|
clientIP = util.ParseIPAddress(clientIP)
|
||||||
token, err := util.RandomHex(16)
|
token, err := util.RandomHex(16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -139,7 +46,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
|
|||||||
|
|
||||||
now := getTime().Unix()
|
now := getTime().Unix()
|
||||||
|
|
||||||
userToken := userAuthToken{
|
userAuthToken := userAuthToken{
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
AuthToken: hashedToken,
|
AuthToken: hashedToken,
|
||||||
PrevAuthToken: hashedToken,
|
PrevAuthToken: hashedToken,
|
||||||
@ -151,98 +58,114 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent
|
|||||||
SeenAt: 0,
|
SeenAt: 0,
|
||||||
AuthTokenSeen: false,
|
AuthTokenSeen: false,
|
||||||
}
|
}
|
||||||
_, err = s.SQLStore.NewSession().Insert(&userToken)
|
_, err = s.SQLStore.NewSession().Insert(&userAuthToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
hashedToken := hashToken(unhashedToken)
|
||||||
if setting.Env == setting.DEV {
|
if setting.Env == setting.DEV {
|
||||||
s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
|
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
|
var model userAuthToken
|
||||||
exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, ErrAuthTokenNotFound
|
return nil, models.ErrUserTokenNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen {
|
if model.AuthToken != hashedToken && model.PrevAuthToken == hashedToken && model.AuthTokenSeen {
|
||||||
userTokenCopy := userToken
|
modelCopy := model
|
||||||
userTokenCopy.AuthTokenSeen = false
|
modelCopy.AuthTokenSeen = false
|
||||||
expireBefore := getTime().Add(-UrgentRotateTime).Unix()
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if affectedRows == 0 {
|
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 {
|
} 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 {
|
if !model.AuthTokenSeen && model.AuthToken == hashedToken {
|
||||||
userTokenCopy := userToken
|
modelCopy := model
|
||||||
userTokenCopy.AuthTokenSeen = true
|
modelCopy.AuthTokenSeen = true
|
||||||
userTokenCopy.SeenAt = getTime().Unix()
|
modelCopy.SeenAt = getTime().Unix()
|
||||||
affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy)
|
affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", modelCopy.Id, modelCopy.AuthToken).AllCols().Update(&modelCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if affectedRows == 1 {
|
if affectedRows == 1 {
|
||||||
userToken = userTokenCopy
|
model = modelCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
if affectedRows == 0 {
|
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 {
|
} 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 {
|
if token == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model := userAuthTokenFromUserToken(token)
|
||||||
|
|
||||||
now := getTime()
|
now := getTime()
|
||||||
|
|
||||||
needsRotation := false
|
needsRotation := false
|
||||||
rotatedAt := time.Unix(token.RotatedAt, 0)
|
rotatedAt := time.Unix(model.RotatedAt, 0)
|
||||||
if token.AuthTokenSeen {
|
if model.AuthTokenSeen {
|
||||||
needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute))
|
needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.TokenRotationIntervalMinutes) * time.Minute))
|
||||||
} else {
|
} else {
|
||||||
needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime))
|
needsRotation = rotatedAt.Before(now.Add(-urgentRotateTime))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !needsRotation {
|
if !needsRotation {
|
||||||
return false, nil
|
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)
|
clientIP = util.ParseIPAddress(clientIP)
|
||||||
newToken, _ := util.RandomHex(16)
|
newToken, err := util.RandomHex(16)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
hashedToken := hashToken(newToken)
|
hashedToken := hashToken(newToken)
|
||||||
|
|
||||||
// very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
|
// 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 = ?
|
rotated_at = ?
|
||||||
WHERE id = ? AND (auth_token_seen = ? OR 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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
affected, _ := res.RowsAffected()
|
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 {
|
if affected > 0 {
|
||||||
token.UnhashedToken = newToken
|
model.UnhashedToken = newToken
|
||||||
|
model.toUserToken(token)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, 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 {
|
func hashToken(token string) string {
|
||||||
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
|
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
|
||||||
return hex.EncodeToString(hashBytes[:])
|
return hex.EncodeToString(hashBytes[:])
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"encoding/json"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
macaron "gopkg.in/macaron.v1"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
)
|
)
|
||||||
@ -28,236 +26,265 @@ func TestUserAuthToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Convey("When creating token", func() {
|
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(err, ShouldBeNil)
|
||||||
So(token, ShouldNotBeNil)
|
So(userToken, ShouldNotBeNil)
|
||||||
So(token.AuthTokenSeen, ShouldBeFalse)
|
So(userToken.AuthTokenSeen, ShouldBeFalse)
|
||||||
|
|
||||||
Convey("When lookup unhashed token should return user auth token", func() {
|
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(err, ShouldBeNil)
|
||||||
So(LookupToken, ShouldNotBeNil)
|
So(userToken, ShouldNotBeNil)
|
||||||
So(LookupToken.UserId, ShouldEqual, userID)
|
So(userToken.UserId, ShouldEqual, userID)
|
||||||
So(LookupToken.AuthTokenSeen, ShouldBeTrue)
|
So(userToken.AuthTokenSeen, ShouldBeTrue)
|
||||||
|
|
||||||
storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id)
|
storedAuthToken, err := ctx.getAuthTokenByID(userToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(storedAuthToken, ShouldNotBeNil)
|
So(storedAuthToken, ShouldNotBeNil)
|
||||||
So(storedAuthToken.AuthTokenSeen, ShouldBeTrue)
|
So(storedAuthToken.AuthTokenSeen, ShouldBeTrue)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("When lookup hashed token should return user auth token not found error", func() {
|
Convey("When lookup hashed token should return user auth token not found error", func() {
|
||||||
LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken)
|
userToken, err := userAuthTokenService.LookupToken(userToken.AuthToken)
|
||||||
So(err, ShouldEqual, ErrAuthTokenNotFound)
|
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
||||||
So(LookupToken, ShouldBeNil)
|
So(userToken, ShouldBeNil)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("signing out should delete token and cookie if present", func() {
|
Convey("revoking existing token should delete token", func() {
|
||||||
httpreq := &http.Request{Header: make(http.Header)}
|
err = userAuthTokenService.RevokeToken(userToken)
|
||||||
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)
|
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
// makes sure we tell the browser to overwrite the cookie
|
model, err := ctx.getAuthTokenByID(userToken.Id)
|
||||||
cookieHeader := fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly", userAuthTokenService.Cfg.LoginCookieName)
|
So(err, ShouldBeNil)
|
||||||
So(ctx.Resp.Header().Get("Set-Cookie"), ShouldEqual, cookieHeader)
|
So(model, ShouldBeNil)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("signing out an none existing session should return an error", func() {
|
Convey("revoking nil token should return error", func() {
|
||||||
httpreq := &http.Request{Header: make(http.Header)}
|
err = userAuthTokenService.RevokeToken(nil)
|
||||||
httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: ""})
|
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
ctx := &models.ReqContext{Context: &macaron.Context{
|
Convey("revoking non-existing token should return error", func() {
|
||||||
Req: macaron.Request{Request: httpreq},
|
userToken.Id = 1000
|
||||||
Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
|
err = userAuthTokenService.RevokeToken(userToken)
|
||||||
},
|
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
||||||
Logger: log.New("fakelogger"),
|
|
||||||
}
|
|
||||||
|
|
||||||
err = userAuthTokenService.SignOutUser(ctx)
|
|
||||||
So(err, ShouldNotBeNil)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("expires correctly", func() {
|
Convey("expires correctly", func() {
|
||||||
token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
|
userToken, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(token, ShouldNotBeNil)
|
|
||||||
|
|
||||||
_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
|
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
token, err = ctx.getAuthTokenByID(token.Id)
|
userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
getTime = func() time.Time {
|
getTime = func() time.Time {
|
||||||
return t.Add(time.Hour)
|
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(err, ShouldBeNil)
|
||||||
So(refreshed, ShouldBeTrue)
|
So(rotated, ShouldBeTrue)
|
||||||
|
|
||||||
_, err = userAuthTokenService.LookupToken(token.UnhashedToken)
|
userToken, err = userAuthTokenService.LookupToken(userToken.UnhashedToken)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
|
stillGood, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(stillGood, ShouldNotBeNil)
|
So(stillGood, ShouldNotBeNil)
|
||||||
|
|
||||||
getTime = func() time.Time {
|
model, err := ctx.getAuthTokenByID(userToken.Id)
|
||||||
return t.Add(24 * 7 * time.Hour)
|
So(err, ShouldBeNil)
|
||||||
}
|
|
||||||
notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
|
Convey("when rotated_at is 6:59:59 ago should find token", func() {
|
||||||
So(err, ShouldEqual, ErrAuthTokenNotFound)
|
getTime = func() time.Time {
|
||||||
So(notGood, ShouldBeNil)
|
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() {
|
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(err, ShouldBeNil)
|
||||||
So(token, ShouldNotBeNil)
|
|
||||||
|
|
||||||
prevToken := token.AuthToken
|
prevToken := userToken.AuthToken
|
||||||
unhashedPrev := token.UnhashedToken
|
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(err, ShouldBeNil)
|
||||||
So(refreshed, ShouldBeFalse)
|
So(rotated, ShouldBeFalse)
|
||||||
|
|
||||||
updated, err := ctx.markAuthTokenAsSeen(token.Id)
|
updated, err := ctx.markAuthTokenAsSeen(userToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(updated, ShouldBeTrue)
|
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)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
getTime = func() time.Time {
|
getTime = func() time.Time {
|
||||||
return t.Add(time.Hour)
|
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(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)
|
So(err, ShouldBeNil)
|
||||||
token.UnhashedToken = unhashedToken
|
model.UnhashedToken = unhashedToken
|
||||||
|
|
||||||
So(token.RotatedAt, ShouldEqual, getTime().Unix())
|
So(model.RotatedAt, ShouldEqual, getTime().Unix())
|
||||||
So(token.ClientIp, ShouldEqual, "192.168.10.12")
|
So(model.ClientIp, ShouldEqual, "192.168.10.12")
|
||||||
So(token.UserAgent, ShouldEqual, "a new user agent")
|
So(model.UserAgent, ShouldEqual, "a new user agent")
|
||||||
So(token.AuthTokenSeen, ShouldBeFalse)
|
So(model.AuthTokenSeen, ShouldBeFalse)
|
||||||
So(token.SeenAt, ShouldEqual, 0)
|
So(model.SeenAt, ShouldEqual, 0)
|
||||||
So(token.PrevAuthToken, ShouldEqual, prevToken)
|
So(model.PrevAuthToken, ShouldEqual, prevToken)
|
||||||
|
|
||||||
// ability to auth using an old token
|
// ability to auth using an old token
|
||||||
|
|
||||||
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
|
lookedUpUserToken, err := userAuthTokenService.LookupToken(model.UnhashedToken)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(lookedUp, ShouldNotBeNil)
|
So(lookedUpUserToken, ShouldNotBeNil)
|
||||||
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
|
So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
|
||||||
So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
|
So(lookedUpUserToken.SeenAt, ShouldEqual, getTime().Unix())
|
||||||
|
|
||||||
lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
|
lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(lookedUp, ShouldNotBeNil)
|
So(lookedUpUserToken, ShouldNotBeNil)
|
||||||
So(lookedUp.Id, ShouldEqual, token.Id)
|
So(lookedUpUserToken.Id, ShouldEqual, model.Id)
|
||||||
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
|
So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
|
||||||
|
|
||||||
getTime = func() time.Time {
|
getTime = func() time.Time {
|
||||||
return t.Add(time.Hour + (2 * time.Minute))
|
return t.Add(time.Hour + (2 * time.Minute))
|
||||||
}
|
}
|
||||||
|
|
||||||
lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
|
lookedUpUserToken, err = userAuthTokenService.LookupToken(unhashedPrev)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(lookedUp, ShouldNotBeNil)
|
So(lookedUpUserToken, ShouldNotBeNil)
|
||||||
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
|
So(lookedUpUserToken.AuthTokenSeen, ShouldBeTrue)
|
||||||
|
|
||||||
lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
|
lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(lookedUp, ShouldNotBeNil)
|
So(lookedUpModel, ShouldNotBeNil)
|
||||||
So(lookedUp.AuthTokenSeen, ShouldBeFalse)
|
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(err, ShouldBeNil)
|
||||||
So(refreshed, ShouldBeTrue)
|
So(rotated, ShouldBeTrue)
|
||||||
|
|
||||||
token, err = ctx.getAuthTokenByID(token.Id)
|
model, err = ctx.getAuthTokenByID(userToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(token, ShouldNotBeNil)
|
So(model, ShouldNotBeNil)
|
||||||
So(token.SeenAt, ShouldEqual, 0)
|
So(model.SeenAt, ShouldEqual, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("keeps prev token valid for 1 minute after it is confirmed", func() {
|
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(err, ShouldBeNil)
|
||||||
So(token, ShouldNotBeNil)
|
So(userToken, ShouldNotBeNil)
|
||||||
|
|
||||||
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
|
lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(lookedUp, ShouldNotBeNil)
|
So(lookedUpUserToken, ShouldNotBeNil)
|
||||||
|
|
||||||
getTime = func() time.Time {
|
getTime = func() time.Time {
|
||||||
return t.Add(10 * time.Minute)
|
return t.Add(10 * time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
prevToken := token.UnhashedToken
|
prevToken := userToken.UnhashedToken
|
||||||
refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
|
rotated, err := userAuthTokenService.TryRotateToken(userToken, "1.1.1.1", "firefox")
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(refreshed, ShouldBeTrue)
|
So(rotated, ShouldBeTrue)
|
||||||
|
|
||||||
getTime = func() time.Time {
|
getTime = func() time.Time {
|
||||||
return t.Add(20 * time.Minute)
|
return t.Add(20 * time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
|
currentUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(current, ShouldNotBeNil)
|
So(currentUserToken, ShouldNotBeNil)
|
||||||
|
|
||||||
prev, err := userAuthTokenService.LookupToken(prevToken)
|
prevUserToken, err := userAuthTokenService.LookupToken(prevToken)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(prev, ShouldNotBeNil)
|
So(prevUserToken, ShouldNotBeNil)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("will not mark token unseen when prev and current are the same", func() {
|
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(err, ShouldBeNil)
|
||||||
So(token, ShouldNotBeNil)
|
So(userToken, ShouldNotBeNil)
|
||||||
|
|
||||||
lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
|
lookedUpUserToken, err := userAuthTokenService.LookupToken(userToken.UnhashedToken)
|
||||||
So(err, ShouldBeNil)
|
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(err, ShouldBeNil)
|
||||||
So(lookedUp, ShouldNotBeNil)
|
So(lookedUpUserToken, ShouldNotBeNil)
|
||||||
|
|
||||||
lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
|
lookedUpModel, err := ctx.getAuthTokenByID(lookedUpUserToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(lookedUp, ShouldNotBeNil)
|
So(lookedUpModel, ShouldNotBeNil)
|
||||||
So(lookedUp.AuthTokenSeen, ShouldBeTrue)
|
So(lookedUpModel.AuthTokenSeen, ShouldBeTrue)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("Rotate token", func() {
|
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(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() {
|
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(err, ShouldBeNil)
|
||||||
So(updated, ShouldBeTrue)
|
So(updated, ShouldBeTrue)
|
||||||
|
|
||||||
@ -265,11 +292,11 @@ func TestUserAuthToken(t *testing.T) {
|
|||||||
return t.Add(10 * time.Minute)
|
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(err, ShouldBeNil)
|
||||||
So(refreshed, ShouldBeTrue)
|
So(rotated, ShouldBeTrue)
|
||||||
|
|
||||||
storedToken, err := ctx.getAuthTokenByID(token.Id)
|
storedToken, err := ctx.getAuthTokenByID(userToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(storedToken, ShouldNotBeNil)
|
So(storedToken, ShouldNotBeNil)
|
||||||
So(storedToken.AuthTokenSeen, ShouldBeFalse)
|
So(storedToken.AuthTokenSeen, ShouldBeFalse)
|
||||||
@ -278,7 +305,7 @@ func TestUserAuthToken(t *testing.T) {
|
|||||||
|
|
||||||
prevToken = storedToken.AuthToken
|
prevToken = storedToken.AuthToken
|
||||||
|
|
||||||
updated, err = ctx.markAuthTokenAsSeen(token.Id)
|
updated, err = ctx.markAuthTokenAsSeen(userToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(updated, ShouldBeTrue)
|
So(updated, ShouldBeTrue)
|
||||||
|
|
||||||
@ -286,11 +313,11 @@ func TestUserAuthToken(t *testing.T) {
|
|||||||
return t.Add(20 * time.Minute)
|
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(err, ShouldBeNil)
|
||||||
So(refreshed, ShouldBeTrue)
|
So(rotated, ShouldBeTrue)
|
||||||
|
|
||||||
storedToken, err = ctx.getAuthTokenByID(token.Id)
|
storedToken, err = ctx.getAuthTokenByID(userToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(storedToken, ShouldNotBeNil)
|
So(storedToken, ShouldNotBeNil)
|
||||||
So(storedToken.AuthTokenSeen, ShouldBeFalse)
|
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() {
|
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 {
|
getTime = func() time.Time {
|
||||||
return t.Add(2 * time.Minute)
|
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(err, ShouldBeNil)
|
||||||
So(refreshed, ShouldBeTrue)
|
So(rotated, ShouldBeTrue)
|
||||||
|
|
||||||
storedToken, err := ctx.getAuthTokenByID(token.Id)
|
storedToken, err := ctx.getAuthTokenByID(userToken.Id)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(storedToken, ShouldNotBeNil)
|
So(storedToken, ShouldNotBeNil)
|
||||||
So(storedToken.AuthTokenSeen, ShouldBeFalse)
|
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() {
|
Reset(func() {
|
||||||
getTime = time.Now
|
getTime = time.Now
|
||||||
})
|
})
|
||||||
@ -328,19 +420,16 @@ func createTestContext(t *testing.T) *testContext {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
sqlstore := sqlstore.InitTestDB(t)
|
sqlstore := sqlstore.InitTestDB(t)
|
||||||
tokenService := &UserAuthTokenServiceImpl{
|
tokenService := &UserAuthTokenService{
|
||||||
SQLStore: sqlstore,
|
SQLStore: sqlstore,
|
||||||
Cfg: &setting.Cfg{
|
Cfg: &setting.Cfg{
|
||||||
LoginCookieName: "grafana_session",
|
LoginMaxInactiveLifetimeDays: 7,
|
||||||
LoginCookieMaxDays: 7,
|
LoginMaxLifetimeDays: 30,
|
||||||
LoginDeleteExpiredTokensAfterDays: 30,
|
TokenRotationIntervalMinutes: 10,
|
||||||
LoginCookieRotation: 10,
|
|
||||||
},
|
},
|
||||||
log: log.New("test-logger"),
|
log: log.New("test-logger"),
|
||||||
}
|
}
|
||||||
|
|
||||||
UrgentRotateTime = time.Minute
|
|
||||||
|
|
||||||
return &testContext{
|
return &testContext{
|
||||||
sqlstore: sqlstore,
|
sqlstore: sqlstore,
|
||||||
tokenService: tokenService,
|
tokenService: tokenService,
|
||||||
@ -349,7 +438,7 @@ func createTestContext(t *testing.T) *testContext {
|
|||||||
|
|
||||||
type testContext struct {
|
type testContext struct {
|
||||||
sqlstore *sqlstore.SqlStore
|
sqlstore *sqlstore.SqlStore
|
||||||
tokenService *UserAuthTokenServiceImpl
|
tokenService *UserAuthTokenService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
|
func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
|
||||||
@ -376,3 +465,17 @@ func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
|
|||||||
}
|
}
|
||||||
return rowsAffected == 1, nil
|
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
|
||||||
|
}
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
)
|
|
||||||
|
|
||||||
// Typed errors
|
"github.com/grafana/grafana/pkg/models"
|
||||||
var (
|
|
||||||
ErrAuthTokenNotFound = errors.New("User auth token not found")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type userAuthToken struct {
|
type userAuthToken struct {
|
||||||
@ -23,3 +20,51 @@ type userAuthToken struct {
|
|||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
UnhashedToken string `xorm:"-"`
|
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
|
||||||
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUserAuthTokenCleanup(t *testing.T) {
|
|
||||||
|
|
||||||
Convey("Test user auth token cleanup", t, func() {
|
|
||||||
ctx := createTestContext(t)
|
|
||||||
|
|
||||||
insertToken := func(token string, prev string, rotatedAt int64) {
|
|
||||||
ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
|
|
||||||
_, err := ctx.sqlstore.NewSession().Insert(&ut)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert three old tokens that should be deleted
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert three active tokens that should not be deleted
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix())
|
|
||||||
}
|
|
||||||
|
|
||||||
affected, err := ctx.tokenService.deleteOldSession(time.Hour)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(affected, ShouldEqual, 3)
|
|
||||||
})
|
|
||||||
}
|
|
57
pkg/services/auth/token_cleanup.go
Normal file
57
pkg/services/auth/token_cleanup.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (srv *UserAuthTokenService) Run(ctx context.Context) error {
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
maxInactiveLifetime := time.Duration(srv.Cfg.LoginMaxInactiveLifetimeDays) * 24 * time.Hour
|
||||||
|
maxLifetime := time.Duration(srv.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
|
||||||
|
|
||||||
|
err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
|
||||||
|
srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
err := srv.ServerLockService.LockAndExecute(ctx, "cleanup expired auth tokens", time.Hour*12, func() {
|
||||||
|
srv.deleteExpiredTokens(maxInactiveLifetime, maxLifetime)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
srv.log.Error("failed to lock and execite cleanup of expired auth token", "erro", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *UserAuthTokenService) deleteExpiredTokens(maxInactiveLifetime, maxLifetime time.Duration) (int64, error) {
|
||||||
|
createdBefore := getTime().Add(-maxLifetime)
|
||||||
|
rotatedBefore := getTime().Add(-maxInactiveLifetime)
|
||||||
|
|
||||||
|
srv.log.Debug("starting cleanup of expired auth tokens", "createdBefore", createdBefore, "rotatedBefore", rotatedBefore)
|
||||||
|
|
||||||
|
sql := `DELETE from user_auth_token WHERE created_at <= ? OR rotated_at <= ?`
|
||||||
|
res, err := srv.SQLStore.NewSession().Exec(sql, createdBefore.Unix(), rotatedBefore.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
srv.log.Error("failed to cleanup expired auth tokens", "error", err)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.log.Info("cleanup of expired auth tokens done", "count", affected)
|
||||||
|
return affected, err
|
||||||
|
}
|
68
pkg/services/auth/token_cleanup_test.go
Normal file
68
pkg/services/auth/token_cleanup_test.go
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
@ -89,6 +89,8 @@ var (
|
|||||||
EmailCodeValidMinutes int
|
EmailCodeValidMinutes int
|
||||||
DataProxyWhiteList map[string]bool
|
DataProxyWhiteList map[string]bool
|
||||||
DisableBruteForceLoginProtection bool
|
DisableBruteForceLoginProtection bool
|
||||||
|
CookieSecure bool
|
||||||
|
CookieSameSite http.SameSite
|
||||||
|
|
||||||
// Snapshots
|
// Snapshots
|
||||||
ExternalSnapshotUrl string
|
ExternalSnapshotUrl string
|
||||||
@ -118,8 +120,10 @@ var (
|
|||||||
ViewersCanEdit bool
|
ViewersCanEdit bool
|
||||||
|
|
||||||
// Http auth
|
// Http auth
|
||||||
AdminUser string
|
AdminUser string
|
||||||
AdminPassword string
|
AdminPassword string
|
||||||
|
LoginCookieName string
|
||||||
|
LoginMaxLifetimeDays int
|
||||||
|
|
||||||
AnonymousEnabled bool
|
AnonymousEnabled bool
|
||||||
AnonymousOrgName string
|
AnonymousOrgName string
|
||||||
@ -215,7 +219,11 @@ type Cfg struct {
|
|||||||
RendererLimit int
|
RendererLimit int
|
||||||
RendererLimitAlerting int
|
RendererLimitAlerting int
|
||||||
|
|
||||||
|
// Security
|
||||||
DisableBruteForceLoginProtection bool
|
DisableBruteForceLoginProtection bool
|
||||||
|
CookieSecure bool
|
||||||
|
CookieSameSite http.SameSite
|
||||||
|
|
||||||
TempDataLifetime time.Duration
|
TempDataLifetime time.Duration
|
||||||
MetricsEndpointEnabled bool
|
MetricsEndpointEnabled bool
|
||||||
MetricsEndpointBasicAuthUsername string
|
MetricsEndpointBasicAuthUsername string
|
||||||
@ -224,13 +232,11 @@ type Cfg struct {
|
|||||||
DisableSanitizeHtml bool
|
DisableSanitizeHtml bool
|
||||||
EnterpriseLicensePath string
|
EnterpriseLicensePath string
|
||||||
|
|
||||||
LoginCookieName string
|
// Auth
|
||||||
LoginCookieMaxDays int
|
LoginCookieName string
|
||||||
LoginCookieRotation int
|
LoginMaxInactiveLifetimeDays int
|
||||||
LoginDeleteExpiredTokensAfterDays int
|
LoginMaxLifetimeDays int
|
||||||
LoginCookieSameSite http.SameSite
|
TokenRotationIntervalMinutes int
|
||||||
|
|
||||||
SecurityHTTPSCookies bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandLineArgs struct {
|
type CommandLineArgs struct {
|
||||||
@ -554,30 +560,6 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
|||||||
ApplicationName = APP_NAME_ENTERPRISE
|
ApplicationName = APP_NAME_ENTERPRISE
|
||||||
}
|
}
|
||||||
|
|
||||||
//login
|
|
||||||
login := iniFile.Section("login")
|
|
||||||
cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
|
|
||||||
cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
|
|
||||||
cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
|
|
||||||
|
|
||||||
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")
|
Env = iniFile.Section("").Key("app_mode").MustString("development")
|
||||||
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
|
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
|
||||||
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
|
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
|
||||||
@ -621,9 +603,26 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
|||||||
SecretKey = security.Key("secret_key").String()
|
SecretKey = security.Key("secret_key").String()
|
||||||
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
|
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
|
||||||
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
|
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
|
||||||
cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
|
|
||||||
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
|
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
|
||||||
|
|
||||||
|
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
|
// read snapshots settings
|
||||||
snapshots := iniFile.Section("snapshots")
|
snapshots := iniFile.Section("snapshots")
|
||||||
ExternalSnapshotUrl = snapshots.Key("external_snapshot_url").String()
|
ExternalSnapshotUrl = snapshots.Key("external_snapshot_url").String()
|
||||||
@ -661,6 +660,19 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
|
|||||||
|
|
||||||
// auth
|
// auth
|
||||||
auth := iniFile.Section("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)
|
DisableLoginForm = auth.Key("disable_login_form").MustBool(false)
|
||||||
DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false)
|
DisableSignoutMenu = auth.Key("disable_signout_menu").MustBool(false)
|
||||||
OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false)
|
OAuthAutoLogin = auth.Key("oauth_auto_login").MustBool(false)
|
||||||
|
@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go/aws/request"
|
"github.com/aws/aws-sdk-go/aws/request"
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
"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/null"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
"github.com/grafana/grafana/pkg/metrics"
|
"github.com/grafana/grafana/pkg/metrics"
|
||||||
@ -28,7 +29,8 @@ import (
|
|||||||
|
|
||||||
type CloudWatchExecutor struct {
|
type CloudWatchExecutor struct {
|
||||||
*models.DataSource
|
*models.DataSource
|
||||||
ec2Svc ec2iface.EC2API
|
ec2Svc ec2iface.EC2API
|
||||||
|
rgtaSvc resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatasourceInfo struct {
|
type DatasourceInfo struct {
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go/aws/session"
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
"github.com/aws/aws-sdk-go/service/ec2"
|
"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/components/simplejson"
|
||||||
"github.com/grafana/grafana/pkg/metrics"
|
"github.com/grafana/grafana/pkg/metrics"
|
||||||
"github.com/grafana/grafana/pkg/tsdb"
|
"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/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/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": {"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/EC2Spot": {"AvailableInstancePoolsCount", "BidsSubmittedForCapacity", "EligibleInstancePoolCount", "FulfilledCapacity", "MaxPercentCapacityAllocation", "PendingCapacity", "PercentCapacityAllocation", "TargetCapacity", "TerminatingCapacity"},
|
||||||
"AWS/ECS": {"CPUReservation", "MemoryReservation", "CPUUtilization", "MemoryUtilization"},
|
"AWS/ECS": {"CPUReservation", "MemoryReservation", "CPUUtilization", "MemoryUtilization"},
|
||||||
"AWS/EFS": {"BurstCreditBalance", "ClientConnections", "DataReadIOBytes", "DataWriteIOBytes", "MetadataIOBytes", "TotalIOBytes", "PermittedThroughput", "PercentIOLimit"},
|
"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/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/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/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/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/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"},
|
"AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"},
|
||||||
@ -132,6 +134,7 @@ func init() {
|
|||||||
"AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"},
|
"AWS/DynamoDB": {"TableName", "GlobalSecondaryIndexName", "Operation", "StreamLabel"},
|
||||||
"AWS/EBS": {"VolumeId"},
|
"AWS/EBS": {"VolumeId"},
|
||||||
"AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"},
|
"AWS/EC2": {"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"},
|
||||||
|
"AWS/EC2/API": {},
|
||||||
"AWS/EC2Spot": {"AvailabilityZone", "FleetRequestId", "InstanceType"},
|
"AWS/EC2Spot": {"AvailabilityZone", "FleetRequestId", "InstanceType"},
|
||||||
"AWS/ECS": {"ClusterName", "ServiceName"},
|
"AWS/ECS": {"ClusterName", "ServiceName"},
|
||||||
"AWS/EFS": {"FileSystemId"},
|
"AWS/EFS": {"FileSystemId"},
|
||||||
@ -200,6 +203,8 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo
|
|||||||
data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext)
|
data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext)
|
||||||
case "ec2_instance_attribute":
|
case "ec2_instance_attribute":
|
||||||
data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext)
|
data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext)
|
||||||
|
case "resource_arns":
|
||||||
|
data, err = e.handleGetResourceArns(ctx, parameters, queryContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
transformToTable(data, queryResult)
|
transformToTable(data, queryResult)
|
||||||
@ -536,6 +541,65 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context,
|
|||||||
return result, nil
|
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) {
|
func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace string, metricName string, dimensions []*cloudwatch.DimensionFilter) (*cloudwatch.ListMetricsOutput, error) {
|
||||||
svc, err := e.getClient(region)
|
svc, err := e.getClient(region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -587,6 +651,28 @@ func (e *CloudWatchExecutor) ec2DescribeInstances(region string, filters []*ec2.
|
|||||||
return &resp, nil
|
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) {
|
func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) {
|
||||||
creds, err := GetCredentials(cwData)
|
creds, err := GetCredentials(cwData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -8,6 +8,8 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
"github.com/aws/aws-sdk-go/service/cloudwatch"
|
||||||
"github.com/aws/aws-sdk-go/service/ec2"
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
"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/bmizerany/assert"
|
||||||
"github.com/grafana/grafana/pkg/components/securejsondata"
|
"github.com/grafana/grafana/pkg/components/securejsondata"
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
@ -22,6 +24,11 @@ type mockedEc2 struct {
|
|||||||
RespRegions ec2.DescribeRegionsOutput
|
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 {
|
func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error {
|
||||||
fn(&m.Resp, true)
|
fn(&m.Resp, true)
|
||||||
return nil
|
return nil
|
||||||
@ -30,6 +37,11 @@ func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeR
|
|||||||
return &m.RespRegions, nil
|
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) {
|
func TestCloudWatchMetrics(t *testing.T) {
|
||||||
|
|
||||||
Convey("When calling getMetricsForCustomMetrics", t, func() {
|
Convey("When calling getMetricsForCustomMetrics", t, func() {
|
||||||
@ -209,6 +221,51 @@ func TestCloudWatchMetrics(t *testing.T) {
|
|||||||
So(result[7].Text, ShouldEqual, "vol-4-2")
|
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) {
|
func TestParseMultiSelectValue(t *testing.T) {
|
||||||
|
@ -32,6 +32,18 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
|
|||||||
datasource.Url,
|
datasource.Url,
|
||||||
datasource.Database,
|
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)
|
logger.Debug("getEngine", "connection", cnnstr)
|
||||||
|
|
||||||
config := tsdb.SqlQueryEndpointConfiguration{
|
config := tsdb.SqlQueryEndpointConfiguration{
|
||||||
|
@ -9,7 +9,7 @@ import { TagFilter } from './components/TagFilter/TagFilter';
|
|||||||
import { SideMenu } from './components/sidemenu/SideMenu';
|
import { SideMenu } from './components/sidemenu/SideMenu';
|
||||||
import { MetricSelect } from './components/Select/MetricSelect';
|
import { MetricSelect } from './components/Select/MetricSelect';
|
||||||
import AppNotificationList from './components/AppNotifications/AppNotificationList';
|
import AppNotificationList from './components/AppNotifications/AppNotificationList';
|
||||||
import { ColorPicker, SeriesColorPickerPopover } from '@grafana/ui';
|
import { ColorPicker, SeriesColorPickerPopoverWithTheme } from '@grafana/ui';
|
||||||
|
|
||||||
export function registerAngularDirectives() {
|
export function registerAngularDirectives() {
|
||||||
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
|
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
|
||||||
@ -27,7 +27,7 @@ export function registerAngularDirectives() {
|
|||||||
'color',
|
'color',
|
||||||
['onChange', { watchDepth: 'reference', wrapApply: true }],
|
['onChange', { watchDepth: 'reference', wrapApply: true }],
|
||||||
]);
|
]);
|
||||||
react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
|
react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopoverWithTheme, [
|
||||||
'color',
|
'color',
|
||||||
'series',
|
'series',
|
||||||
'onColorChange',
|
'onColorChange',
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Emitter } from './utils/emitter';
|
import { Emitter } from './utils/emitter';
|
||||||
|
|
||||||
const appEvents = new Emitter();
|
export const appEvents = new Emitter();
|
||||||
|
|
||||||
export default appEvents;
|
export default appEvents;
|
||||||
|
42
public/app/core/components/AlertBox/AlertBox.tsx
Normal file
42
public/app/core/components/AlertBox/AlertBox.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React, { FunctionComponent } from 'react';
|
||||||
|
import { AppNotificationSeverity } from 'app/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
text?: string;
|
||||||
|
severity: AppNotificationSeverity;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconFromSeverity(severity: AppNotificationSeverity): string {
|
||||||
|
switch (severity) {
|
||||||
|
case AppNotificationSeverity.Error: {
|
||||||
|
return 'fa fa-exclamation-triangle';
|
||||||
|
}
|
||||||
|
case AppNotificationSeverity.Success: {
|
||||||
|
return 'fa fa-check';
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertBox: FunctionComponent<Props> = ({ title, icon, text, severity, onClose }) => {
|
||||||
|
return (
|
||||||
|
<div className={`alert alert-${severity}`}>
|
||||||
|
<div className="alert-icon">
|
||||||
|
<i className={icon || getIconFromSeverity(severity)} />
|
||||||
|
</div>
|
||||||
|
<div className="alert-body">
|
||||||
|
<div className="alert-title">{title}</div>
|
||||||
|
{text && <div className="alert-text">{text}</div>}
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" className="alert-close" onClick={onClose}>
|
||||||
|
<i className="fa fa fa-remove" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,5 +1,6 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { AppNotification } from 'app/types';
|
import { AppNotification } from 'app/types';
|
||||||
|
import { AlertBox } from '../AlertBox/AlertBox';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
appNotification: AppNotification;
|
appNotification: AppNotification;
|
||||||
@ -22,18 +23,13 @@ export default class AppNotificationItem extends Component<Props> {
|
|||||||
const { appNotification, onClearNotification } = this.props;
|
const { appNotification, onClearNotification } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`alert-${appNotification.severity} alert`}>
|
<AlertBox
|
||||||
<div className="alert-icon">
|
severity={appNotification.severity}
|
||||||
<i className={appNotification.icon} />
|
title={appNotification.title}
|
||||||
</div>
|
text={appNotification.text}
|
||||||
<div className="alert-body">
|
icon={appNotification.icon}
|
||||||
<div className="alert-title">{appNotification.title}</div>
|
onClose={() => onClearNotification(appNotification.id)}
|
||||||
<div className="alert-text">{appNotification.text}</div>
|
/>
|
||||||
</div>
|
|
||||||
<button type="button" className="alert-close" onClick={() => onClearNotification(appNotification.id)}>
|
|
||||||
<i className="fa fa fa-remove" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Page extends Component<Props> {
|
class Page extends Component<Props> {
|
||||||
private bodyClass = 'is-react';
|
|
||||||
private body = document.body;
|
|
||||||
static Header = PageHeader;
|
static Header = PageHeader;
|
||||||
static Contents = PageContents;
|
static Contents = PageContents;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.body.classList.add(this.bodyClass);
|
|
||||||
this.updateTitle();
|
this.updateTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,10 +30,6 @@ class Page extends Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.body.classList.remove(this.bodyClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTitle = () => {
|
updateTitle = () => {
|
||||||
const title = this.getPageTitle;
|
const title = this.getPageTitle;
|
||||||
document.title = title ? title + ' - Grafana' : 'Grafana';
|
document.title = title ? title + ' - Grafana' : 'Grafana';
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import coreModule from 'app/core/core_module';
|
|
||||||
|
|
||||||
const template = `
|
|
||||||
<div class="scroll-canvas">
|
|
||||||
<navbar model="model"></navbar>
|
|
||||||
<div class="page-container">
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>
|
|
||||||
<i class="{{::model.node.icon}}" ng-if="::model.node.icon"></i>
|
|
||||||
<img ng-src="{{::model.node.img}}" ng-if="::model.node.img"></i>
|
|
||||||
{{::model.node.text}}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="page-header__actions" ng-transclude="header"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-body" ng-transclude="body">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
export function gfPageDirective() {
|
|
||||||
return {
|
|
||||||
restrict: 'E',
|
|
||||||
template: template,
|
|
||||||
scope: {
|
|
||||||
model: '=',
|
|
||||||
},
|
|
||||||
transclude: {
|
|
||||||
header: '?gfPageHeader',
|
|
||||||
body: 'gfPageBody',
|
|
||||||
},
|
|
||||||
link: (scope, elem, attrs) => {
|
|
||||||
console.log(scope);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
coreModule.directive('gfPage', gfPageDirective);
|
|
@ -1,43 +0,0 @@
|
|||||||
import coreModule from 'app/core/core_module';
|
|
||||||
import appEvents from 'app/core/app_events';
|
|
||||||
|
|
||||||
export function pageScrollbar() {
|
|
||||||
return {
|
|
||||||
restrict: 'A',
|
|
||||||
link: (scope, elem, attrs) => {
|
|
||||||
let lastPos = 0;
|
|
||||||
|
|
||||||
appEvents.on(
|
|
||||||
'dash-scroll',
|
|
||||||
evt => {
|
|
||||||
if (evt.restore) {
|
|
||||||
elem[0].scrollTop = lastPos;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastPos = elem[0].scrollTop;
|
|
||||||
|
|
||||||
if (evt.animate) {
|
|
||||||
elem.animate({ scrollTop: evt.pos }, 500);
|
|
||||||
} else {
|
|
||||||
elem[0].scrollTop = evt.pos;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scope
|
|
||||||
);
|
|
||||||
|
|
||||||
scope.$on('$routeChangeSuccess', () => {
|
|
||||||
lastPos = 0;
|
|
||||||
elem[0].scrollTop = 0;
|
|
||||||
// Focus page to enable scrolling by keyboard
|
|
||||||
elem[0].focus({ preventScroll: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
elem[0].tabIndex = -1;
|
|
||||||
// Focus page to enable scrolling by keyboard
|
|
||||||
elem[0].focus({ preventScroll: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
coreModule.directive('pageScrollbar', pageScrollbar);
|
|
@ -1,5 +1,6 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { PanelPlugin } from 'app/types/plugins';
|
import { PanelPlugin } from 'app/types/plugins';
|
||||||
|
import { GrafanaTheme, getTheme, GrafanaThemeType } from '@grafana/ui';
|
||||||
|
|
||||||
export interface BuildInfo {
|
export interface BuildInfo {
|
||||||
version: string;
|
version: string;
|
||||||
@ -36,8 +37,11 @@ export class Settings {
|
|||||||
loginError: any;
|
loginError: any;
|
||||||
viewersCanEdit: boolean;
|
viewersCanEdit: boolean;
|
||||||
disableSanitizeHtml: boolean;
|
disableSanitizeHtml: boolean;
|
||||||
|
theme: GrafanaTheme;
|
||||||
|
|
||||||
constructor(options: Settings) {
|
constructor(options: Settings) {
|
||||||
|
this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
datasources: {},
|
datasources: {},
|
||||||
windowTitlePrefix: 'Grafana - ',
|
windowTitlePrefix: 'Grafana - ',
|
||||||
@ -68,5 +72,5 @@ const bootData = (window as any).grafanaBootData || {
|
|||||||
const options = bootData.settings;
|
const options = bootData.settings;
|
||||||
options.bootData = bootData;
|
options.bootData = bootData;
|
||||||
|
|
||||||
const config = new Settings(options);
|
export const config = new Settings(options);
|
||||||
export default config;
|
export default config;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
|
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
|
||||||
|
import { getMessageFromError } from 'app/core/utils/errors';
|
||||||
|
|
||||||
const defaultSuccessNotification: AppNotification = {
|
const defaultSuccessNotification: AppNotification = {
|
||||||
title: '',
|
title: '',
|
||||||
@ -31,12 +32,14 @@ export const createSuccessNotification = (title: string, text?: string): AppNoti
|
|||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createErrorNotification = (title: string, text?: string): AppNotification => ({
|
export const createErrorNotification = (title: string, text?: any): AppNotification => {
|
||||||
...defaultErrorNotification,
|
return {
|
||||||
title: title,
|
...defaultErrorNotification,
|
||||||
text: text,
|
title: title,
|
||||||
id: Date.now(),
|
text: getMessageFromError(text),
|
||||||
});
|
id: Date.now(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
|
export const createWarningNotification = (title: string, text?: string): AppNotification => ({
|
||||||
...defaultWarningNotification,
|
...defaultWarningNotification,
|
||||||
|
@ -43,8 +43,6 @@ import { helpModal } from './components/help/help';
|
|||||||
import { JsonExplorer } from './components/json_explorer/json_explorer';
|
import { JsonExplorer } from './components/json_explorer/json_explorer';
|
||||||
import { NavModelSrv, NavModel } from './nav_model_srv';
|
import { NavModelSrv, NavModel } from './nav_model_srv';
|
||||||
import { geminiScrollbar } from './components/scroll/scroll';
|
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 { orgSwitcher } from './components/org_switcher';
|
||||||
import { profiler } from './profiler';
|
import { profiler } from './profiler';
|
||||||
import { registerAngularDirectives } from './angular_wrappers';
|
import { registerAngularDirectives } from './angular_wrappers';
|
||||||
@ -79,8 +77,6 @@ export {
|
|||||||
NavModelSrv,
|
NavModelSrv,
|
||||||
NavModel,
|
NavModel,
|
||||||
geminiScrollbar,
|
geminiScrollbar,
|
||||||
pageScrollbar,
|
|
||||||
gfPageDirective,
|
|
||||||
orgSwitcher,
|
orgSwitcher,
|
||||||
manageDashboardsDirective,
|
manageDashboardsDirective,
|
||||||
TimeSeries,
|
TimeSeries,
|
||||||
|
@ -8,12 +8,13 @@ export const initialState: LocationState = {
|
|||||||
path: '',
|
path: '',
|
||||||
query: {},
|
query: {},
|
||||||
routeParams: {},
|
routeParams: {},
|
||||||
|
replace: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const locationReducer = (state = initialState, action: Action): LocationState => {
|
export const locationReducer = (state = initialState, action: Action): LocationState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case CoreActionTypes.UpdateLocation: {
|
case CoreActionTypes.UpdateLocation: {
|
||||||
const { path, routeParams } = action.payload;
|
const { path, routeParams, replace } = action.payload;
|
||||||
let query = action.payload.query || state.query;
|
let query = action.payload.query || state.query;
|
||||||
|
|
||||||
if (action.payload.partial) {
|
if (action.payload.partial) {
|
||||||
@ -26,6 +27,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
|
|||||||
path: path || state.path,
|
path: path || state.path,
|
||||||
query: { ...query },
|
query: { ...query },
|
||||||
routeParams: routeParams || state.routeParams,
|
routeParams: routeParams || state.routeParams,
|
||||||
|
replace: replace === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user