Merge branch 'master' into ui-new-red-green-blue

This commit is contained in:
Patrick O'Carroll 2019-02-11 09:17:19 +01:00 committed by GitHub
commit c34021344a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
316 changed files with 38098 additions and 4907 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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
View File

@ -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"

View File

@ -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

View File

@ -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

View 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

View 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"

View 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
```

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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 "$@"

View File

@ -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.

View 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,

View File

@ -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"
}
``` ```

View File

@ -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 />

View File

@ -50,6 +50,7 @@ Filter Option | Example | Raw | Interpolated | Description
`regex` | ${servers:regex} | `'test.', 'test2'` | <code>(test\.&#124;test2)</code> | Formats multi-value variable into a regex string `regex` | ${servers:regex} | `'test.', 'test2'` | <code>(test\.&#124;test2)</code> | Formats multi-value variable into a regex string
`pipe` | ${servers:pipe} | `'test.', 'test2'` | <code>test.&#124;test2</code> | Formats multi-value variable into a pipe-separated string `pipe` | ${servers:pipe} | `'test.', 'test2'` | <code>test.&#124;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.

View File

@ -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",

View File

@ -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"
} }

View File

@ -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);
} }

View File

@ -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;
}; };

View File

@ -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",

View File

@ -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>

View File

@ -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'));

View File

@ -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}
/>
);
}); });

View File

@ -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);

View File

@ -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
); );
} }
} }

View File

@ -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}
/> />

View File

@ -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>
); );

View File

@ -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);
}); });

View File

@ -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);

View File

@ -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>
); );

View File

@ -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>
); );
}; };

View File

@ -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',

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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>
); );
} }
} }

View File

@ -34,7 +34,7 @@
cursor: pointer; cursor: pointer;
&:hover { &:hover {
color: $text-color-strong; color: $white;
} }
} }

View File

@ -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';

View File

@ -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';

View 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;
};

View 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;

View 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;

View 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;
};
};

View 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;

View 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);
});
});

View 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];
};

View File

@ -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;
}

View 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;
}

View File

@ -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)');
}); });

View File

@ -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']);

View File

@ -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
);
};

View 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>;

View File

@ -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()

View File

@ -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()

View File

@ -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))
}) })

View File

@ -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 }

View File

@ -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"`

View File

@ -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"`
} }

View File

@ -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 {

View File

@ -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

View File

@ -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,
}) })
} }

View File

@ -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],
}) })
} }

View File

@ -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"

View File

@ -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))

View File

@ -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
} }

View File

@ -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" {

View File

@ -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)
}

View File

@ -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()

View File

@ -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 {

View File

@ -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

View File

@ -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
View 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
}

View File

@ -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[:])

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}

View 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
}

View 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)
})
})
}

View File

@ -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)

View File

@ -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 {

View File

@ -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 {

View File

@ -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) {

View File

@ -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{

View File

@ -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',

View File

@ -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;

View 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>
);
};

View File

@ -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>
); );
} }
} }

View File

@ -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';

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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