Merge branch 'master' into 15053/gauge-value-font-size

This commit is contained in:
Peter Holmberg 2019-01-29 11:20:16 +01:00
commit b0984cd503
287 changed files with 12552 additions and 6039 deletions

View File

@ -19,7 +19,7 @@ version: 2
jobs: jobs:
mysql-integration-test: mysql-integration-test:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
- image: circleci/mysql:5.6-ram - image: circleci/mysql:5.6-ram
environment: environment:
MYSQL_ROOT_PASSWORD: rootpass MYSQL_ROOT_PASSWORD: rootpass
@ -39,7 +39,7 @@ jobs:
postgres-integration-test: postgres-integration-test:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
- image: circleci/postgres:9.3-ram - image: circleci/postgres:9.3-ram
environment: environment:
POSTGRES_USER: grafanatest POSTGRES_USER: grafanatest
@ -74,7 +74,7 @@ jobs:
gometalinter: gometalinter:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
environment: environment:
# we need CGO because of go-sqlite3 # we need CGO because of go-sqlite3
CGO_ENABLED: 1 CGO_ENABLED: 1
@ -106,7 +106,7 @@ jobs:
test-backend: test-backend:
docker: docker:
- image: circleci/golang:1.11.4 - image: circleci/golang:1.11.5
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -116,7 +116,7 @@ jobs:
build-all: build-all:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -147,9 +147,6 @@ jobs:
- run: - run:
name: sha-sum packages name: sha-sum packages
command: 'go run build.go sha-dist' command: 'go run build.go sha-dist'
- run:
name: Build Grafana.com master publisher
command: 'go build -o scripts/publish scripts/build/publish.go'
- run: - run:
name: Test and build Grafana.com release publisher name: Test and build Grafana.com release publisher
command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .' command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .'
@ -158,13 +155,12 @@ jobs:
paths: paths:
- dist/grafana* - dist/grafana*
- scripts/*.sh - scripts/*.sh
- scripts/publish
- scripts/build/release_publisher/release_publisher - scripts/build/release_publisher/release_publisher
- scripts/build/publish.sh - scripts/build/publish.sh
build: build:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -233,7 +229,7 @@ jobs:
build-enterprise: build-enterprise:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -265,7 +261,7 @@ jobs:
build-all-enterprise: build-all-enterprise:
docker: docker:
- image: grafana/build-container:1.2.2 - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana working_directory: /go/src/github.com/grafana/grafana
steps: steps:
- checkout - checkout
@ -393,7 +389,8 @@ jobs:
name: Publish to Grafana.com name: Publish to Grafana.com
command: | command: |
rm dist/grafana-master-$(echo "${CIRCLE_SHA1}" | cut -b1-7).linux-x64.tar.gz rm dist/grafana-master-$(echo "${CIRCLE_SHA1}" | cut -b1-7).linux-x64.tar.gz
./scripts/publish -apiKey ${GRAFANA_COM_API_KEY} rm dist/*latest*
cd dist && ../scripts/build/release_publisher/release_publisher -apikey ${GRAFANA_COM_API_KEY} -from-local
deploy-release: deploy-release:
docker: docker:

View File

@ -3,34 +3,46 @@
### New Features ### New Features
* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster) * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
* **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968) * **Elasticsearch**: Support bucket script pipeline aggregations [#5968](https://github.com/grafana/grafana/issues/5968)
* **Influxdb**: Add support for time zone (`tz`) clause [#10322](https://github.com/grafana/grafana/issues/10322), thx [@cykl](https://github.com/cykl)
* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109) * **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
* **Provisioning**: Provisioning support for alert notifiers [#10487](https://github.com/grafana/grafana/issues/10487), thx [@pbakulev](https://github.com/pbakulev)
### Minor ### Minor
* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi) * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175) * **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
* **Postgres/MySQL/MSSQL**: Nanosecond timestamp support (`$__unixEpochNanoFilter`, `$__unixEpochNanoFrom`, `$__unixEpochNanoTo`) [#14711](https://github.com/grafana/grafana/pull/14711), thx [@ander26](https://github.com/ander26)
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire) * **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483) * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548) * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas) * **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik) * **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok) * **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864) * **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484)
* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Dataproxy**: Add global datasource proxy timeout setting [#5699](https://github.com/grafana/grafana/issues/5699), thx [@RangerRick](https://github.com/RangerRick)
* **Database**: Support specifying database host using IPV6 for backend database and sql datasources [#13711](https://github.com/grafana/grafana/issues/13711), thx [@ellisvlad](https://github.com/ellisvlad)
### Bug fixes ### Bug fixes
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486) * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795) * **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy)
### Breaking changes ### Breaking changes
* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. * **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
* [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags * **Dashboard**: Panel property `minSpan` replaced by `maxPerRow`. Dashboard migration will automatically migrate all dashboard panels using the `minSpan` property to the new `maxPerRow` property [#12991](https://github.com/grafana/grafana/pull/12991)
* they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings
* `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable
* `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
# 5.4.3 (2019-01-14) # 5.4.3 (2019-01-14)

View File

@ -2,7 +2,7 @@
## Our Pledge ## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards ## Our Standards

View File

@ -1,5 +1,5 @@
# Golang build container # Golang build container
FROM golang:1.11.4 FROM golang:1.11.5
WORKDIR $GOPATH/src/github.com/grafana/grafana WORKDIR $GOPATH/src/github.com/grafana/grafana
@ -19,11 +19,13 @@ COPY package.json package.json
RUN go run build.go build RUN go run build.go build
# Node build container # Node build container
FROM node:8 FROM node:10.14.2
WORKDIR /usr/src/app/ WORKDIR /usr/src/app/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
COPY packages packages
RUN yarn install --pure-lockfile --no-progress RUN yarn install --pure-lockfile --no-progress
COPY Gruntfile.js tsconfig.json tslint.json ./ COPY Gruntfile.js tsconfig.json tslint.json ./

View File

@ -1,7 +1,7 @@
# Plugin Development # Plugin Development
This document is not meant as complete guide for developing plugins but more as a changelog for changes in This document is not meant as a complete guide for developing plugins but more as a changelog for changes in
Grafana that can impact plugin development. When ever you as plugin author encounter an issue with your plugin after Grafana that can impact plugin development. Whenever you as a plugin author encounter an issue with your plugin after
upgrading Grafana please check here before creating an issue. upgrading Grafana please check here before creating an issue.
## Links ## Links

View File

@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra
Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides. Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
## Run from master ## Run from master
If you want to build a package yourself, or contribute - Here is a guide for how to do that. You can always find If you want to build a package yourself, or contribute - here is a guide for how to do that. You can always find
the latest master builds [here](https://grafana.com/grafana/download) the latest master builds [here](https://grafana.com/grafana/download)
### Dependencies ### Dependencies
@ -71,7 +71,7 @@ Open grafana in your browser (default: `http://localhost:3000`) and login with a
### Building a Docker image ### Building a Docker image
There are two different ways to build a Grafana docker image. If you're machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker. There are two different ways to build a Grafana docker image. If your machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker.
Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev` Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev`
@ -90,7 +90,7 @@ Choose this option to build on platforms other than linux/amd64 and/or not have
The resulting image will be tagged as `grafana/grafana:dev` The resulting image will be tagged as `grafana/grafana:dev`
Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Preferences -> Advanced), otherwize you may faild at `grunt build` Notice: If you are using Docker for MacOS, be sure to set the memory limit to be larger than 2 GiB (at docker -> Preferences -> Advanced), otherwise `grunt build` may fail.
### Dev config ### Dev config
@ -129,8 +129,8 @@ GRAFANA_TEST_DB=postgres go test ./pkg/...
## Contribute ## Contribute
If you have any idea for an improvement or found a bug, do not hesitate to open an issue. If you have any ideas for improvement or have found a bug, do not hesitate to open an issue.
And if you have time clone this repo and submit a pull request and help me make Grafana And if you have time, clone this repo and submit a pull request to help me make Grafana
the kickass metrics & devops dashboard we all dream about! the kickass metrics & devops dashboard we all dream about!
Read the [contributing](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guide then check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are easy and that we would like help with. Read the [contributing](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guide then check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are easy and that we would like help with.

View File

@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment: environment:
nodejs_version: "8" nodejs_version: "8"
GOPATH: C:\gopath GOPATH: C:\gopath
GOVERSION: 1.11.4 GOVERSION: 1.11.5
install: install:
- rmdir c:\go /s /q - rmdir c:\go /s /q

View File

@ -106,6 +106,22 @@ 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
# 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"
@ -143,6 +159,9 @@ conn_max_lifetime = 14400
# This enables data proxy logging, default is false # This enables data proxy logging, default is false
logging = false logging = false
# How long the data proxy should wait before timing out default is 30 (seconds)
timeout = 30
#################################### Analytics ########################### #################################### Analytics ###########################
[analytics] [analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours. # Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@ -175,11 +194,6 @@ admin_password = admin
# used for signing # used for signing
secret_key = SW2YcwTIb9zpOOhoPsMm secret_key = SW2YcwTIb9zpOOhoPsMm
# Auto-login remember days
login_remember_days = 7
cookie_username = grafana_user
cookie_remember_name = grafana_remember
# disable gravatar profile images # disable gravatar profile images
disable_gravatar = false disable_gravatar = false
@ -189,6 +203,9 @@ 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
https_flag_cookies = false
#################################### Snapshots ########################### #################################### Snapshots ###########################
[snapshots] [snapshots]
# snapshot sharing options # snapshot sharing options
@ -490,7 +507,7 @@ concurrent_render_limit = 5
#################################### Explore ############################# #################################### Explore #############################
[explore] [explore]
# Enable the Explore section # Enable the Explore section
enabled = false enabled = true
#################################### Internal Grafana Metrics ############ #################################### Internal Grafana Metrics ############
# Metrics available at HTTP API Url /metrics # Metrics available at HTTP API Url /metrics

View File

@ -0,0 +1,25 @@
# # config file version
apiVersion: 1
# notifiers:
# - name: default-slack-temp
# type: slack
# org_name: Main Org.
# is_default: true
# uid: notifier1
# settings:
# recipient: "XXX"
# token: "xoxb"
# uploadImage: true
# url: https://slack.com
# - name: default-email
# type: email
# org_id: 1
# uid: notifier2
# is_default: false
# settings:
# addresses: example11111@example.com
# delete_notifiers:
# - name: default-slack-temp
# org_name: Main Org.
# uid: notifier1

View File

@ -102,6 +102,22 @@ 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
# 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"
@ -130,6 +146,9 @@ log_queries =
# This enables data proxy logging, default is false # This enables data proxy logging, default is false
;logging = false ;logging = false
# How long the data proxy should wait before timing out default is 30 (seconds)
;timeout = 30
#################################### Analytics #################################### #################################### Analytics ####################################
[analytics] [analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours. # Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@ -162,11 +181,6 @@ log_queries =
# used for signing # used for signing
;secret_key = SW2YcwTIb9zpOOhoPsMm ;secret_key = SW2YcwTIb9zpOOhoPsMm
# Auto-login remember days
;login_remember_days = 7
;cookie_username = grafana_user
;cookie_remember_name = grafana_remember
# disable gravatar profile images # disable gravatar profile images
;disable_gravatar = false ;disable_gravatar = false
@ -176,6 +190,9 @@ 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
;https_flag_cookies = false
#################################### Snapshots ########################### #################################### Snapshots ###########################
[snapshots] [snapshots]
# snapshot sharing options # snapshot sharing options
@ -415,7 +432,7 @@ log_queries =
#################################### Explore ############################# #################################### Explore #############################
[explore] [explore]
# Enable the Explore section # Enable the Explore section
;enabled = false ;enabled = true
#################################### Internal Grafana Metrics ########################## #################################### Internal Grafana Metrics ##########################
# Metrics available at HTTP API Url /metrics # Metrics available at HTTP API Url /metrics

View File

@ -54,7 +54,8 @@ services:
# - GF_DATABASE_SSL_MODE=disable # - GF_DATABASE_SSL_MODE=disable
# - 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 - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
- GF_LOGIN_ROTATE_TOKEN_MINUTES=2
ports: ports:
- 3000 - 3000
depends_on: depends_on:

View File

@ -0,0 +1,69 @@
# Grafana load test
Runs load tests and checks using [k6](https://k6.io/).
## Prerequisites
Docker
## Run
Run load test for 15 minutes:
```bash
$ ./run.sh
```
Run load test for custom duration:
```bash
$ ./run.sh -d 10s
```
Example output:
```bash
/\ |‾‾| /‾‾/ /‾/
/\ / \ | |_/ / / /
/ \/ \ | | / ‾‾\
/ \ | |‾\ \ | (_) |
/ __________ \ |__| \__\ \___/ .io
execution: local
output: -
script: src/auth_token_test.js
duration: 15m0s, iterations: -
vus: 2, max: 2
done [==========================================================] 15m0s / 15m0s
█ user auth token test
█ user authenticates thru ui with username and password
✓ response status is 200
✓ response has cookie 'grafana_session' with 32 characters
█ batch tsdb requests
✓ response status is 200
checks.....................: 100.00% ✓ 32844 ✗ 0
data_received..............: 411 MB 457 kB/s
data_sent..................: 12 MB 14 kB/s
group_duration.............: avg=95.64ms min=16.42ms med=94.35ms max=307.52ms p(90)=137.78ms p(95)=146.75ms
http_req_blocked...........: avg=1.27ms min=942ns med=610.08µs max=48.32ms p(90)=2.92ms p(95)=4.25ms
http_req_connecting........: avg=1.06ms min=0s med=456.79µs max=47.19ms p(90)=2.55ms p(95)=3.78ms
http_req_duration..........: avg=58.16ms min=1ms med=52.59ms max=293.35ms p(90)=109.53ms p(95)=120.19ms
http_req_receiving.........: avg=38.98µs min=6.43µs med=32.55µs max=16.2ms p(90)=64.63µs p(95)=78.8µs
http_req_sending...........: avg=328.66µs min=8.09µs med=110.77µs max=44.13ms p(90)=552.65µs p(95)=1.09ms
http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...........: avg=57.79ms min=935.02µs med=52.15ms max=293.06ms p(90)=109.04ms p(95)=119.71ms
http_reqs..................: 34486 38.317775/s
iteration_duration.........: avg=1.09s min=1.81µs med=1.09s max=1.3s p(90)=1.13s p(95)=1.14s
iterations.................: 1642 1.824444/s
vus........................: 2 min=2 max=2
vus_max....................: 2 min=2 max=2
```

View File

@ -0,0 +1,71 @@
import { sleep, check, group } from 'k6';
import { createClient, createBasicAuthClient } from './modules/client.js';
import { createTestOrgIfNotExists, createTestdataDatasourceIfNotExists } from './modules/util.js';
export let options = {
noCookiesReset: true
};
let endpoint = __ENV.URL || 'http://localhost:3000';
const client = createClient(endpoint);
export const setup = () => {
const basicAuthClient = createBasicAuthClient(endpoint, 'admin', 'admin');
const orgId = createTestOrgIfNotExists(basicAuthClient);
const datasourceId = createTestdataDatasourceIfNotExists(basicAuthClient);
client.withOrgId(orgId);
return {
orgId: orgId,
datasourceId: datasourceId,
};
}
export default (data) => {
group("user auth token test", () => {
if (__ITER === 0) {
group("user authenticates thru ui with username and password", () => {
let res = client.ui.login('admin', 'admin');
check(res, {
'response status is 200': (r) => r.status === 200,
'response has cookie \'grafana_session\' with 32 characters': (r) => r.cookies.grafana_session[0].value.length === 32,
});
});
}
if (__ITER !== 0) {
group("batch tsdb requests", () => {
const batchCount = 20;
const requests = [];
const payload = {
from: '1547765247624',
to: '1547768847624',
queries: [{
refId: 'A',
scenarioId: 'random_walk',
intervalMs: 10000,
maxDataPoints: 433,
datasourceId: data.datasourceId,
}]
};
requests.push({ method: 'GET', url: '/api/annotations?dashboardId=2074&from=1548078832772&to=1548082432772' });
for (let n = 0; n < batchCount; n++) {
requests.push({ method: 'POST', url: '/api/tsdb/query', body: payload });
}
let responses = client.batch(requests);
for (let n = 0; n < batchCount; n++) {
check(responses[n], {
'response status is 200': (r) => r.status === 200,
});
}
});
}
});
sleep(1)
}
export const teardown = (data) => {}

View File

@ -0,0 +1,187 @@
import http from "k6/http";
import encoding from 'k6/encoding';
export const UIEndpoint = class UIEndpoint {
constructor(httpClient) {
this.httpClient = httpClient;
}
login(username, pwd) {
const payload = { user: username, password: pwd };
return this.httpClient.formPost('/login', payload);
}
}
export const DatasourcesEndpoint = class DatasourcesEndpoint {
constructor(httpClient) {
this.httpClient = httpClient;
}
getById(id) {
return this.httpClient.get(`/datasources/${id}`);
}
getByName(name) {
return this.httpClient.get(`/datasources/name/${name}`);
}
create(payload) {
return this.httpClient.post(`/datasources`, JSON.stringify(payload));
}
delete(id) {
return this.httpClient.delete(`/datasources/${id}`);
}
}
export const OrganizationsEndpoint = class OrganizationsEndpoint {
constructor(httpClient) {
this.httpClient = httpClient;
}
getById(id) {
return this.httpClient.get(`/orgs/${id}`);
}
getByName(name) {
return this.httpClient.get(`/orgs/name/${name}`);
}
create(name) {
let payload = {
name: name,
};
return this.httpClient.post(`/orgs`, JSON.stringify(payload));
}
delete(id) {
return this.httpClient.delete(`/orgs/${id}`);
}
}
export const GrafanaClient = class GrafanaClient {
constructor(httpClient) {
httpClient.onBeforeRequest = this.onBeforeRequest;
this.raw = httpClient;
this.ui = new UIEndpoint(httpClient);
this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api'));
this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api'));
}
batch(requests) {
return this.raw.batch(requests);
}
withOrgId(orgId) {
this.orgId = orgId;
}
onBeforeRequest(params) {
if (this.orgId && this.orgId > 0) {
params = params.headers || {};
params.headers["X-Grafana-Org-Id"] = this.orgId;
}
}
}
export const BaseClient = class BaseClient {
constructor(url, subUrl) {
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
if (subUrl.endsWith('/')) {
subUrl = subUrl.substring(0, subUrl.length - 1);
}
this.url = url + subUrl;
this.onBeforeRequest = () => {};
}
withUrl(subUrl) {
let c = new BaseClient(this.url, subUrl);
c.onBeforeRequest = this.onBeforeRequest;
return c;
}
beforeRequest(params) {
}
get(url, params) {
params = params || {};
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.get(this.url + url, params);
}
formPost(url, body, params) {
params = params || {};
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.post(this.url + url, body, params);
}
post(url, body, params) {
params = params || {};
params.headers = params.headers || {};
params.headers['Content-Type'] = 'application/json';
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.post(this.url + url, body, params);
}
delete(url, params) {
params = params || {};
this.beforeRequest(params);
this.onBeforeRequest(params);
return http.del(this.url + url, null, params);
}
batch(requests) {
for (let n = 0; n < requests.length; n++) {
let params = requests[n].params || {};
params.headers = params.headers || {};
params.headers['Content-Type'] = 'application/json';
this.beforeRequest(params);
this.onBeforeRequest(params);
requests[n].params = params;
requests[n].url = this.url + requests[n].url;
if (requests[n].body) {
requests[n].body = JSON.stringify(requests[n].body);
}
}
return http.batch(requests);
}
}
export class BasicAuthClient extends BaseClient {
constructor(url, subUrl, username, password) {
super(url, subUrl);
this.username = username;
this.password = password;
}
withUrl(subUrl) {
let c = new BasicAuthClient(this.url, subUrl, this.username, this.password);
c.onBeforeRequest = this.onBeforeRequest;
return c;
}
beforeRequest(params) {
params = params || {};
params.headers = params.headers || {};
let token = `${this.username}:${this.password}`;
params.headers['Authorization'] = `Basic ${encoding.b64encode(token)}`;
}
}
export const createClient = (url) => {
return new GrafanaClient(new BaseClient(url, ''));
}
export const createBasicAuthClient = (url, username, password) => {
return new GrafanaClient(new BasicAuthClient(url, '', username, password));
}

View File

@ -0,0 +1,35 @@
export const createTestOrgIfNotExists = (client) => {
let orgId = 0;
let res = client.orgs.getByName('k6');
if (res.status === 404) {
res = client.orgs.create('k6');
if (res.status !== 200) {
throw new Error('Expected 200 response status when creating org');
}
orgId = res.json().orgId;
} else {
orgId = res.json().id;
}
client.withOrgId(orgId);
return orgId;
}
export const createTestdataDatasourceIfNotExists = (client) => {
const payload = {
access: 'proxy',
isDefault: false,
name: 'k6-testdata',
type: 'testdata',
};
let res = client.datasources.getByName(payload.name);
if (res.status === 404) {
res = client.datasources.create(payload);
if (res.status !== 200) {
throw new Error('Expected 200 response status when creating datasource');
}
}
return res.json().id;
}

24
devenv/docker/loadtest/run.sh Executable file
View File

@ -0,0 +1,24 @@
#/bin/bash
PWD=$(pwd)
run() {
duration='15m'
url='http://localhost:3000'
while getopts ":d:u:" o; do
case "${o}" in
d)
duration=${OPTARG}
;;
u)
url=${OPTARG}
;;
esac
done
shift $((OPTIND-1))
docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus 2 --duration $duration src/auth_token_test.js
}
run "$@"

View File

@ -231,3 +231,187 @@ By default Grafana will delete dashboards in the database if the file is removed
> which leads to problems if you re-use settings that are supposed to be unique. > which leads to problems if you re-use settings that are supposed to be unique.
> Be careful not to re-use the same `title` multiple times within a folder > Be careful not to re-use the same `title` multiple times within a folder
> or `uid` within the same installation as this will cause weird behaviors. > or `uid` within the same installation as this will cause weird behaviors.
## Alert Notification Channels
Alert Notification Channels can be provisioned by adding one or more yaml config files in the [`provisioning/notifiers`](/installation/configuration/#provisioning) directory.
Each config file can contain the following top-level fields:
- `notifiers`, a list of alert notifications that will be added or updated during start up. If the notification channel already exists, Grafana will update it to match the configuration file.
- `delete_notifiers`, a list of alert notifications to be deleted before before inserting/updating those in the `notifiers` list.
Provisioning looks up alert notifications by uid, and will update any existing notification with the provided uid.
By default, exporting a dashboard as JSON will use a sequential identifier to refer to alert notifications. The field `uid` can be optionally specified to specify a string identifier for the alert name.
```json
{
...
"alert": {
...,
"conditions": [...],
"frequency": "24h",
"noDataState": "ok",
"notifications": [
{"uid": "notifier1"},
{"uid": "notifier2"},
]
}
...
}
```
### Example Alert Notification Channels Config File
```yaml
notifiers:
- name: notification-channel-1
type: slack
uid: notifier1
# either
org_id: 2
# or
org_name: Main Org.
is_default: true
# See `Supported Settings` section for settings supporter for each
# alert notification type.
settings:
recipient: "XXX"
token: "xoxb"
uploadImage: true
url: https://slack.com
delete_notifiers:
- name: notification-channel-1
uid: notifier1
# either
org_id: 2
# or
org_name: Main Org.
- name: notification-channel-2
# default org_id: 1
```
### Supported Settings
The following sections detail the supported settings for each alert notification type.
#### Alert notification `pushover`
| Name |
| ---- |
| apiToken |
| userKey |
| device |
| retry |
| expire |
#### Alert notification `slack`
| Name |
| ---- |
| url |
| recipient |
| username |
| iconEmoji |
| iconUrl |
| uploadImage |
| mention |
| token |
#### Alert notification `victorops`
| Name |
| ---- |
| url |
#### Alert notification `kafka`
| Name |
| ---- |
| kafkaRestProxy |
| kafkaTopic |
#### Alert notification `LINE`
| Name |
| ---- |
| token |
#### Alert notification `pagerduty`
| Name |
| ---- |
| integrationKey |
#### Alert notification `sensu`
| Name |
| ---- |
| url |
| source |
| handler |
| username |
| password |
#### Alert notification `prometheus-alertmanager`
| Name |
| ---- |
| url |
#### Alert notification `teams`
| Name |
| ---- |
| url |
#### Alert notification `dingding`
| Name |
| ---- |
| url |
#### Alert notification `email`
| Name |
| ---- |
| addresses |
#### Alert notification `hipchat`
| Name |
| ---- |
| url |
| apikey |
| roomid |
#### Alert notification `opsgenie`
| Name |
| ---- |
| apiKey |
| apiUrl |
#### Alert notification `telegram`
| Name |
| ---- |
| bottoken |
| chatid |
#### Alert notification `threema`
| Name |
| ---- |
| gateway_id |
| recipient_id |
| api_secret |
#### Alert notification `webhook`
| Name |
| ---- |
| url |
| username |
| password |

View File

@ -110,6 +110,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183* *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamp. For example, *dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872*
*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as nanosecond timestamp. For example, *1494497183142514872*
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+). *$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+).
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). *$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+).

View File

@ -144,6 +144,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183* *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamp. For example, *dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872*
*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as nanosecond timestamp. For example, *1494497183142514872*
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+). *$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+).
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). *$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+).

View File

@ -154,6 +154,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamps. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183* *$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamps. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183*
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamps. For example, *dateColumn >= 1494410783152415214 AND dateColumn <= 1494497183142514872*
*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183142514872*
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup, but for times stored as unix timestamp (only available in Grafana 5.3+). *$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup, but for times stored as unix timestamp (only available in Grafana 5.3+).
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above, but also adds a column alias (only available in Grafana 5.3+). *$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above, but also adds a column alias (only available in Grafana 5.3+).

View File

@ -83,3 +83,28 @@ Content-Type: application/json
{"message": "Logged in"} {"message": "Logged in"}
``` ```
# Health API
## Returns health information about Grafana
`GET /api/health`
**Example Request**
```http
GET /api/health
Accept: application/json
```
**Example Response**:
```http
HTTP/1.1 200 OK
{
"commit": "087143285",
"database": "ok",
"version": "5.1.3"
}
```

View File

@ -0,0 +1,2 @@
import '@storybook/addon-knobs/register';
import '@storybook/addon-actions/register';

View File

@ -0,0 +1,12 @@
import { configure } from '@storybook/react';
import '../../../public/sass/grafana.light.scss';
// automatically import all files ending in *.stories.tsx
const req = require.context('../src/components', true, /.story.tsx$/);
function loadStories() {
req.keys().forEach(req);
}
configure(loadStories, module);

View File

@ -0,0 +1,56 @@
const path = require('path');
module.exports = (baseConfig, env, config) => {
config.module.rules.push({
test: /\.(ts|tsx)$/,
use: [
{
loader: require.resolve('awesome-typescript-loader'),
},
],
});
config.module.rules.push({
test: /\.scss$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
options: {
importLoaders: 2,
url: false,
sourceMap: false,
minimize: false,
},
},
{
loader: 'postcss-loader',
options: {
sourceMap: false,
config: { path: __dirname + '../../../../scripts/webpack/postcss.config.js' },
},
},
{ loader: 'sass-loader', options: { sourceMap: false } },
],
});
config.module.rules.push({
test: require.resolve('jquery'),
use: [
{
loader: 'expose-loader',
query: 'jQuery',
},
{
loader: 'expose-loader',
query: '$',
},
],
});
config.resolve.extensions.push('.ts', '.tsx');
return config;
};

View File

@ -5,19 +5,20 @@
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"tslint": "tslint -c tslint.json --project tsconfig.json", "tslint": "tslint -c tslint.json --project tsconfig.json",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"storybook": "start-storybook -p 9001 -c .storybook -s ../../public"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@torkelo/react-select": "2.1.1", "@torkelo/react-select": "2.1.1",
"@types/react-test-renderer": "^16.0.3", "@types/react-color": "^2.14.0",
"@types/react-transition-group": "^2.0.15",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"jquery": "^3.2.1", "jquery": "^3.2.1",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"moment": "^2.22.2", "moment": "^2.22.2",
"react": "^16.6.3", "react": "^16.6.3",
"react-color": "^2.17.0",
"react-custom-scrollbars": "^4.2.1", "react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.6.3", "react-dom": "^16.6.3",
"react-highlight-words": "0.11.0", "react-highlight-words": "0.11.0",
@ -29,16 +30,32 @@
"tinycolor2": "^1.4.1" "tinycolor2": "^1.4.1"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "^4.1.7",
"@storybook/addon-info": "^4.1.6",
"@storybook/addon-knobs": "^4.1.7",
"@storybook/react": "^4.1.4",
"@types/classnames": "^2.2.6", "@types/classnames": "^2.2.6",
"@types/jest": "^23.3.2", "@types/jest": "^23.3.2",
"@types/jquery": "^1.10.35", "@types/jquery": "^1.10.35",
"@types/lodash": "^4.14.119", "@types/lodash": "^4.14.119",
"@types/node": "^10.12.18",
"@types/react": "^16.7.6", "@types/react": "^16.7.6",
"@types/react-custom-scrollbars": "^4.0.5", "@types/react-custom-scrollbars": "^4.0.5",
"@types/react-test-renderer": "^16.0.3", "@types/react-test-renderer": "^16.0.3",
"@types/react-transition-group": "^2.0.15",
"@types/storybook__addon-actions": "^3.4.1",
"@types/storybook__addon-info": "^3.4.2",
"@types/storybook__addon-knobs": "^4.0.0",
"@types/storybook__react": "^4.0.0",
"@types/tether-drop": "^1.4.8", "@types/tether-drop": "^1.4.8",
"@types/tinycolor2": "^1.4.1", "@types/tinycolor2": "^1.4.1",
"awesome-typescript-loader": "^5.2.1",
"react-docgen-typescript-loader": "^3.0.0",
"react-docgen-typescript-webpack-plugin": "^1.1.0",
"react-test-renderer": "^16.7.0", "react-test-renderer": "^16.7.0",
"typescript": "^3.2.2" "typescript": "^3.2.2"
},
"resolutions": {
"@types/lodash": "4.14.119"
} }
} }

View File

@ -0,0 +1,94 @@
import React from 'react';
import { ColorPickerProps } from './ColorPicker';
import tinycolor from 'tinycolor2';
import { debounce } from 'lodash';
interface ColorInputState {
previousColor: string;
value: string;
}
interface ColorInputProps extends ColorPickerProps {
style?: React.CSSProperties;
}
class ColorInput extends React.PureComponent<ColorInputProps, ColorInputState> {
constructor(props: ColorInputProps) {
super(props);
this.state = {
previousColor: props.color,
value: props.color,
};
this.updateColor = debounce(this.updateColor, 100);
}
static getDerivedStateFromProps(props: ColorPickerProps, state: ColorInputState) {
const newColor = tinycolor(props.color);
if (newColor.isValid() && props.color !== state.previousColor) {
return {
...state,
previousColor: props.color,
value: newColor.toString(),
};
}
return state;
}
updateColor = (color: string) => {
this.props.onChange(color);
};
handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const newColor = tinycolor(event.currentTarget.value);
this.setState({
value: event.currentTarget.value,
});
if (newColor.isValid()) {
this.updateColor(newColor.toString());
}
};
handleBlur = () => {
const newColor = tinycolor(this.state.value);
if (!newColor.isValid()) {
this.setState({
value: this.props.color,
});
}
};
render() {
const { value } = this.state;
return (
<div
style={{
display: 'flex',
...this.props.style,
}}
>
<div
style={{
background: this.props.color,
width: '35px',
height: '35px',
flexGrow: 0,
borderRadius: '3px 0 0 3px',
}}
/>
<div
style={{
flexGrow: 1,
}}
>
<input className="gf-form-input" value={value} onChange={this.handleChange} onBlur={this.handleBlur} />
</div>
</div>
);
}
}
export default ColorInput;

View File

@ -0,0 +1,63 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withKnobs, boolean } from '@storybook/addon-knobs';
import { SeriesColorPicker, ColorPicker } from './ColorPicker';
import { action } from '@storybook/addon-actions';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
import { getThemeKnob } from '../../utils/storybook/themeKnob';
const getColorPickerKnobs = () => {
return {
selectedTheme: getThemeKnob(),
enableNamedColors: boolean('Enable named colors', false),
};
};
const ColorPickerStories = storiesOf('UI/ColorPicker/Pickers', module);
ColorPickerStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
ColorPickerStories.add('default', () => {
const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
return (
<UseState initialState="#00ff00">
{(selectedColor, updateSelectedColor) => {
return (
<ColorPicker
enableNamedColors={enableNamedColors}
color={selectedColor}
onChange={color => {
action('Color changed')(color);
updateSelectedColor(color);
}}
theme={selectedTheme || undefined}
/>
);
}}
</UseState>
);
});
ColorPickerStories.add('Series color picker', () => {
const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
return (
<UseState initialState="#00ff00">
{(selectedColor, updateSelectedColor) => {
return (
<SeriesColorPicker
enableNamedColors={enableNamedColors}
yaxis={1}
onToggleAxis={() => {}}
color={selectedColor}
onChange={color => updateSelectedColor(color)}
theme={selectedTheme || undefined}
>
<div style={{ color: selectedColor, cursor: 'pointer' }}>Open color picker</div>
</SeriesColorPicker>
);
}}
</UseState>
);
});

View File

@ -1,61 +1,114 @@
import React from 'react'; import React, { Component, createRef } from 'react';
import ReactDOM from 'react-dom'; import PopperController from '../Tooltip/PopperController';
import Drop from 'tether-drop'; import Popper, { RenderPopperArrowFn } from '../Tooltip/Popper';
import { ColorPickerPopover } from './ColorPickerPopover'; import { ColorPickerPopover } from './ColorPickerPopover';
import { Themeable, GrafanaTheme } from '../../types';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
import propDeprecationWarning from '../../utils/propDeprecationWarning';
export interface Props { type ColorPickerChangeHandler = (color: string) => void;
export interface ColorPickerProps extends Themeable {
color: string; color: string;
onChange: (c: string) => void; onChange: ColorPickerChangeHandler;
/**
* @deprecated Use onChange instead
*/
onColorChange?: ColorPickerChangeHandler;
enableNamedColors?: boolean;
withArrow?: boolean;
children?: JSX.Element;
} }
export class ColorPicker extends React.Component<Props, any> { export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => {
pickerElem: HTMLElement | null; const { onColorChange } = props;
colorPickerDrop: any; if (onColorChange) {
propDeprecationWarning(componentName, 'onColorChange', 'onChange');
openColorPicker = () => {
const dropContent = <ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />;
const dropContentElem = document.createElement('div');
ReactDOM.render(dropContent, dropContentElem);
const drop = new Drop({
target: this.pickerElem as Element,
content: dropContentElem,
position: 'top center',
classes: 'drop-popover',
openOn: 'click',
hoverCloseDelay: 200,
tetherOptions: {
constraints: [{ to: 'scrollParent', attachment: 'none both' }],
attachment: 'bottom center',
},
});
drop.on('close', this.closeColorPicker);
this.colorPickerDrop = drop;
this.colorPickerDrop.open();
};
closeColorPicker = () => {
setTimeout(() => {
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
this.colorPickerDrop.destroy();
}
}, 100);
};
onColorSelect = (color: string) => {
this.props.onChange(color);
};
render() {
return (
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={element => (this.pickerElem = element)}>
<div className="sp-preview">
<div className="sp-preview-inner" style={{ backgroundColor: this.props.color }} />
</div>
</div>
);
} }
} };
export const colorPickerFactory = <T extends ColorPickerProps>(
popover: React.ComponentType<T>,
displayName = 'ColorPicker',
renderPopoverArrowFunction?: RenderPopperArrowFn
) => {
return class ColorPicker extends Component<T, any> {
static displayName = displayName;
pickerTriggerRef = createRef<HTMLDivElement>();
handleColorChange = (color: string) => {
const { onColorChange, onChange } = this.props;
const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler;
return changeHandler(color);
};
render() {
const popoverElement = React.createElement(popover, {
...this.props,
onChange: this.handleColorChange,
});
const { theme, withArrow, 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 (
<PopperController content={popoverElement} hideAfter={300}>
{(showPopper, hidePopper, popperProps) => {
return (
<>
{this.pickerTriggerRef.current && (
<Popper
{...popperProps}
referenceElement={this.pickerTriggerRef.current}
wrapperClassName="ColorPicker"
renderArrow={withArrow && (renderPopoverArrowFunction || renderArrow)}
onMouseLeave={hidePopper}
onMouseEnter={showPopper}
/>
)}
{children ? (
React.cloneElement(children as JSX.Element, {
ref: this.pickerTriggerRef,
onClick: showPopper,
onMouseLeave: hidePopper,
})
) : (
<div
ref={this.pickerTriggerRef}
onClick={showPopper}
onMouseLeave={hidePopper}
className="sp-replacer sp-light"
>
<div className="sp-preview">
<div
className="sp-preview-inner"
style={{
backgroundColor: getColorFromHexRgbOrName(this.props.color || '#000000', theme),
}}
/>
</div>
</div>
)}
</>
);
}}
</PopperController>
);
}
};
};
export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker');
export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker');

View File

@ -0,0 +1,40 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { ColorPickerPopover } from './ColorPickerPopover';
import { withKnobs } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { getThemeKnob } from '../../utils/storybook/themeKnob';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
const ColorPickerPopoverStories = storiesOf('UI/ColorPicker/Popovers', module);
ColorPickerPopoverStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
ColorPickerPopoverStories.add('default', () => {
const selectedTheme = getThemeKnob();
return (
<ColorPickerPopover
color="#BC67E6"
onChange={color => {
console.log(color);
}}
theme={selectedTheme || undefined}
/>
);
});
ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => {
const selectedTheme = getThemeKnob();
return (
<SeriesColorPickerPopover
color="#BC67E6"
onChange={color => {
console.log(color);
}}
theme={selectedTheme || undefined}
/>
);
});

View File

@ -0,0 +1,75 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { ColorPickerPopover } from './ColorPickerPopover';
import { getColorDefinitionByName, getNamedColorPalette } from '../../utils/namedColorsPalette';
import { ColorSwatch } from './NamedColorsGroup';
import { flatten } from 'lodash';
import { GrafanaTheme } from '../../types';
const allColors = flatten(Array.from(getNamedColorPalette().values()));
describe('ColorPickerPopover', () => {
const BasicGreen = getColorDefinitionByName('green');
const BasicBlue = getColorDefinitionByName('blue');
describe('rendering', () => {
it('should render provided color as selected if color provided by name', () => {
const wrapper = mount(<ColorPickerPopover color={BasicGreen.name} onChange={() => {}} />);
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
expect(selectedSwatch.length).toBe(1);
expect(notSelectedSwatches.length).toBe(allColors.length - 1);
expect(selectedSwatch.prop('isSelected')).toBe(true);
});
it('should render provided color as selected if color provided by hex', () => {
const wrapper = mount(<ColorPickerPopover color={BasicGreen.variants.dark} onChange={() => {}} />);
const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
expect(selectedSwatch.length).toBe(1);
expect(notSelectedSwatches.length).toBe(allColors.length - 1);
expect(selectedSwatch.prop('isSelected')).toBe(true);
});
});
describe('named colors support', () => {
const onChangeSpy = jest.fn();
let wrapper: ReactWrapper;
afterEach(() => {
wrapper.unmount();
onChangeSpy.mockClear();
});
it('should pass hex color value to onChange prop by default', () => {
wrapper = mount(
<ColorPickerPopover color={BasicGreen.variants.dark} onChange={onChangeSpy} theme={GrafanaTheme.Light} />
);
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
basicBlueSwatch.simulate('click');
expect(onChangeSpy).toBeCalledTimes(1);
expect(onChangeSpy).toBeCalledWith(BasicBlue.variants.light);
});
it('should pass color name to onChange prop when named colors enabled', () => {
wrapper = mount(
<ColorPickerPopover
enableNamedColors
color={BasicGreen.variants.dark}
onChange={onChangeSpy}
theme={GrafanaTheme.Light}
/>
);
const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
basicBlueSwatch.simulate('click');
expect(onChangeSpy).toBeCalledTimes(1);
expect(onChangeSpy).toBeCalledWith(BasicBlue.name);
});
});
});

View File

@ -1,112 +1,129 @@
import React from 'react'; import React from 'react';
import $ from 'jquery'; import { NamedColorsPalette } from './NamedColorsPalette';
import tinycolor from 'tinycolor2'; import { getColorName, getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import { ColorPalette } from './ColorPalette'; import { ColorPickerProps, warnAboutColorPickerPropsDeprecation } from './ColorPicker';
import { SpectrumPicker } from './SpectrumPicker'; import { GrafanaTheme } from '../../types';
import { PopperContentProps } from '../Tooltip/PopperController';
import SpectrumPalette from './SpectrumPalette';
const DEFAULT_COLOR = '#000000'; export interface Props<T> extends ColorPickerProps, PopperContentProps {
customPickers?: T;
export interface Props {
color: string;
onColorSelect: (c: string) => void;
} }
export class ColorPickerPopover extends React.Component<Props, any> { type PickerType = 'palette' | 'spectrum';
pickerNavElem: any;
constructor(props: Props) { interface CustomPickersDescriptor {
[key: string]: {
tabComponent: React.ComponentType<ColorPickerProps>;
name: string;
};
}
interface State<T> {
activePicker: PickerType | keyof T;
}
export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React.Component<Props<T>, State<T>> {
constructor(props: Props<T>) {
super(props); super(props);
this.state = { this.state = {
tab: 'palette', activePicker: 'palette',
color: this.props.color || DEFAULT_COLOR,
colorString: this.props.color || DEFAULT_COLOR,
}; };
warnAboutColorPickerPropsDeprecation('ColorPickerPopover', props);
} }
setPickerNavElem(elem: any) { getTabClassName = (tabName: PickerType | keyof T) => {
this.pickerNavElem = $(elem); const { activePicker } = this.state;
} return `ColorPickerPopover__tab ${activePicker === tabName && 'ColorPickerPopover__tab--active'}`;
};
setColor(color: string) { handleChange = (color: any) => {
const newColor = tinycolor(color); const { onColorChange, onChange, enableNamedColors, theme } = this.props;
if (newColor.isValid()) { const changeHandler = onColorChange || onChange;
this.setState({ color: newColor.toString(), colorString: newColor.toString() });
this.props.onColorSelect(color); if (enableNamedColors) {
return changeHandler(color);
} }
} changeHandler(getColorFromHexRgbOrName(color, theme));
};
sampleColorSelected(color: string) { handleTabChange = (tab: PickerType | keyof T) => {
this.setColor(color); return () => this.setState({ activePicker: tab });
} };
spectrumColorSelected(color: any) { renderPicker = () => {
const rgbColor = color.toRgbString(); const { activePicker } = this.state;
this.setColor(rgbColor); const { color, theme } = this.props;
}
onColorStringChange(e: any) { switch (activePicker) {
const colorString = e.target.value; case 'spectrum':
this.setState({ colorString: colorString }); return <SpectrumPalette color={color} onChange={this.handleChange} theme={theme} />;
case 'palette':
const newColor = tinycolor(colorString); return <NamedColorsPalette color={getColorName(color, theme)} onChange={this.handleChange} theme={theme} />;
if (newColor.isValid()) { default:
// Update only color state return this.renderCustomPicker(activePicker);
const newColorString = newColor.toString();
this.setState({ color: newColorString });
this.props.onColorSelect(newColorString);
} }
} };
onColorStringBlur(e: any) { renderCustomPicker = (tabKey: keyof T) => {
const colorString = e.target.value; const { customPickers, color, theme } = this.props;
this.setColor(colorString); if (!customPickers) {
} return null;
}
componentDidMount() { return React.createElement(customPickers[tabKey].tabComponent, {
this.pickerNavElem.find('li:first').addClass('active'); color,
this.pickerNavElem.on('show', (e: any) => { theme,
// use href attr (#name => name) onChange: this.handleChange,
const tab = e.target.hash.slice(1);
this.setState({ tab: tab });
}); });
} };
render() { renderCustomPickerTabs = () => {
const paletteTab = ( const { customPickers } = this.props;
<div id="palette">
<ColorPalette color={this.state.color} onColorSelect={this.sampleColorSelected.bind(this)} /> if (!customPickers) {
</div> return null;
); }
const spectrumTab = (
<div id="spectrum">
<SpectrumPicker color={this.state.color} onColorSelect={this.spectrumColorSelected.bind(this)} options={{}} />
</div>
);
const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab;
return ( return (
<div className="gf-color-picker"> <>
<ul className="nav nav-tabs" id="colorpickernav" ref={this.setPickerNavElem.bind(this)}> {Object.keys(customPickers).map(key => {
<li className="gf-tabs-item-colorpicker"> return (
<a href="#palette" data-toggle="tab"> <div
Colors className={this.getTabClassName(key)}
</a> onClick={this.handleTabChange(key)}
</li> key={key}
<li className="gf-tabs-item-colorpicker"> >
<a href="#spectrum" data-toggle="tab"> {customPickers[key].name}
Custom </div>
</a> );
</li> })}
</ul> </>
<div className="gf-color-picker__body">{currentTab}</div> );
<div> };
<input
className="gf-form-input gf-form-input--small" render() {
value={this.state.colorString} const { theme } = this.props;
onChange={this.onColorStringChange.bind(this)} const colorPickerTheme = theme || GrafanaTheme.Dark;
onBlur={this.onColorStringBlur.bind(this)}
/> return (
<div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
<div className="ColorPickerPopover__tabs">
<div
className={this.getTabClassName('palette')}
onClick={this.handleTabChange('palette')}
>
Colors
</div>
<div
className={this.getTabClassName('spectrum')}
onClick={this.handleTabChange('spectrum')}
>
Custom
</div>
{this.renderCustomPickerTabs()}
</div> </div>
<div className="ColorPickerPopover__content">{this.renderPicker()}</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,110 @@
import React, { FunctionComponent } from 'react';
import { Themeable, GrafanaTheme } from '../../types';
import { ColorDefinition, getColorForTheme } from '../../utils/namedColorsPalette';
import { Color } from 'csstype';
import { find, upperFirst } from 'lodash';
type ColorChangeHandler = (color: ColorDefinition) => void;
export enum ColorSwatchVariant {
Small = 'small',
Large = 'large',
}
interface ColorSwatchProps extends Themeable, React.DOMAttributes<HTMLDivElement> {
color: string;
label?: string;
variant?: ColorSwatchVariant;
isSelected?: boolean;
}
export const ColorSwatch: FunctionComponent<ColorSwatchProps> = ({
color,
label,
variant = ColorSwatchVariant.Small,
isSelected,
theme,
...otherProps
}) => {
const isSmall = variant === ColorSwatchVariant.Small;
const swatchSize = isSmall ? '16px' : '32px';
const selectedSwatchBorder = theme === GrafanaTheme.Light ? '#ffffff' : '#1A1B1F';
const swatchStyles = {
width: swatchSize,
height: swatchSize,
borderRadius: '50%',
background: `${color}`,
marginRight: isSmall ? '0px' : '8px',
boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${selectedSwatchBorder}` : 'none',
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
}}
{...otherProps}
>
<div style={swatchStyles} />
{variant === ColorSwatchVariant.Large && <span>{label}</span>}
</div>
);
};
interface NamedColorsGroupProps extends Themeable {
colors: ColorDefinition[];
selectedColor?: Color;
onColorSelect: ColorChangeHandler;
key?: string;
}
const NamedColorsGroup: FunctionComponent<NamedColorsGroupProps> = ({
colors,
selectedColor,
onColorSelect,
theme,
...otherProps
}) => {
const primaryColor = find(colors, color => !!color.isPrimary);
return (
<div {...otherProps} style={{ display: 'flex', flexDirection: 'column' }}>
{primaryColor && (
<ColorSwatch
key={primaryColor.name}
isSelected={primaryColor.name === selectedColor}
variant={ColorSwatchVariant.Large}
color={getColorForTheme(primaryColor, theme)}
label={upperFirst(primaryColor.hue)}
onClick={() => onColorSelect(primaryColor)}
theme={theme}
/>
)}
<div
style={{
display: 'flex',
marginTop: '8px',
}}
>
{colors.map(
color =>
!color.isPrimary && (
<div key={color.name} style={{ marginRight: '4px' }}>
<ColorSwatch
key={color.name}
isSelected={color.name === selectedColor}
color={getColorForTheme(color, theme)}
onClick={() => onColorSelect(color)}
theme={theme}
/>
</div>
)
)}
</div>
</div>
);
};
export default NamedColorsGroup;

View File

@ -0,0 +1,52 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { NamedColorsPalette } from './NamedColorsPalette';
import { getColorName, getColorDefinitionByName } from '../../utils/namedColorsPalette';
import { withKnobs, select } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
const BasicGreen = getColorDefinitionByName('green');
const BasicBlue = getColorDefinitionByName('blue');
const LightBlue = getColorDefinitionByName('light-blue');
const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module);
NamedColorsPaletteStories.addDecorator(withKnobs).addDecorator(withCenteredStory);
NamedColorsPaletteStories.add('Named colors swatch - support for named colors', () => {
const selectedColor = select(
'Selected color',
{
Green: 'green',
Red: 'red',
'Light blue': 'light-blue',
},
'red'
);
return (
<UseState initialState={selectedColor}>
{(selectedColor, updateSelectedColor) => {
return <NamedColorsPalette color={selectedColor} onChange={updateSelectedColor} />;
}}
</UseState>
);
}).add('Named colors swatch - support for hex values', () => {
const selectedColor = select(
'Selected color',
{
Green: BasicGreen.variants.dark,
Red: BasicBlue.variants.dark,
'Light blue': LightBlue.variants.dark,
},
'red'
);
return (
<UseState initialState={selectedColor}>
{(selectedColor, updateSelectedColor) => {
return <NamedColorsPalette color={getColorName(selectedColor)} onChange={updateSelectedColor} />;
}}
</UseState>
);
});

View File

@ -0,0 +1,36 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { NamedColorsPalette } from './NamedColorsPalette';
import { ColorSwatch } from './NamedColorsGroup';
import { getColorDefinitionByName } from '../../utils';
import { GrafanaTheme } from '../../types';
describe('NamedColorsPalette', () => {
const BasicGreen = getColorDefinitionByName('green');
describe('theme support for named colors', () => {
let wrapper: ReactWrapper, selectedSwatch;
afterEach(() => {
wrapper.unmount();
});
it('should render provided color variant specific for theme', () => {
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={GrafanaTheme.Dark} onChange={() => {}} />);
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
wrapper.unmount();
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={GrafanaTheme.Light} onChange={() => {}} />);
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light);
});
it('should render dar variant of provided color when theme not provided', () => {
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} onChange={() => {}} />);
selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
});
});
});

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Color, getNamedColorPalette } from '../../utils/namedColorsPalette';
import { Themeable } from '../../types/index';
import NamedColorsGroup from './NamedColorsGroup';
interface NamedColorsPaletteProps extends Themeable {
color?: Color;
onChange: (colorName: string) => void;
}
export const NamedColorsPalette = ({ color, onChange, theme }: NamedColorsPaletteProps) => {
const swatches: JSX.Element[] = [];
getNamedColorPalette().forEach((colors, hue) => {
swatches.push(
<NamedColorsGroup
key={hue}
theme={theme}
selectedColor={color}
colors={colors}
onColorSelect={color => {
onChange(color.name);
}}
/>
);
});
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridRowGap: '24px',
gridColumnGap: '24px',
}}
>
{swatches}
</div>
);
};

View File

@ -1,85 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Drop from 'tether-drop';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
export interface SeriesColorPickerProps {
color: string;
yaxis?: number;
optionalClass?: string;
onColorChange: (newColor: string) => void;
onToggleAxis?: () => void;
}
export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
pickerElem: any;
colorPickerDrop: any;
static defaultProps = {
optionalClass: '',
yaxis: undefined,
onToggleAxis: () => {},
};
constructor(props: SeriesColorPickerProps) {
super(props);
}
componentWillUnmount() {
this.destroyDrop();
}
onClickToOpen = () => {
if (this.colorPickerDrop) {
this.destroyDrop();
}
const { color, yaxis, onColorChange, onToggleAxis } = this.props;
const dropContent = (
<SeriesColorPickerPopover color={color} yaxis={yaxis} onColorChange={onColorChange} onToggleAxis={onToggleAxis} />
);
const dropContentElem = document.createElement('div');
ReactDOM.render(dropContent, dropContentElem);
const drop = new Drop({
target: this.pickerElem,
content: dropContentElem,
position: 'bottom center',
classes: 'drop-popover',
openOn: 'hover',
hoverCloseDelay: 200,
remove: true,
tetherOptions: {
constraints: [{ to: 'scrollParent', attachment: 'none both' }],
attachment: 'bottom center',
},
});
drop.on('close', this.closeColorPicker.bind(this));
this.colorPickerDrop = drop;
this.colorPickerDrop.open();
};
closeColorPicker() {
setTimeout(() => {
this.destroyDrop();
}, 100);
}
destroyDrop() {
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
this.colorPickerDrop.destroy();
this.colorPickerDrop = null;
}
}
render() {
const { optionalClass, children } = this.props;
return (
<div className={optionalClass} ref={e => (this.pickerElem = e)} onClick={this.onClickToOpen}>
{children}
</div>
);
}
}

View File

@ -1,23 +1,44 @@
import React from 'react'; import React, { FunctionComponent } from 'react';
import { ColorPickerPopover } from './ColorPickerPopover';
export interface SeriesColorPickerPopoverProps { import { ColorPickerPopover } from './ColorPickerPopover';
color: string; import { ColorPickerProps } from './ColorPicker';
import { PopperContentProps } from '../Tooltip/PopperController';
import { Switch } from '../Switch/Switch';
export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperContentProps {
yaxis?: number; yaxis?: number;
onColorChange: (color: string) => void;
onToggleAxis?: () => void; onToggleAxis?: () => void;
} }
export class SeriesColorPickerPopover extends React.PureComponent<SeriesColorPickerPopoverProps, any> { export const SeriesColorPickerPopover: FunctionComponent<SeriesColorPickerPopoverProps> = props => {
render() { const { yaxis, onToggleAxis, color, ...colorPickerProps } = props;
return (
<div className="graph-legend-popover"> return (
{this.props.yaxis && <AxisSelector yaxis={this.props.yaxis} onToggleAxis={this.props.onToggleAxis} />} <ColorPickerPopover
<ColorPickerPopover color={this.props.color} onColorSelect={this.props.onColorChange} /> {...colorPickerProps}
</div> color={color || '#000000'}
); customPickers={{
} yaxis: {
} name: 'Y-Axis',
tabComponent: () => (
<Switch
key="yaxisSwitch"
label="Use right y-axis"
className="ColorPicker__axisSwitch"
labelClass="ColorPicker__axisSwitchLabel"
checked={yaxis === 2}
onChange={() => {
if (onToggleAxis) {
onToggleAxis();
}
}}
/>
),
},
}}
/>
);
};
interface AxisSelectorProps { interface AxisSelectorProps {
yaxis: number; yaxis: number;

View File

@ -0,0 +1,23 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withKnobs } from '@storybook/addon-knobs';
import SpectrumPalette from './SpectrumPalette';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
import { getThemeKnob } from '../../utils/storybook/themeKnob';
const SpectrumPaletteStories = storiesOf('UI/ColorPicker/Palettes/SpectrumPalette', module);
SpectrumPaletteStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
SpectrumPaletteStories.add('Named colors swatch - support for named colors', () => {
const selectedTheme = getThemeKnob();
return (
<UseState initialState="red">
{(selectedColor, updateSelectedColor) => {
return <SpectrumPalette theme={selectedTheme} color={selectedColor} onChange={updateSelectedColor} />;
}}
</UseState>
);
});

View File

@ -0,0 +1,100 @@
import React from 'react';
import { CustomPicker, ColorResult } from 'react-color';
import { Saturation, Hue, Alpha } from 'react-color/lib/components/common';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
import tinycolor from 'tinycolor2';
import ColorInput from './ColorInput';
import { Themeable, GrafanaTheme } from '../../types';
import SpectrumPalettePointer, { SpectrumPalettePointerProps } from './SpectrumPalettePointer';
export interface SpectrumPaletteProps extends Themeable {
color: string;
onChange: (color: string) => void;
}
const renderPointer = (theme?: GrafanaTheme) => (props: SpectrumPalettePointerProps) => (
<SpectrumPalettePointer {...props} theme={theme} />
);
// @ts-ignore
const SpectrumPicker = CustomPicker<Themeable>(({ rgb, hsl, onChange, theme }) => {
return (
<div
style={{
display: 'flex',
width: '100%',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
}}
>
<div
style={{
display: 'flex',
flexGrow: 1,
flexDirection: 'column',
}}
>
<div
style={{
position: 'relative',
height: '100px',
width: '100%',
}}
>
{/*
// @ts-ignore */}
<Saturation onChange={onChange} hsl={hsl} hsv={tinycolor(hsl).toHsv()} />
</div>
<div
style={{
width: '100%',
height: '16px',
marginTop: '16px',
position: 'relative',
background: 'white',
}}
>
{/*
// @ts-ignore */}
<Alpha rgb={rgb} hsl={hsl} a={rgb.a} onChange={onChange} pointer={renderPointer(theme)} />
</div>
</div>
<div
style={{
position: 'relative',
width: '16px',
height: '100px',
marginLeft: '16px',
}}
>
{/*
// @ts-ignore */}
<Hue onChange={onChange} hsl={hsl} direction="vertical" pointer={renderPointer(theme)} />
</div>
</div>
</div>
);
});
const SpectrumPalette: React.FunctionComponent<SpectrumPaletteProps> = ({ color, onChange, theme }) => {
return (
<div>
<SpectrumPicker
color={tinycolor(getColorFromHexRgbOrName(color)).toRgb()}
onChange={(a: ColorResult) => {
onChange(tinycolor(a.rgb).toString());
}}
theme={theme}
/>
<ColorInput color={color} onChange={onChange} style={{ marginTop: '16px' }} />
</div>
);
};
export default SpectrumPalette;

View File

@ -0,0 +1,80 @@
import React from 'react';
import { GrafanaTheme, Themeable } from '../../types';
export interface SpectrumPalettePointerProps extends Themeable {
direction?: string;
}
const SpectrumPalettePointer: React.FunctionComponent<SpectrumPalettePointerProps> = ({
theme,
direction,
}) => {
const styles = {
picker: {
width: '16px',
height: '16px',
transform: direction === 'vertical' ? 'translate(0, -8px)' : 'translate(-8px, 0)',
},
};
const pointerColor = theme === GrafanaTheme.Light ? '#3F444D' : '#8E8E8E';
let pointerStyles: React.CSSProperties = {
position: 'absolute',
left: '6px',
width: '0',
height: '0',
borderStyle: 'solid',
background: 'none',
};
let topArrowStyles: React.CSSProperties = {
top: '-7px',
borderWidth: '6px 3px 0px 3px',
borderColor: `${pointerColor} transparent transparent transparent`,
};
let bottomArrowStyles: React.CSSProperties = {
bottom: '-7px',
borderWidth: '0px 3px 6px 3px',
borderColor: ` transparent transparent ${pointerColor} transparent`,
};
if (direction === 'vertical') {
pointerStyles = {
...pointerStyles,
left: 'auto',
};
topArrowStyles = {
borderWidth: '3px 0px 3px 6px',
borderColor: `transparent transparent transparent ${pointerColor}`,
left: '-7px',
top: '7px',
};
bottomArrowStyles = {
borderWidth: '3px 6px 3px 0px',
borderColor: `transparent ${pointerColor} transparent transparent`,
right: '-7px',
top: '7px',
};
}
return (
<div style={styles.picker}>
<div
style={{
...pointerStyles,
...topArrowStyles,
}}
/>
<div
style={{
...pointerStyles,
...bottomArrowStyles,
}}
/>
</div>
);
};
export default SpectrumPalettePointer;

View File

@ -1,72 +0,0 @@
import React from 'react';
import _ from 'lodash';
import $ from 'jquery';
import '../../vendor/spectrum';
export interface Props {
color: string;
options: object;
onColorSelect: (c: string) => void;
}
export class SpectrumPicker extends React.Component<Props, any> {
elem: any;
isMoving: boolean;
constructor(props: Props) {
super(props);
this.onSpectrumMove = this.onSpectrumMove.bind(this);
this.setComponentElem = this.setComponentElem.bind(this);
}
setComponentElem(elem: any) {
this.elem = $(elem);
}
onSpectrumMove(color: any) {
this.isMoving = true;
this.props.onColorSelect(color);
}
componentDidMount() {
const spectrumOptions = _.assignIn(
{
flat: true,
showAlpha: true,
showButtons: false,
color: this.props.color,
appendTo: this.elem,
move: this.onSpectrumMove,
},
this.props.options
);
this.elem.spectrum(spectrumOptions);
this.elem.spectrum('show');
this.elem.spectrum('set', this.props.color);
}
componentWillUpdate(nextProps: any) {
// If user move pointer over spectrum field this produce 'move' event and component
// may update props.color. We don't want to update spectrum color in this case, so we can use
// isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which
// is called after updating occurs (when user finished moving).
if (!this.isMoving) {
this.elem.spectrum('set', nextProps.color);
}
}
componentDidUpdate() {
if (this.isMoving) {
this.isMoving = false;
}
}
componentWillUnmount() {
this.elem.spectrum('destroy');
}
render() {
return <div className="spectrum-container" ref={this.setComponentElem} />;
}
}

View File

@ -1,8 +1,172 @@
$arrowSize: 15px;
.ColorPicker {
@extend .popper;
font-size: 12px;
}
.ColorPicker__arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 0px;
&[data-placement^='top'] {
border-width: $arrowSize $arrowSize 0 $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
bottom: -$arrowSize;
left: calc(50%-#{$arrowSize});
padding-top: $arrowSize;
}
&[data-placement^='bottom'] {
border-width: 0 $arrowSize $arrowSize $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
top: 0;
left: calc(50%-#{$arrowSize});
}
&[data-placement^='bottom-start'] {
border-width: 0 $arrowSize $arrowSize $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
top: 0;
left: $arrowSize;
}
&[data-placement^='bottom-end'] & {
border-width: 0 $arrowSize $arrowSize $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
top: 0;
left: calc(100% -$arrowSize);
}
&[data-placement^='right'] {
border-width: $arrowSize $arrowSize $arrowSize 0;
border-left-color: transparent;
border-top-color: transparent;
border-bottom-color: transparent;
left: 0;
top: calc(50%-#{$arrowSize});
}
&[data-placement^='left'] {
border-width: $arrowSize 0 $arrowSize $arrowSize;
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
right: -$arrowSize;
top: calc(50%-#{$arrowSize});
}
}
.ColorPicker__arrow--light {
border-color: #ffffff;
}
.ColorPicker__arrow--dark {
border-color: #1e2028;
}
// Top
.ColorPicker[data-placement^='top'] {
padding-bottom: $arrowSize;
}
// Bottom
.ColorPicker[data-placement^='bottom'] {
padding-top: $arrowSize;
}
.ColorPicker[data-placement^='bottom-start'] {
padding-top: $arrowSize;
}
.ColorPicker[data-placement^='bottom-end'] {
padding-top: $arrowSize;
}
// Right
.ColorPicker[data-placement^='right'] {
padding-left: $arrowSize;
}
// Left
.ColorPicker[data-placement^='left'] {
padding-right: $arrowSize;
}
.ColorPickerPopover {
border-radius: 3px;
}
.ColorPickerPopover--light {
color: black;
background: linear-gradient(180deg, #ffffff 0%, #f7f8fa 104.25%);
box-shadow: 0px 2px 4px #dde4ed, 0px 0px 2px #dde4ed;
}
.ColorPickerPopover--dark {
color: #d8d9da;
background: linear-gradient(180deg, #1e2028 0%, #161719 104.25%);
box-shadow: 0px 2px 4px #000000, 0px 0px 2px #000000;
.ColorPickerPopover__tab {
background: #303133;
color: white;
cursor: pointer;
}
.ColorPickerPopover__tab--active {
background: none;
}
}
.ColorPickerPopover__content {
width: 336px;
min-height: 184px;
padding: 24px;
}
.ColorPickerPopover__tabs {
display: flex;
width: 100%;
border-radius: 3px 3px 0 0;
overflow: hidden;
}
.ColorPickerPopover__tab {
width: 50%;
text-align: center;
padding: 8px 0;
background: #dde4ed;
}
.ColorPickerPopover__tab--active {
background: white;
}
.ColorPicker__axisSwitch {
width: 100%;
}
.ColorPicker__axisSwitchLabel {
display: flex;
flex-grow: 1;
}
.sp-replacer { .sp-replacer {
background: inherit; background: inherit;
border: none; border: none;
color: inherit; color: inherit;
padding: 0; padding: 0;
border-radius: 10px;
} }
.sp-replacer:hover, .sp-replacer:hover,
@ -35,10 +199,22 @@
margin: 0; margin: 0;
float: left; float: left;
z-index: 0; z-index: 0;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
.sp-preview-inner,
.sp-alpha-inner,
.sp-thumb-inner {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
} }
.gf-color-picker__body { .gf-color-picker__body {
padding-bottom: 10px; padding-bottom: $arrowSize;
padding-left: 6px; padding-left: 6px;
} }
@ -47,3 +223,18 @@
width: 210px; width: 210px;
} }
} }
// TODO: Remove. This is a temporary solution until color picker popovers are used
// with Drop.js.
.drop-popover.drop-popover--transparent {
.drop-content {
border: none;
background: none;
padding: 0;
max-width: none;
&:before {
display: none;
}
}
}

View File

@ -7,10 +7,12 @@ interface Props {
autoHide?: boolean; autoHide?: boolean;
autoHideTimeout?: number; autoHideTimeout?: number;
autoHideDuration?: number; autoHideDuration?: number;
autoMaxHeight?: string; autoHeightMax?: string;
hideTracksWhenNotNeeded?: boolean; hideTracksWhenNotNeeded?: boolean;
renderTrackHorizontal?: React.FunctionComponent<any>;
renderTrackVertical?: React.FunctionComponent<any>;
scrollTop?: number; scrollTop?: number;
setScrollTop: (value: React.MouseEvent<HTMLElement>) => void; setScrollTop: (event: any) => void;
autoHeightMin?: number | string; autoHeightMin?: number | string;
} }
@ -20,13 +22,13 @@ interface Props {
export class CustomScrollbar extends PureComponent<Props> { export class CustomScrollbar extends PureComponent<Props> {
static defaultProps: Partial<Props> = { static defaultProps: Partial<Props> = {
customClassName: 'custom-scrollbars', customClassName: 'custom-scrollbars',
autoHide: true, autoHide: false,
autoHideTimeout: 200, autoHideTimeout: 200,
autoHideDuration: 200, autoHideDuration: 200,
autoMaxHeight: '100%',
hideTracksWhenNotNeeded: false,
setScrollTop: () => {}, setScrollTop: () => {},
autoHeightMin: '0' hideTracksWhenNotNeeded: false,
autoHeightMin: '0',
autoHeightMax: '100%',
}; };
private ref: React.RefObject<Scrollbars>; private ref: React.RefObject<Scrollbars>;
@ -45,7 +47,7 @@ export class CustomScrollbar extends PureComponent<Props> {
} else { } else {
ref.scrollTop(this.props.scrollTop); ref.scrollTop(this.props.scrollTop);
} }
} }
} }
componentDidMount() { componentDidMount() {
@ -57,18 +59,34 @@ export class CustomScrollbar extends PureComponent<Props> {
} }
render() { render() {
const { customClassName, children, autoMaxHeight } = this.props; const {
customClassName,
children,
autoHeightMax,
autoHeightMin,
setScrollTop,
autoHide,
autoHideTimeout,
hideTracksWhenNotNeeded,
renderTrackHorizontal,
renderTrackVertical,
} = this.props;
return ( return (
<Scrollbars <Scrollbars
ref={this.ref} ref={this.ref}
className={customClassName} className={customClassName}
onScroll={setScrollTop}
autoHeight={true} autoHeight={true}
autoHide={autoHide}
autoHideTimeout={autoHideTimeout}
hideTracksWhenNotNeeded={hideTracksWhenNotNeeded}
// These autoHeightMin & autoHeightMax options affect firefox and chrome differently. // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
// Before these where set to inhert but that caused problems with cut of legends in firefox // Before these where set to inhert but that caused problems with cut of legends in firefox
autoHeightMax={autoMaxHeight} autoHeightMax={autoHeightMax}
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />} autoHeightMin={autoHeightMin}
renderTrackVertical={props => <div {...props} className="track-vertical" />} renderTrackHorizontal={renderTrackHorizontal || (props => <div {...props} className="track-horizontal" />)}
renderTrackVertical={renderTrackVertical || (props => <div {...props} className="track-vertical" />)}
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />} renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
renderThumbVertical={props => <div {...props} className="thumb-vertical" />} renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
renderView={props => <div {...props} className="view" />} renderView={props => <div {...props} className="view" />}

View File

@ -7,7 +7,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
Object { Object {
"height": "auto", "height": "auto",
"maxHeight": "100%", "maxHeight": "100%",
"minHeight": 0, "minHeight": "0",
"overflow": "hidden", "overflow": "hidden",
"position": "relative", "position": "relative",
"width": "100%", "width": "100%",
@ -24,7 +24,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
"marginBottom": 0, "marginBottom": 0,
"marginRight": 0, "marginRight": 0,
"maxHeight": "calc(100% + 0px)", "maxHeight": "calc(100% + 0px)",
"minHeight": 0, "minHeight": "calc(0 + 0px)",
"overflow": "scroll", "overflow": "scroll",
"position": "relative", "position": "relative",
"right": undefined, "right": undefined,

View File

@ -0,0 +1,24 @@
import React, { FunctionComponent } from 'react';
import { storiesOf } from '@storybook/react';
import { DeleteButton } from '@grafana/ui';
const CenteredStory: FunctionComponent<{}> = ({ children }) => {
return (
<div
style={{
height: '100vh ',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
</div>
);
};
storiesOf('UI/DeleteButton', module)
.addDecorator(story => <CenteredStory>{story()}</CenteredStory>)
.add('default', () => {
return <DeleteButton onConfirm={() => {}} />;
});

View File

@ -1,10 +1,11 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import $ from 'jquery'; import $ from 'jquery';
import { BasicGaugeColor, ThemeName, ThemeNames, Threshold, ValueMapping } from '../../types'; import { ValueMapping, Threshold, BasicGaugeColor, TimeSeriesVMs, GrafanaTheme } from '../../types';
import { TimeSeriesVMs } from '../../types'; import { getMappedValue } from '../../utils/valueMappings';
import { getValueFormat } from '../../utils'; import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
import { getMappedValue, TimeSeriesValue } from '../../utils/valueMappings';
type TimeSeriesValue = string | number | null;
export interface Props { export interface Props {
decimals: number; decimals: number;
@ -21,7 +22,7 @@ export interface Props {
suffix: string; suffix: string;
unit: string; unit: string;
width: number; width: number;
theme?: ThemeName; theme?: GrafanaTheme;
} }
const FONT_SCALE = 1; const FONT_SCALE = 1;
@ -40,7 +41,7 @@ export class Gauge extends PureComponent<Props> {
thresholds: [], thresholds: [],
unit: 'none', unit: 'none',
stat: 'avg', stat: 'avg',
theme: ThemeNames.Dark, theme: GrafanaTheme.Dark,
}; };
componentDidMount() { componentDidMount() {
@ -73,29 +74,29 @@ export class Gauge extends PureComponent<Props> {
} }
getFontColor(value: TimeSeriesValue) { getFontColor(value: TimeSeriesValue) {
const { thresholds } = this.props; const { thresholds, theme } = this.props;
if (thresholds.length === 1) { if (thresholds.length === 1) {
return thresholds[0].color; return getColorFromHexRgbOrName(thresholds[0].color, theme);
} }
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 atThreshold.color; return getColorFromHexRgbOrName(atThreshold.color, theme);
} }
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 nearestThreshold.color; return getColorFromHexRgbOrName(nearestThreshold.color, theme);
} }
return BasicGaugeColor.Red; return BasicGaugeColor.Red;
} }
getFormattedThresholds() { getFormattedThresholds() {
const { maxValue, minValue, thresholds } = this.props; const { maxValue, minValue, thresholds, theme } = this.props;
const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index); const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1]; const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
@ -103,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: threshold.color }; return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme) };
} }
const previousThreshold = thresholdsSortedByIndex[threshold.index - 1]; const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
return { value: threshold.value, color: previousThreshold.color }; return { value: threshold.value, color: getColorFromHexRgbOrName(previousThreshold.color, theme) };
}), }),
{ value: maxValue, color: lastThreshold.color }, { value: maxValue, color: getColorFromHexRgbOrName(lastThreshold.color, theme) },
]; ];
} }
@ -143,7 +144,7 @@ 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 === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)'; const backgroundColor = theme === GrafanaTheme.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
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

@ -61,7 +61,7 @@ interface AsyncProps {
export const MenuList = (props: any) => { export const MenuList = (props: any) => {
return ( return (
<components.MenuList {...props}> <components.MenuList {...props}>
<CustomScrollbar autoHide={false} autoMaxHeight="inherit">{props.children}</CustomScrollbar> <CustomScrollbar autoHide={false} autoHeightMax="inherit">{props.children}</CustomScrollbar>
</components.MenuList> </components.MenuList>
); );
}; };

View File

@ -3,6 +3,7 @@ import renderer from 'react-test-renderer';
import SelectOption from './SelectOption'; import SelectOption from './SelectOption';
import { OptionProps } from 'react-select/lib/components/Option'; import { OptionProps } from 'react-select/lib/components/Option';
// @ts-ignore
const model: OptionProps<any> = { const model: OptionProps<any> = {
data: jest.fn(), data: jest.fn(),
cx: jest.fn(), cx: jest.fn(),

View File

@ -4,10 +4,11 @@ import _ from 'lodash';
export interface Props { export interface Props {
label: string; label: string;
checked: boolean; checked: boolean;
className?: string;
labelClass?: string; labelClass?: string;
switchClass?: string; switchClass?: string;
transparent?: boolean; transparent?: boolean;
onChange: (event) => any; onChange: (event?: React.SyntheticEvent<HTMLInputElement>) => void;
} }
export interface State { export interface State {
@ -19,20 +20,21 @@ export class Switch extends PureComponent<Props, State> {
id: _.uniqueId(), id: _.uniqueId(),
}; };
internalOnChange = event => { internalOnChange = (event: React.FormEvent<HTMLInputElement>) => {
event.stopPropagation(); event.stopPropagation();
this.props.onChange(event);
this.props.onChange();
}; };
render() { render() {
const { labelClass = '', switchClass = '', label, checked, transparent } = this.props; const { labelClass = '', switchClass = '', label, checked, transparent, className } = this.props;
const labelId = `check-${this.state.id}`; const labelId = `check-${this.state.id}`;
const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`; const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`;
const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`; const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`;
return ( return (
<label htmlFor={labelId} className="gf-form gf-form-switch-container"> <label htmlFor={labelId} className={`gf-form gf-form-switch-container ${className}`}>
{label && <div className={labelClassName}>{label}</div>} {label && <div className={labelClassName}>{label}</div>}
<div className={switchClassName}> <div className={switchClassName}>
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} /> <input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />

View File

@ -1,12 +1,11 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
// import tinycolor, { ColorInput } from 'tinycolor2'; 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';
export interface Props { export interface Props extends Themeable {
thresholds: Threshold[]; thresholds: Threshold[];
onChange: (thresholds: Threshold[]) => void; onChange: (thresholds: Threshold[]) => void;
} }
@ -189,6 +188,7 @@ 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"> <PanelOptionsGroup title="Thresholds">
@ -199,7 +199,10 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
<div className="thresholds-row-add-button" onClick={() => this.onAddThreshold(threshold.index + 1)}> <div className="thresholds-row-add-button" onClick={() => this.onAddThreshold(threshold.index + 1)}>
<i className="fa fa-plus" /> <i className="fa fa-plus" />
</div> </div>
<div className="thresholds-row-color-indicator" style={{ backgroundColor: threshold.color }} /> <div
className="thresholds-row-color-indicator"
style={{ backgroundColor: getColorFromHexRgbOrName(threshold.color, theme) }}
/>
<div className="thresholds-row-input">{this.renderInput(threshold)}</div> <div className="thresholds-row-input">{this.renderInput(threshold)}</div>
</div> </div>
); );

View File

@ -1,73 +1,88 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import * as PopperJS from 'popper.js'; import * as PopperJS from 'popper.js';
import { Manager, Popper as ReactPopper } from 'react-popper'; import { Manager, Popper as ReactPopper, PopperArrowProps } from 'react-popper';
import { Portal } from '@grafana/ui'; import { Portal } from '@grafana/ui';
import Transition from 'react-transition-group/Transition'; import Transition from 'react-transition-group/Transition';
import { PopperContent } from './PopperController';
export enum Themes {
Default = 'popper__background--default',
Error = 'popper__background--error',
Brand = 'popper__background--brand',
}
const defaultTransitionStyles = { const defaultTransitionStyles = {
transition: 'opacity 200ms linear', transition: 'opacity 200ms linear',
opacity: 0, opacity: 0,
}; };
const transitionStyles: {[key: string]: object} = { const transitionStyles: { [key: string]: object } = {
exited: { opacity: 0 }, exited: { opacity: 0 },
entering: { opacity: 0 }, entering: { opacity: 0 },
entered: { opacity: 1 }, entered: { opacity: 1, transitionDelay: '0s' },
exiting: { opacity: 0 }, exiting: { opacity: 0, transitionDelay: '500ms' },
}; };
interface Props extends React.DOMAttributes<HTMLDivElement> { export type RenderPopperArrowFn = (
renderContent: (content: any) => any; props: {
arrowProps: PopperArrowProps;
placement: string;
}
) => JSX.Element;
interface Props extends React.HTMLAttributes<HTMLDivElement> {
show: boolean; show: boolean;
placement?: PopperJS.Placement; placement?: PopperJS.Placement;
content: string | ((props: any) => JSX.Element); content: PopperContent<any>;
referenceElement: PopperJS.ReferenceObject; referenceElement: PopperJS.ReferenceObject;
theme?: Themes; wrapperClassName?: string;
renderArrow?: RenderPopperArrowFn;
} }
class Popper extends PureComponent<Props> { class Popper extends PureComponent<Props> {
render() { render() {
const { renderContent, show, placement, onMouseEnter, onMouseLeave, theme } = this.props; const { show, placement, onMouseEnter, onMouseLeave, className, wrapperClassName, renderArrow } = this.props;
const { content } = this.props; const { content } = this.props;
const popperBackgroundClassName = 'popper__background' + (theme ? ' ' + theme : '');
return ( return (
<Manager> <Manager>
<Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}> <Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
{transitionState => ( {transitionState => {
<Portal> return (
<ReactPopper placement={placement} referenceElement={this.props.referenceElement}> <Portal>
{({ ref, style, placement, arrowProps }) => { <ReactPopper
return ( placement={placement}
<div referenceElement={this.props.referenceElement}
onMouseEnter={onMouseEnter} // TODO: move modifiers config to popper controller
onMouseLeave={onMouseLeave} modifiers={{ preventOverflow: { enabled: true, boundariesElement: 'window' } }}
ref={ref} >
style={{ {({ ref, style, placement, arrowProps, scheduleUpdate }) => {
...style, return (
...defaultTransitionStyles, <div
...transitionStyles[transitionState], onMouseEnter={onMouseEnter}
}} onMouseLeave={onMouseLeave}
data-placement={placement} ref={ref}
className="popper" style={{
> ...style,
<div className={popperBackgroundClassName}> ...defaultTransitionStyles,
{renderContent(content)} ...transitionStyles[transitionState],
<div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" /> }}
data-placement={placement}
className={`${wrapperClassName}`}
>
<div className={className}>
{typeof content === 'string'
? content
: React.cloneElement(content, {
updatePopperPosition: scheduleUpdate,
})}
{renderArrow &&
renderArrow({
arrowProps,
placement,
})}
</div>
</div> </div>
</div> );
); }}
}} </ReactPopper>
</ReactPopper> </Portal>
</Portal> );
)} }}
</Transition> </Transition>
</Manager> </Manager>
); );

View File

@ -1,16 +1,19 @@
import React from 'react'; import React from 'react';
import * as PopperJS from 'popper.js'; import * as PopperJS from 'popper.js';
import { Themes } from './Popper';
type PopperContent = string | (() => JSX.Element); // This API allows popovers to update Popper's position when e.g. popover content changes
// updatePopperPosition is delivered to content by react-popper
export interface PopperContentProps {
updatePopperPosition?: () => void;
}
export type PopperContent<T extends PopperContentProps> = string | React.ReactElement<T>;
export interface UsingPopperProps { export interface UsingPopperProps {
show?: boolean; show?: boolean;
placement?: PopperJS.Placement; placement?: PopperJS.Placement;
content: PopperContent; content: PopperContent<any>;
children: JSX.Element; children: JSX.Element;
renderContent?: (content: PopperContent) => JSX.Element;
theme?: Themes;
} }
type PopperControllerRenderProp = ( type PopperControllerRenderProp = (
@ -19,18 +22,16 @@ type PopperControllerRenderProp = (
popperProps: { popperProps: {
show: boolean; show: boolean;
placement: PopperJS.Placement; placement: PopperJS.Placement;
content: string | ((props: any) => JSX.Element); content: PopperContent<any>;
renderContent: (content: any) => any;
theme?: Themes;
} }
) => JSX.Element; ) => JSX.Element;
interface Props { interface Props {
placement?: PopperJS.Placement; placement?: PopperJS.Placement;
content: PopperContent; content: PopperContent<any>;
className?: string; className?: string;
children: PopperControllerRenderProp; children: PopperControllerRenderProp;
theme?: Themes; hideAfter?: number;
} }
interface State { interface State {
@ -39,6 +40,8 @@ interface State {
} }
class PopperController extends React.Component<Props, State> { class PopperController extends React.Component<Props, State> {
private hideTimeout: any;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@ -60,6 +63,10 @@ class PopperController extends React.Component<Props, State> {
} }
showPopper = () => { showPopper = () => {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
this.setState(prevState => ({ this.setState(prevState => ({
...prevState, ...prevState,
show: true, show: true,
@ -67,31 +74,29 @@ class PopperController extends React.Component<Props, State> {
}; };
hidePopper = () => { hidePopper = () => {
if (this.props.hideAfter !== 0) {
this.hideTimeout = setTimeout(() => {
this.setState(prevState => ({
...prevState,
show: false,
}));
}, this.props.hideAfter);
return;
}
this.setState(prevState => ({ this.setState(prevState => ({
...prevState, ...prevState,
show: false, show: false,
})); }));
}; };
renderContent(content: PopperContent) {
if (typeof content === 'function') {
// If it's a function we assume it's a React component
const ReactComponent = content;
return <ReactComponent />;
}
return content;
}
render() { render() {
const { children, content, theme } = this.props; const { children, content } = this.props;
const { show, placement } = this.state; const { show, placement } = this.state;
return children(this.showPopper, this.hidePopper, { return children(this.showPopper, this.hidePopper, {
show, show,
placement, placement,
content, content,
renderContent: this.renderContent,
theme,
}); });
} }
} }

View File

@ -3,8 +3,18 @@ import * as PopperJS from 'popper.js';
import Popper from './Popper'; import Popper from './Popper';
import PopperController, { UsingPopperProps } from './PopperController'; import PopperController, { UsingPopperProps } from './PopperController';
export const Tooltip = ({ children, renderContent, ...controllerProps }: UsingPopperProps) => { export enum Themes {
Default = 'popper__background--default',
Error = 'popper__background--error',
Brand = 'popper__background--brand',
}
interface TooltipProps extends UsingPopperProps {
theme?: Themes;
}
export const Tooltip = ({ children, theme, ...controllerProps }: TooltipProps) => {
const tooltipTriggerRef = createRef<PopperJS.ReferenceObject>(); const tooltipTriggerRef = createRef<PopperJS.ReferenceObject>();
const popperBackgroundClassName = 'popper__background' + (theme ? ' ' + theme : '');
return ( return (
<PopperController {...controllerProps}> <PopperController {...controllerProps}>
@ -17,6 +27,11 @@ export const Tooltip = ({ children, renderContent, ...controllerProps }: UsingPo
onMouseEnter={showPopper} onMouseEnter={showPopper}
onMouseLeave={hidePopper} onMouseLeave={hidePopper}
referenceElement={tooltipTriggerRef.current} referenceElement={tooltipTriggerRef.current}
wrapperClassName='popper'
className={popperBackgroundClassName}
renderArrow={({ arrowProps, placement }) => (
<div className="popper__arrow" data-placement={placement} {...arrowProps} />
)}
/> />
)} )}
{React.cloneElement(children, { {React.cloneElement(children, {

View File

@ -1,6 +1,5 @@
$popper-margin-from-ref: 5px; $popper-margin-from-ref: 5px;
@mixin popper-theme($backgroundColor, $arrowColor) { @mixin popper-theme($backgroundColor, $arrowColor) {
background: $backgroundColor; background: $backgroundColor;
.popper__arrow { .popper__arrow {
@ -22,6 +21,10 @@ $popper-margin-from-ref: 5px;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5); box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
padding: 10px; padding: 10px;
.popper__arrow {
border-color: $tooltipBackground;
}
// Themes // Themes
&.popper__background--error { &.popper__background--error {
@include popper-theme($tooltipBackgroundError, $tooltipBackgroundError); @include popper-theme($tooltipBackgroundError, $tooltipBackgroundError);
@ -41,10 +44,6 @@ $popper-margin-from-ref: 5px;
margin: 0px; margin: 0px;
} }
.popper__arrow {
border-color: $tooltipBackground;
}
// Top // Top
.popper[data-placement^='top'] { .popper[data-placement^='top'] {
padding-bottom: $popper-margin-from-ref; padding-bottom: $popper-margin-from-ref;

View File

@ -14,12 +14,12 @@ 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 } from './ColorPicker/ColorPicker'; export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover'; export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
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';
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid'; export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor'; export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
export { Gauge } from './Gauge/Gauge'; export { Gauge } from './Gauge/Gauge';
export { Switch } from './Switch/Switch';

View File

@ -1,3 +1 @@
@import 'vendor/spectrum';
@import 'components/index'; @import 'components/index';

View File

@ -3,3 +3,12 @@ export * from './time';
export * from './panel'; export * from './panel';
export * from './plugin'; export * from './plugin';
export * from './datasource'; export * from './datasource';
export enum GrafanaTheme {
Light = 'light',
Dark = 'dark',
}
export interface Themeable {
theme?: GrafanaTheme;
}

View File

@ -36,7 +36,7 @@ export interface PanelMenuItem {
export interface Threshold { export interface Threshold {
index: number; index: number;
value: number; value: number;
color?: string; color: string;
} }
export enum BasicGaugeColor { export enum BasicGaugeColor {
@ -66,10 +66,3 @@ export interface RangeMap extends BaseMap {
from: string; from: string;
to: string; to: string;
} }
export type ThemeName = 'dark' | 'light';
export enum ThemeNames {
Dark = 'dark',
Light = 'light',
}

View File

@ -9,7 +9,6 @@ export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)';
export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)'; export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)';
export const PENDING_COLOR = 'rgba(247, 149, 32, 1)'; export const PENDING_COLOR = 'rgba(247, 149, 32, 1)';
export const REGION_FILL_ALPHA = 0.09; export const REGION_FILL_ALPHA = 0.09;
export const colors = [ export const colors = [
'#7EB26D', // 0: pale green '#7EB26D', // 0: pale green
'#EAB839', // 1: mustard '#EAB839', // 1: mustard

View File

@ -1,3 +1,4 @@
export * from './processTimeSeries'; export * from './processTimeSeries';
export * from './valueFormats/valueFormats'; export * from './valueFormats/valueFormats';
export * from './colors'; export * from './colors';
export * from './namedColorsPalette';

View File

@ -0,0 +1,66 @@
import {
getColorName,
getColorDefinition,
getColorByName,
getColorFromHexRgbOrName,
getColorDefinitionByName,
} from './namedColorsPalette';
import { GrafanaTheme } from '../types/index';
describe('colors', () => {
const SemiDarkBlue = getColorDefinitionByName('semi-dark-blue');
describe('getColorDefinition', () => {
it('returns undefined for unknown hex', () => {
expect(getColorDefinition('#ff0000', GrafanaTheme.Light)).toBeUndefined();
expect(getColorDefinition('#ff0000', GrafanaTheme.Dark)).toBeUndefined();
});
it('returns definition for known hex', () => {
expect(getColorDefinition(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue);
expect(getColorDefinition(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue);
});
});
describe('getColorName', () => {
it('returns undefined for unknown hex', () => {
expect(getColorName('#ff0000')).toBeUndefined();
});
it('returns name for known hex', () => {
expect(getColorName(SemiDarkBlue.variants.light, GrafanaTheme.Light)).toEqual(SemiDarkBlue.name);
expect(getColorName(SemiDarkBlue.variants.dark, GrafanaTheme.Dark)).toEqual(SemiDarkBlue.name);
});
});
describe('getColorByName', () => {
it('returns undefined for unknown color', () => {
expect(getColorByName('aruba-sunshine')).toBeUndefined();
});
it('returns color definiton for known color', () => {
expect(getColorByName(SemiDarkBlue.name)).toBe(SemiDarkBlue);
});
});
describe('getColorFromHexRgbOrName', () => {
it('returns undefined for unknown color', () => {
expect(() => getColorFromHexRgbOrName('aruba-sunshine')).toThrow();
});
it('returns dark hex variant for known color if theme not specified', () => {
expect(getColorFromHexRgbOrName(SemiDarkBlue.name)).toBe(SemiDarkBlue.variants.dark);
});
it("returns correct variant's hex for known color if theme specified", () => {
expect(getColorFromHexRgbOrName(SemiDarkBlue.name, GrafanaTheme.Light)).toBe(SemiDarkBlue.variants.light);
});
it('returns color if specified as hex or rgb/a', () => {
expect(getColorFromHexRgbOrName('ff0000')).toBe('ff0000');
expect(getColorFromHexRgbOrName('#ff0000')).toBe('#ff0000');
expect(getColorFromHexRgbOrName('rgb(0,0,0)')).toBe('rgb(0,0,0)');
expect(getColorFromHexRgbOrName('rgba(0,0,0,1)')).toBe('rgba(0,0,0,1)');
});
});
});

View File

@ -0,0 +1,182 @@
import { flatten } from 'lodash';
import { GrafanaTheme } from '../types';
type Hue = 'green' | 'yellow' | 'red' | 'blue' | 'orange' | 'purple';
export type Color =
| 'green'
| 'dark-green'
| 'semi-dark-green'
| 'light-green'
| 'super-light-green'
| 'yellow'
| 'dark-yellow'
| 'semi-dark-yellow'
| 'light-yellow'
| 'super-light-yellow'
| 'red'
| 'dark-red'
| 'semi-dark-red'
| 'light-red'
| 'super-light-red'
| 'blue'
| 'dark-blue'
| 'semi-dark-blue'
| 'light-blue'
| 'super-light-blue'
| 'orange'
| 'dark-orange'
| 'semi-dark-orange'
| 'light-orange'
| 'super-light-orange'
| 'purple'
| 'dark-purple'
| 'semi-dark-purple'
| 'light-purple'
| 'super-light-purple';
type ThemeVariants = {
dark: string;
light: string;
};
export type ColorDefinition = {
hue: Hue;
isPrimary?: boolean;
name: Color;
variants: ThemeVariants;
};
let colorsPaletteInstance: Map<Hue, ColorDefinition[]>;
const buildColorDefinition = (
hue: Hue,
name: Color,
[light, dark]: string[],
isPrimary?: boolean
): ColorDefinition => ({
hue,
name,
variants: {
light,
dark,
},
isPrimary: !!isPrimary,
});
export const getColorDefinitionByName = (name: Color): ColorDefinition => {
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.name === name)[0];
};
export const getColorDefinition = (hex: string, theme: GrafanaTheme): ColorDefinition | undefined => {
return flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.variants[theme] === hex)[0];
};
const isHex = (color: string) => {
const hexRegex = /^((0x){0,1}|#{0,1})([0-9A-F]{8}|[0-9A-F]{6})$/gi;
return hexRegex.test(color);
};
export const getColorName = (color?: string, theme?: GrafanaTheme): Color | undefined => {
if (!color) {
return undefined;
}
if (color.indexOf('rgb') > -1) {
return undefined;
}
if (isHex(color)) {
const definition = getColorDefinition(color, theme || GrafanaTheme.Dark);
return definition ? definition.name : undefined;
}
return color as Color;
};
export const getColorByName = (colorName: string) => {
const definition = flatten(Array.from(getNamedColorPalette().values())).filter(definition => definition.name === colorName);
return definition.length > 0 ? definition[0] : undefined;
};
export const getColorFromHexRgbOrName = (color: string, theme?: GrafanaTheme): string => {
if (color.indexOf('rgb') > -1 || isHex(color)) {
return color;
}
const colorDefinition = getColorByName(color);
if (!colorDefinition) {
throw new Error('Unknown color');
}
return theme ? colorDefinition.variants[theme] : colorDefinition.variants.dark;
};
export const getColorForTheme = (color: ColorDefinition, theme?: GrafanaTheme) => {
return theme ? color.variants[theme] : color.variants.dark;
};
const buildNamedColorsPalette = () => {
const palette = new Map<Hue, ColorDefinition[]>();
const BasicGreen = buildColorDefinition('green', 'green', ['#56A64B', '#73BF69'], true);
const DarkGreen = buildColorDefinition('green', 'dark-green', ['#19730E', '#37872D']);
const SemiDarkGreen = buildColorDefinition('green', 'semi-dark-green', ['#37872D', '#56A64B']);
const LightGreen = buildColorDefinition('green', 'light-green', ['#73BF69', '#96D98D']);
const SuperLightGreen = buildColorDefinition('green', 'super-light-green', ['#96D98D', '#C8F2C2']);
const BasicYellow = buildColorDefinition('yellow', 'yellow', ['#F2CC0C', '#FADE2A'], true);
const DarkYellow = buildColorDefinition('yellow', 'dark-yellow', ['#CC9D00', '#E0B400']);
const SemiDarkYellow = buildColorDefinition('yellow', 'semi-dark-yellow', ['#E0B400', '#F2CC0C']);
const LightYellow = buildColorDefinition('yellow', 'light-yellow', ['#FADE2A', '#FFEE52']);
const SuperLightYellow = buildColorDefinition('yellow', 'super-light-yellow', ['#FFEE52', '#FFF899']);
const BasicRed = buildColorDefinition('red', 'red', ['#E02F44', '#F2495C'], true);
const DarkRed = buildColorDefinition('red', 'dark-red', ['#AD0317', '#C4162A']);
const SemiDarkRed = buildColorDefinition('red', 'semi-dark-red', ['#C4162A', '#E02F44']);
const LightRed = buildColorDefinition('red', 'light-red', ['#F2495C', '#FF7383']);
const SuperLightRed = buildColorDefinition('red', 'super-light-red', ['#FF7383', '#FFA6B0']);
const BasicBlue = buildColorDefinition('blue', 'blue', ['#3274D9', '#5794F2'], true);
const DarkBlue = buildColorDefinition('blue', 'dark-blue', ['#1250B0', '#1F60C4']);
const SemiDarkBlue = buildColorDefinition('blue', 'semi-dark-blue', ['#1F60C4', '#3274D9']);
const LightBlue = buildColorDefinition('blue', 'light-blue', ['#5794F2', '#8AB8FF']);
const SuperLightBlue = buildColorDefinition('blue', 'super-light-blue', ['#8AB8FF', '#C0D8FF']);
const BasicOrange = buildColorDefinition('orange', 'orange', ['#FF780A', '#FF9830'], true);
const DarkOrange = buildColorDefinition('orange', 'dark-orange', ['#E55400', '#FA6400']);
const SemiDarkOrange = buildColorDefinition('orange', 'semi-dark-orange', ['#FA6400', '#FF780A']);
const LightOrange = buildColorDefinition('orange', 'light-orange', ['#FF9830', '#FFB357']);
const SuperLightOrange = buildColorDefinition('orange', 'super-light-orange', ['#FFB357', '#FFCB7D']);
const BasicPurple = buildColorDefinition('purple', 'purple', ['#A352CC', '#B877D9'], true);
const DarkPurple = buildColorDefinition('purple', 'dark-purple', ['#7C2EA3', '#8F3BB8']);
const SemiDarkPurple = buildColorDefinition('purple', 'semi-dark-purple', ['#8F3BB8', '#A352CC']);
const LightPurple = buildColorDefinition('purple', 'light-purple', ['#B877D9', '#CA95E5']);
const SuperLightPurple = buildColorDefinition('purple', 'super-light-purple', ['#CA95E5', '#DEB6F2']);
const greens = [BasicGreen, DarkGreen, SemiDarkGreen, LightGreen, SuperLightGreen];
const yellows = [BasicYellow, DarkYellow, SemiDarkYellow, LightYellow, SuperLightYellow];
const reds = [BasicRed, DarkRed, SemiDarkRed, LightRed, SuperLightRed];
const blues = [BasicBlue, DarkBlue, SemiDarkBlue, LightBlue, SuperLightBlue];
const oranges = [BasicOrange, DarkOrange, SemiDarkOrange, LightOrange, SuperLightOrange];
const purples = [BasicPurple, DarkPurple, SemiDarkPurple, LightPurple, SuperLightPurple];
palette.set('green', greens);
palette.set('yellow', yellows);
palette.set('red', reds);
palette.set('blue', blues);
palette.set('orange', oranges);
palette.set('purple', purples);
return palette;
};
export const getNamedColorPalette = () => {
if (colorsPaletteInstance) {
return colorsPaletteInstance;
}
colorsPaletteInstance = buildNamedColorsPalette();
return colorsPaletteInstance;
};

View File

@ -0,0 +1,6 @@
const propDeprecationWarning = (componentName: string, propName: string, newPropName: string) => {
const message = `[Deprecation warning] ${componentName}: ${propName} is deprecated. Use ${newPropName} instead`;
console.warn(message);
};
export default propDeprecationWarning;

View File

@ -0,0 +1,38 @@
import React from 'react';
interface StateHolderProps<T> {
initialState: T;
children: (currentState: T, updateState: (nextState: T) => void) => JSX.Element;
}
export class UseState<T> extends React.Component<StateHolderProps<T>, { value: T; initialState: T }> {
constructor(props: StateHolderProps<T>) {
super(props);
this.state = {
value: props.initialState,
initialState: props.initialState, // To enable control from knobs
};
}
// @ts-ignore
static getDerivedStateFromProps(props: StateHolderProps<{}>, state: { value: any; initialState: any }) {
if (props.initialState !== state.initialState) {
return {
initialState: props.initialState,
value: props.initialState,
};
}
return {
...state,
value: state.value,
};
}
handleStateUpdate = (nextState: T) => {
console.log(nextState);
this.setState({ value: nextState });
};
render() {
return this.props.children(this.state.value, this.handleStateUpdate);
}
}

View File

@ -0,0 +1,14 @@
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,19 @@
import React from 'react';
import { RenderFunction } from '@storybook/react';
const CenteredStory: React.FunctionComponent<{}> = ({ children }) => {
return (
<div
style={{
height: '100vh ',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
</div>
);
};
export const withCenteredStory = (story: RenderFunction) => <CenteredStory>{story()}</CenteredStory>;

View File

@ -1,509 +0,0 @@
/***
Spectrum Colorpicker v1.3.0
https://github.com/bgrins/spectrum
Author: Brian Grinstead
License: MIT
***/
.sp-container {
position:absolute;
top:0;
left:0;
display:inline-block;
*display: inline;
*zoom: 1;
/* https://github.com/bgrins/spectrum/issues/40 */
z-index: 9999994;
overflow: hidden;
}
.sp-container.sp-flat {
position: relative;
}
/* Fix for * { box-sizing: border-box; } */
.sp-container,
.sp-container * {
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
}
/* http://ansciath.tumblr.com/post/7347495869/css-aspect-ratio */
.sp-top {
position:relative;
width: 100%;
display:inline-block;
}
.sp-top-inner {
position:absolute;
top:0;
left:0;
bottom:0;
right:0;
}
.sp-color {
position: absolute;
top:0;
left:0;
bottom:0;
right:20%;
}
.sp-hue {
position: absolute;
top:0;
right:0;
bottom:0;
left:84%;
height: 100%;
}
.sp-clear-enabled .sp-hue {
top:33px;
height: 77.5%;
}
.sp-fill {
padding-top: 80%;
}
.sp-sat, .sp-val {
position: absolute;
top:0;
left:0;
right:0;
bottom:0;
}
.sp-alpha-enabled .sp-top {
margin-bottom: 18px;
}
.sp-alpha-enabled .sp-alpha {
display: block;
}
.sp-alpha-handle {
position:absolute;
top:-4px;
bottom: -4px;
width: 6px;
left: 50%;
cursor: pointer;
border: 1px solid black;
background: white;
opacity: .8;
}
.sp-alpha {
display: none;
position: absolute;
bottom: -14px;
right: 0;
left: 0;
height: 8px;
}
.sp-alpha-inner {
border: solid 1px #333;
}
.sp-clear {
display: none;
}
.sp-clear.sp-clear-display {
background-position: center;
}
.sp-clear-enabled .sp-clear {
display: block;
position:absolute;
top:0px;
right:0;
bottom:0;
left:84%;
height: 28px;
}
/* Don't allow text selection */
.sp-container, .sp-replacer, .sp-preview, .sp-dragger, .sp-slider, .sp-alpha, .sp-clear, .sp-alpha-handle, .sp-container.sp-dragging .sp-input, .sp-container button {
-webkit-user-select:none;
-moz-user-select: -moz-none;
-o-user-select:none;
user-select: none;
}
.sp-container.sp-input-disabled .sp-input-container {
display: none;
}
.sp-container.sp-buttons-disabled .sp-button-container {
display: none;
}
.sp-palette-only .sp-picker-container {
display: none;
}
.sp-palette-disabled .sp-palette-container {
display: none;
}
.sp-initial-disabled .sp-initial {
display: none;
}
/* Gradients for hue, saturation and value instead of images. Not pretty... but it works */
.sp-sat {
background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0));
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#FFFFFFFF, endColorstr=#00CC9A81)";
filter : progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr='#FFFFFFFF', endColorstr='#00CC9A81');
}
.sp-val {
background-image: linear-gradient(to top, #000, rgba(204, 154, 129, 0));
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#00CC9A81, endColorstr=#FF000000)";
filter : progid:DXImageTransform.Microsoft.gradient(startColorstr='#00CC9A81', endColorstr='#FF000000');
}
.sp-hue {
background: -moz-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
background: -ms-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
background: -o-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
background: -webkit-gradient(linear, left top, left bottom, from(#ff0000), color-stop(0.17, #ffff00), color-stop(0.33, #00ff00), color-stop(0.5, #00ffff), color-stop(0.67, #0000ff), color-stop(0.83, #ff00ff), to(#ff0000));
background: -webkit-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%);
}
/* IE filters do not support multiple color stops.
Generate 6 divs, line them up, and do two color gradients for each.
Yes, really.
*/
.sp-1 {
height:17%;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0000', endColorstr='#ffff00');
}
.sp-2 {
height:16%;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffff00', endColorstr='#00ff00');
}
.sp-3 {
height:17%;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ff00', endColorstr='#00ffff');
}
.sp-4 {
height:17%;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffff', endColorstr='#0000ff');
}
.sp-5 {
height:16%;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0000ff', endColorstr='#ff00ff');
}
.sp-6 {
height:17%;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff00ff', endColorstr='#ff0000');
}
.sp-hidden {
display: none !important;
}
/* Clearfix hack */
.sp-cf:before, .sp-cf:after { content: ""; display: table; }
.sp-cf:after { clear: both; }
.sp-cf { *zoom: 1; }
/* Mobile devices, make hue slider bigger so it is easier to slide */
@media (max-device-width: 480px) {
.sp-color { right: 40%; }
.sp-hue { left: 63%; }
.sp-fill { padding-top: 60%; }
}
.sp-dragger {
border-radius: 5px;
height: 5px;
width: 5px;
border: 1px solid #fff;
background: #000;
cursor: pointer;
position:absolute;
top:0;
left: 0;
}
.sp-slider {
position: absolute;
top:0;
cursor:pointer;
height: 3px;
left: -1px;
right: -1px;
border: 1px solid #000;
background: white;
opacity: .8;
}
/*
Theme authors:
Here are the basic themeable display options (colors, fonts, global widths).
See http://bgrins.github.io/spectrum/themes/ for instructions.
*/
.sp-container {
border-radius: 0;
background-color: #ECECEC;
border: solid 1px #f0c49B;
padding: 0;
}
.sp-container, .sp-container button, .sp-container input, .sp-color, .sp-hue, .sp-clear
{
font: normal 12px "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
-ms-box-sizing: border-box;
box-sizing: border-box;
}
.sp-top
{
margin-bottom: 3px;
}
.sp-color, .sp-hue, .sp-clear
{
border: solid 1px #666;
}
/* Input */
.sp-input-container {
float:right;
width: 100px;
margin-bottom: 4px;
}
.sp-initial-disabled .sp-input-container {
width: 100%;
}
.sp-input {
font-size: 12px !important;
border: 1px inset;
padding: 4px 5px;
margin: 0;
width: 100%;
background:transparent;
border-radius: 3px;
color: #222;
}
.sp-input:focus {
border: 1px solid orange;
}
.sp-input.sp-validation-error
{
border: 1px solid red;
background: #fdd;
}
.sp-picker-container , .sp-palette-container
{
float:left;
position: relative;
padding: 10px;
padding-bottom: 300px;
margin-bottom: -290px;
}
.sp-picker-container
{
width: 172px;
border-left: solid 1px #fff;
}
/* Palettes */
.sp-palette-container
{
border-right: solid 1px #ccc;
}
.sp-palette .sp-thumb-el {
display: block;
position:relative;
float:left;
width: 24px;
height: 15px;
margin: 3px;
cursor: pointer;
border:solid 2px transparent;
}
.sp-palette .sp-thumb-el:hover, .sp-palette .sp-thumb-el.sp-thumb-active {
border-color: orange;
}
.sp-thumb-el
{
position:relative;
}
/* Initial */
.sp-initial
{
float: left;
border: solid 1px #333;
}
.sp-initial span {
width: 30px;
height: 25px;
border:none;
display:block;
float:left;
margin:0;
}
.sp-initial .sp-clear-display {
background-position: center;
}
/* Buttons */
.sp-button-container {
float: right;
}
/* Replacer (the little preview div that shows up instead of the <input>) */
.sp-replacer {
margin:0;
overflow:hidden;
cursor:pointer;
padding: 4px;
display:inline-block;
*zoom: 1;
*display: inline;
border: solid 1px #91765d;
background: #eee;
color: #333;
vertical-align: middle;
}
.sp-replacer:hover, .sp-replacer.sp-active {
border-color: #F0C49B;
color: #111;
}
.sp-replacer.sp-disabled {
cursor:default;
border-color: silver;
color: silver;
}
.sp-dd {
padding: 2px 0;
height: 16px;
line-height: 16px;
float:left;
font-size:10px;
}
.sp-preview
{
position:relative;
width:25px;
height: 20px;
border: solid 1px #222;
margin-right: 5px;
float:left;
z-index: 0;
}
.sp-palette
{
*width: 220px;
max-width: 220px;
}
.sp-palette .sp-thumb-el
{
width:16px;
height: 16px;
margin:2px 1px;
border: solid 1px #d0d0d0;
}
.sp-container
{
padding-bottom:0;
}
/* Buttons: http://hellohappy.org/css3-buttons/ */
.sp-container button {
background-color: #eeeeee;
background-image: -webkit-linear-gradient(top, #eeeeee, #cccccc);
background-image: -moz-linear-gradient(top, #eeeeee, #cccccc);
background-image: -ms-linear-gradient(top, #eeeeee, #cccccc);
background-image: -o-linear-gradient(top, #eeeeee, #cccccc);
background-image: linear-gradient(to bottom, #eeeeee, #cccccc);
border: 1px solid #ccc;
border-bottom: 1px solid #bbb;
border-radius: 3px;
color: #333;
font-size: 14px;
line-height: 1;
padding: 5px 4px;
text-align: center;
text-shadow: 0 1px 0 #eee;
vertical-align: middle;
}
.sp-container button:hover {
background-color: #dddddd;
background-image: -webkit-linear-gradient(top, #dddddd, #bbbbbb);
background-image: -moz-linear-gradient(top, #dddddd, #bbbbbb);
background-image: -ms-linear-gradient(top, #dddddd, #bbbbbb);
background-image: -o-linear-gradient(top, #dddddd, #bbbbbb);
background-image: linear-gradient(to bottom, #dddddd, #bbbbbb);
border: 1px solid #bbb;
border-bottom: 1px solid #999;
cursor: pointer;
text-shadow: 0 1px 0 #ddd;
}
.sp-container button:active {
border: 1px solid #aaa;
border-bottom: 1px solid #888;
-webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
-moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
-ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
-o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee;
}
.sp-cancel
{
font-size: 11px;
color: #d93f3f !important;
margin:0;
padding:2px;
margin-right: 5px;
vertical-align: middle;
text-decoration:none;
}
.sp-cancel:hover
{
color: #d93f3f !important;
text-decoration: underline;
}
.sp-palette span:hover, .sp-palette span.sp-thumb-active
{
border-color: #000;
}
.sp-preview, .sp-alpha, .sp-thumb-el
{
position:relative;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
.sp-preview-inner, .sp-alpha-inner, .sp-thumb-inner
{
display:block;
position:absolute;
top:0;left:0;bottom:0;right:0;
}
.sp-palette .sp-thumb-inner
{
background-position: 50% 50%;
background-repeat: no-repeat;
}
.sp-palette .sp-thumb-light.sp-thumb-active .sp-thumb-inner
{
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=);
}
.sp-palette .sp-thumb-dark.sp-thumb-active .sp-thumb-inner
{
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=);
}
.sp-clear-display {
background-repeat:no-repeat;
background-position: center;
background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==);
}

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,17 @@
"src/**/*.tsx" "src/**/*.tsx"
], ],
"exclude": [ "exclude": [
"dist" "dist",
"node_modules"
], ],
"compilerOptions": { "compilerOptions": {
"rootDir": ".", "rootDirs": [".", "stories"],
"module": "esnext", "module": "esnext",
"outDir": "dist", "outDir": "dist",
"declaration": true, "declaration": true,
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true "strictNullChecks": true,
} "typeRoots": ["./node_modules/@types", "types"],
"skipLibCheck": true // Temp workaround for Duplicate identifier tsc errors
},
} }

View File

@ -23,9 +23,9 @@ func (hs *HTTPServer) registerRoutes() {
// not logged in views // not logged in views
r.Get("/", reqSignedIn, hs.Index) r.Get("/", reqSignedIn, hs.Index)
r.Get("/logout", Logout) r.Get("/logout", hs.Logout)
r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost)) r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(hs.LoginPost))
r.Get("/login/:name", quota("session"), OAuthLogin) r.Get("/login/:name", quota("session"), hs.OAuthLogin)
r.Get("/login", hs.LoginView) r.Get("/login", hs.LoginView)
r.Get("/invite/:code", hs.Index) r.Get("/invite/:code", hs.Index)
@ -84,11 +84,11 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/signup", hs.Index) r.Get("/signup", hs.Index)
r.Get("/api/user/signup/options", Wrap(GetSignUpOptions)) r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp)) r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2)) r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(hs.SignUpStep2))
// invited // invited
r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode)) r.Get("/api/user/invite/:code", Wrap(GetInviteInfoByCode))
r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite)) r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(hs.CompleteInvite))
// reset password // reset password
r.Get("/user/password/send-reset-email", hs.Index) r.Get("/user/password/send-reset-email", hs.Index)
@ -109,7 +109,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot)) r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
// api renew session based on remember cookie // api renew session based on remember cookie
r.Get("/api/login/ping", quota("session"), LoginAPIPing) r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
// authed api // authed api
r.Group("/api", func(apiRoute routing.RouteRegister) { r.Group("/api", func(apiRoute routing.RouteRegister) {

View File

@ -5,7 +5,6 @@ import (
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
@ -95,13 +94,14 @@ 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,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
Delims: macaron.Delims{Left: "[[", Right: "]]"}, Delims: macaron.Delims{Left: "[[", Right: "]]"},
})) }))
sc.m.Use(middleware.GetContextHandler()) sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(middleware.Sessioner(&session.Options{}, 0)) 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) UserSignedOutHook(c *m.ReqContext) {}

View File

@ -336,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
"id": 123123, "id": 123123,
"gridPos": map[string]interface{}{ "gridPos": map[string]interface{}{
"x": 0, "x": 0,
"y": 0, "y": 3,
"w": 24, "w": 24,
"h": 4, "h": 4,
}, },

View File

@ -50,6 +50,7 @@ func formatShort(interval time.Duration) string {
func NewAlertNotification(notification *models.AlertNotification) *AlertNotification { func NewAlertNotification(notification *models.AlertNotification) *AlertNotification {
return &AlertNotification{ return &AlertNotification{
Id: notification.Id, Id: notification.Id,
Uid: notification.Uid,
Name: notification.Name, Name: notification.Name,
Type: notification.Type, Type: notification.Type,
IsDefault: notification.IsDefault, IsDefault: notification.IsDefault,
@ -64,6 +65,7 @@ func NewAlertNotification(notification *models.AlertNotification) *AlertNotifica
type AlertNotification struct { type AlertNotification struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Uid string `json:"uid"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
IsDefault bool `json:"isDefault"` IsDefault bool `json:"isDefault"`

View File

@ -11,14 +11,8 @@ import (
"path" "path"
"time" "time"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
macaron "gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/api/live" "github.com/grafana/grafana/pkg/api/live"
"github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static" httpstatic "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
@ -27,11 +21,16 @@ 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"
"github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
macaron "gopkg.in/macaron.v1"
) )
func init() { func init() {
@ -49,13 +48,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:""`
} }
func (hs *HTTPServer) Init() error { func (hs *HTTPServer) Init() error {
@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
hs.macaron = hs.newMacaron() hs.macaron = hs.newMacaron()
hs.registerRoutes() hs.registerRoutes()
session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
return nil return nil
} }
@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
m.Use(hs.healthHandler) m.Use(hs.healthHandler)
m.Use(hs.metricsEndpoint) m.Use(hs.metricsEndpoint)
m.Use(middleware.GetContextHandler()) m.Use(middleware.GetContextHandler(hs.AuthTokenService))
m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
m.Use(middleware.OrgRedirect()) m.Use(middleware.OrgRedirect())
// needs to be after context handler // needs to be after context handler

View File

@ -1,6 +1,8 @@
package api package api
import ( import (
"encoding/hex"
"net/http"
"net/url" "net/url"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
@ -9,12 +11,13 @@ import (
"github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"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"
) )
const ( const (
ViewIndex = "index" ViewIndex = "index"
LoginErrorCookieName = "login_error"
) )
func (hs *HTTPServer) LoginView(c *m.ReqContext) { func (hs *HTTPServer) LoginView(c *m.ReqContext) {
@ -34,8 +37,8 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
viewData.Settings["loginHint"] = setting.LoginHint viewData.Settings["loginHint"] = setting.LoginHint
viewData.Settings["disableLoginForm"] = setting.DisableLoginForm viewData.Settings["disableLoginForm"] = setting.DisableLoginForm
if loginError, ok := c.Session.Get("loginError").(string); ok { if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok {
c.Session.Delete("loginError") deleteCookie(c, LoginErrorCookieName)
viewData.Settings["loginError"] = loginError viewData.Settings["loginError"] = loginError
} }
@ -43,7 +46,7 @@ func (hs *HTTPServer) LoginView(c *m.ReqContext) {
return return
} }
if !tryLoginUsingRememberCookie(c) { if !c.IsSignedIn {
c.HTML(200, ViewIndex, viewData) c.HTML(200, ViewIndex, viewData)
return return
} }
@ -75,56 +78,15 @@ func tryOAuthAutoLogin(c *m.ReqContext) bool {
return false return false
} }
func tryLoginUsingRememberCookie(c *m.ReqContext) bool { func (hs *HTTPServer) LoginAPIPing(c *m.ReqContext) Response {
// Check auto-login. if c.IsSignedIn || c.IsAnonymous {
uname := c.GetCookie(setting.CookieUserName) return JSON(200, "Logged in")
if len(uname) == 0 {
return false
} }
isSucceed := false return Error(401, "Unauthorized", nil)
defer func() {
if !isSucceed {
log.Trace("auto-login cookie cleared: %s", uname)
c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/")
c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
return
}
}()
userQuery := m.GetUserByLoginQuery{LoginOrEmail: uname}
if err := bus.Dispatch(&userQuery); err != nil {
return false
}
user := userQuery.Result
// validate remember me cookie
signingKey := user.Rands + user.Password
if len(signingKey) < 10 {
c.Logger.Error("Invalid user signingKey")
return false
}
if val, _ := c.GetSuperSecureCookie(signingKey, setting.CookieRememberName); val != user.Login {
return false
}
isSucceed = true
loginUserWithUser(user, c)
return true
} }
func LoginAPIPing(c *m.ReqContext) { func (hs *HTTPServer) LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
if !tryLoginUsingRememberCookie(c) {
c.JsonApiErr(401, "Unauthorized", nil)
return
}
c.JsonOK("Logged in")
}
func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
if setting.DisableLoginForm { if setting.DisableLoginForm {
return Error(401, "Login is disabled", nil) return Error(401, "Login is disabled", nil)
} }
@ -146,7 +108,7 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
user := authQuery.User user := authQuery.User
loginUserWithUser(user, c) hs.loginUserWithUser(user, c)
result := map[string]interface{}{ result := map[string]interface{}{
"message": "Logged in", "message": "Logged in",
@ -162,30 +124,60 @@ func LoginPost(c *m.ReqContext, cmd dtos.LoginCommand) Response {
return JSON(200, result) return JSON(200, result)
} }
func loginUserWithUser(user *m.User, c *m.ReqContext) { func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
if user == nil { if user == nil {
log.Error(3, "User login with nil user") hs.log.Error("User login with nil user")
} }
c.Resp.Header().Del("Set-Cookie") err := hs.AuthTokenService.UserAuthenticatedHook(user, c)
if err != nil {
days := 86400 * setting.LogInRememberDays hs.log.Error("User auth hook failed", "error", err)
if days > 0 {
c.SetCookie(setting.CookieUserName, user.Login, days, setting.AppSubUrl+"/")
c.SetSuperSecureCookie(user.Rands+user.Password, setting.CookieRememberName, user.Login, days, setting.AppSubUrl+"/")
} }
c.Session.RegenerateId(c.Context)
c.Session.Set(session.SESS_KEY_USERID, user.Id)
} }
func Logout(c *m.ReqContext) { func (hs *HTTPServer) Logout(c *m.ReqContext) {
c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl+"/") hs.AuthTokenService.UserSignedOutHook(c)
c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl+"/")
c.Session.Destory(c.Context)
if setting.SignoutRedirectUrl != "" { if setting.SignoutRedirectUrl != "" {
c.Redirect(setting.SignoutRedirectUrl) c.Redirect(setting.SignoutRedirectUrl)
} else { } else {
c.Redirect(setting.AppSubUrl + "/login") c.Redirect(setting.AppSubUrl + "/login")
} }
} }
func tryGetEncryptedCookie(ctx *m.ReqContext, cookieName string) (string, bool) {
cookie := ctx.GetCookie(cookieName)
if cookie == "" {
return "", false
}
decoded, err := hex.DecodeString(cookie)
if err != nil {
return "", false
}
decryptedError, err := util.Decrypt([]byte(decoded), setting.SecretKey)
return string(decryptedError), err == nil
}
func deleteCookie(ctx *m.ReqContext, cookieName string) {
ctx.SetCookie(cookieName, "", -1, setting.AppSubUrl+"/")
}
func (hs *HTTPServer) trySetEncryptedCookie(ctx *m.ReqContext, cookieName string, value string, maxAge int) error {
encryptedError, err := util.Encrypt([]byte(value), setting.SecretKey)
if err != nil {
return err
}
http.SetCookie(ctx.Resp, &http.Cookie{
Name: cookieName,
MaxAge: 60,
Value: hex.EncodeToString(encryptedError),
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.SecurityHTTPSCookies,
})
return nil
}

View File

@ -3,9 +3,11 @@ package api
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/hex"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
@ -18,12 +20,14 @@ import (
"github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login"
"github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/metrics"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/social" "github.com/grafana/grafana/pkg/social"
) )
var oauthLogger = log.New("oauth") var (
oauthLogger = log.New("oauth")
OauthStateCookieName = "oauth_state"
)
func GenStateString() string { func GenStateString() string {
rnd := make([]byte, 32) rnd := make([]byte, 32)
@ -31,7 +35,7 @@ func GenStateString() string {
return base64.URLEncoding.EncodeToString(rnd) return base64.URLEncoding.EncodeToString(rnd)
} }
func OAuthLogin(ctx *m.ReqContext) { func (hs *HTTPServer) OAuthLogin(ctx *m.ReqContext) {
if setting.OAuthService == nil { if setting.OAuthService == nil {
ctx.Handle(404, "OAuth not enabled", nil) ctx.Handle(404, "OAuth not enabled", nil)
return return
@ -48,14 +52,15 @@ func OAuthLogin(ctx *m.ReqContext) {
if errorParam != "" { if errorParam != "" {
errorDesc := ctx.Query("error_description") errorDesc := ctx.Query("error_description")
oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc) oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc)
redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) hs.redirectWithError(ctx, login.ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc)
return return
} }
code := ctx.Query("code") code := ctx.Query("code")
if code == "" { if code == "" {
state := GenStateString() state := GenStateString()
ctx.Session.Set(session.SESS_KEY_OAUTH_STATE, state) hashedState := hashStatecode(state, setting.OAuthService.OAuthInfos[name].ClientSecret)
hs.writeCookie(ctx.Resp, OauthStateCookieName, hashedState, 60)
if setting.OAuthService.OAuthInfos[name].HostedDomain == "" { if setting.OAuthService.OAuthInfos[name].HostedDomain == "" {
ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline)) ctx.Redirect(connect.AuthCodeURL(state, oauth2.AccessTypeOnline))
} else { } else {
@ -64,14 +69,20 @@ func OAuthLogin(ctx *m.ReqContext) {
return return
} }
savedState, ok := ctx.Session.Get(session.SESS_KEY_OAUTH_STATE).(string) cookieState := ctx.GetCookie(OauthStateCookieName)
if !ok {
// delete cookie
ctx.Resp.Header().Del("Set-Cookie")
hs.deleteCookie(ctx.Resp, OauthStateCookieName)
if cookieState == "" {
ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil)
return return
} }
queryState := ctx.Query("state") queryState := hashStatecode(ctx.Query("state"), setting.OAuthService.OAuthInfos[name].ClientSecret)
if savedState != queryState { oauthLogger.Info("state check", "queryState", queryState, "cookieState", cookieState)
if cookieState != queryState {
ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil) ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil)
return return
} }
@ -131,7 +142,7 @@ func OAuthLogin(ctx *m.ReqContext) {
userInfo, err := connect.UserInfo(client, token) userInfo, err := connect.UserInfo(client, token)
if err != nil { if err != nil {
if sErr, ok := err.(*social.Error); ok { if sErr, ok := err.(*social.Error); ok {
redirectWithError(ctx, sErr) hs.redirectWithError(ctx, sErr)
} else { } else {
ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
} }
@ -142,13 +153,13 @@ func OAuthLogin(ctx *m.ReqContext) {
// validate that we got at least an email address // validate that we got at least an email address
if userInfo.Email == "" { if userInfo.Email == "" {
redirectWithError(ctx, login.ErrNoEmail) hs.redirectWithError(ctx, login.ErrNoEmail)
return return
} }
// validate that the email is allowed to login to grafana // validate that the email is allowed to login to grafana
if !connect.IsEmailAllowed(userInfo.Email) { if !connect.IsEmailAllowed(userInfo.Email) {
redirectWithError(ctx, login.ErrEmailNotAllowed) hs.redirectWithError(ctx, login.ErrEmailNotAllowed)
return return
} }
@ -171,14 +182,15 @@ func OAuthLogin(ctx *m.ReqContext) {
ExternalUser: extUser, ExternalUser: extUser,
SignupAllowed: connect.IsSignupAllowed(), SignupAllowed: connect.IsSignupAllowed(),
} }
err = bus.Dispatch(cmd) err = bus.Dispatch(cmd)
if err != nil { if err != nil {
redirectWithError(ctx, err) hs.redirectWithError(ctx, err)
return return
} }
// login // login
loginUserWithUser(cmd.Result, ctx) hs.loginUserWithUser(cmd.Result, ctx)
metrics.M_Api_Login_OAuth.Inc() metrics.M_Api_Login_OAuth.Inc()
@ -191,8 +203,29 @@ func OAuthLogin(ctx *m.ReqContext) {
ctx.Redirect(setting.AppSubUrl + "/") ctx.Redirect(setting.AppSubUrl + "/")
} }
func redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) { func (hs *HTTPServer) deleteCookie(w http.ResponseWriter, name string) {
hs.writeCookie(w, name, "", -1)
}
func (hs *HTTPServer) writeCookie(w http.ResponseWriter, name string, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: name,
MaxAge: maxAge,
Value: value,
HttpOnly: true,
Path: setting.AppSubUrl + "/",
Secure: hs.Cfg.SecurityHTTPSCookies,
})
}
func hashStatecode(code, seed string) string {
hashBytes := sha256.Sum256([]byte(code + setting.SecretKey + seed))
return hex.EncodeToString(hashBytes[:])
}
func (hs *HTTPServer) redirectWithError(ctx *m.ReqContext, err error, v ...interface{}) {
ctx.Logger.Error(err.Error(), v...) ctx.Logger.Error(err.Error(), v...)
ctx.Session.Set("loginError", err.Error()) hs.trySetEncryptedCookie(ctx, LoginErrorCookieName, err.Error(), 60)
ctx.Redirect(setting.AppSubUrl + "/login") ctx.Redirect(setting.AppSubUrl + "/login")
} }

View File

@ -148,7 +148,7 @@ func GetInviteInfoByCode(c *m.ReqContext) Response {
}) })
} }
func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response { func (hs *HTTPServer) CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Response {
query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} query := m.GetTempUserByCodeQuery{Code: completeInvite.InviteCode}
if err := bus.Dispatch(&query); err != nil { if err := bus.Dispatch(&query); err != nil {
@ -186,7 +186,7 @@ func CompleteInvite(c *m.ReqContext, completeInvite dtos.CompleteInviteForm) Res
return rsp return rsp
} }
loginUserWithUser(user, c) hs.loginUserWithUser(user, c)
metrics.M_Api_User_SignUpCompleted.Inc() metrics.M_Api_User_SignUpCompleted.Inc()
metrics.M_Api_User_SignUpInvite.Inc() metrics.M_Api_User_SignUpInvite.Inc()

View File

@ -54,7 +54,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx
func newHTTPClient() httpClient { func newHTTPClient() httpClient {
return &http.Client{ return &http.Client{
Timeout: time.Second * 30, Timeout: time.Duration(setting.DataProxyTimeout) * time.Second,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
} }
} }

View File

@ -51,7 +51,7 @@ func SignUp(c *m.ReqContext, form dtos.SignUpForm) Response {
return JSON(200, util.DynMap{"status": "SignUpCreated"}) return JSON(200, util.DynMap{"status": "SignUpCreated"})
} }
func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response { func (hs *HTTPServer) SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
if !setting.AllowUserSignUp { if !setting.AllowUserSignUp {
return Error(401, "User signup is disabled", nil) return Error(401, "User signup is disabled", nil)
} }
@ -109,7 +109,7 @@ func SignUpStep2(c *m.ReqContext, form dtos.SignUpStep2Form) Response {
apiResponse["code"] = "redirect-to-select-org" apiResponse["code"] = "redirect-to-select-org"
} }
loginUserWithUser(user, c) hs.loginUserWithUser(user, c)
metrics.M_Api_User_SignUpCompleted.Inc() metrics.M_Api_User_SignUpCompleted.Inc()
return JSON(200, apiResponse) return JSON(200, apiResponse)

View File

@ -7,7 +7,6 @@ import (
"gopkg.in/macaron.v1" "gopkg.in/macaron.v1"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"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"
) )
@ -17,16 +16,6 @@ type AuthOptions struct {
ReqSignedIn bool ReqSignedIn bool
} }
func getRequestUserId(c *m.ReqContext) int64 {
userID := c.Session.Get(session.SESS_KEY_USERID)
if userID != nil {
return userID.(int64)
}
return 0
}
func getApiKey(c *m.ReqContext) string { func getApiKey(c *m.ReqContext) string {
header := c.Req.Header.Get("Authorization") header := c.Req.Header.Get("Authorization")
parts := strings.SplitN(header, " ", 2) parts := strings.SplitN(header, " ", 2)

View File

@ -16,7 +16,9 @@ import (
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
var AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue" var (
AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
)
func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool { func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
if !setting.AuthProxyEnabled { if !setting.AuthProxyEnabled {
@ -40,6 +42,12 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
return false return false
} }
defer func() {
if err := ctx.Session.Release(); err != nil {
ctx.Logger.Error("failed to save session data", "error", err)
}
}()
query := &m.GetSignedInUserQuery{OrgId: orgID} query := &m.GetSignedInUserQuery{OrgId: orgID}
// if this session has already been authenticated by authProxy just load the user // if this session has already been authenticated by authProxy just load the user
@ -192,6 +200,16 @@ var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error {
return nil return nil
} }
func getRequestUserId(c *m.ReqContext) int64 {
userID := c.Session.Get(session.SESS_KEY_USERID)
if userID != nil {
return userID.(int64)
}
return 0
}
func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error { func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 { if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
return nil return nil

View File

@ -3,15 +3,15 @@ package middleware
import ( import (
"strconv" "strconv"
"gopkg.in/macaron.v1"
"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"
macaron "gopkg.in/macaron.v1"
) )
var ( var (
@ -21,12 +21,12 @@ var (
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
) )
func GetContextHandler() macaron.Handler { func GetContextHandler(ats auth.UserAuthTokenService) macaron.Handler {
return func(c *macaron.Context) { return func(c *macaron.Context) {
ctx := &m.ReqContext{ ctx := &m.ReqContext{
Context: c, Context: c,
SignedInUser: &m.SignedInUser{}, SignedInUser: &m.SignedInUser{},
Session: session.GetSession(), Session: session.GetSession(), // should only be used by auth_proxy
IsSignedIn: false, IsSignedIn: false,
AllowAnonymous: false, AllowAnonymous: false,
SkipCache: false, SkipCache: false,
@ -49,7 +49,7 @@ func GetContextHandler() 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 initContextWithUserSessionCookie(ctx, orgId): case ats.InitContextWithToken(ctx, orgId):
case initContextWithAnonymousUser(ctx): case initContextWithAnonymousUser(ctx):
} }
@ -88,29 +88,6 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
return true return true
} }
func initContextWithUserSessionCookie(ctx *m.ReqContext, orgId int64) bool {
// initialize session
if err := ctx.Session.Start(ctx.Context); err != nil {
ctx.Logger.Error("Failed to start session", "error", err)
return false
}
var userId int64
if userId = getRequestUserId(ctx); userId == 0 {
return false
}
query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId}
if err := bus.Dispatch(&query); err != nil {
ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err)
return false
}
ctx.SignedInUser = query.Result
ctx.IsSignedIn = true
return true
}
func initContextWithApiKey(ctx *m.ReqContext) bool { func initContextWithApiKey(ctx *m.ReqContext) bool {
var keyString string var keyString string
if keyString = getApiKey(ctx); keyString == "" { if keyString = getApiKey(ctx); keyString == "" {

View File

@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
ms "github.com/go-macaron/session" msession "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/services/session"
@ -43,11 +43,6 @@ func TestMiddlewareContext(t *testing.T) {
So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty) So(sc.resp.Header().Get("Cache-Control"), ShouldBeEmpty)
}) })
middlewareScenario("Non api request should init session", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").exec()
So(sc.resp.Header().Get("Set-Cookie"), ShouldContainSubstring, "grafana_sess")
})
middlewareScenario("Invalid api key", func(sc *scenarioContext) { middlewareScenario("Invalid api key", func(sc *scenarioContext) {
sc.apiKey = "invalid_key_test" sc.apiKey = "invalid_key_test"
sc.fakeReq("GET", "/").exec() sc.fakeReq("GET", "/").exec()
@ -151,22 +146,17 @@ func TestMiddlewareContext(t *testing.T) {
}) })
}) })
middlewareScenario("UserId in session", func(sc *scenarioContext) { middlewareScenario("Auth token service", func(sc *scenarioContext) {
var wasCalled bool
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
c.Session.Set(session.SESS_KEY_USERID, int64(12)) wasCalled = true
}).exec() return false
}
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
sc.fakeReq("GET", "/").exec() sc.fakeReq("GET", "/").exec()
Convey("should init context with user info", func() { Convey("should call middleware", func() {
So(sc.context.IsSignedIn, ShouldBeTrue) So(wasCalled, ShouldBeTrue)
So(sc.context.UserId, ShouldEqual, 12)
}) })
}) })
@ -211,6 +201,7 @@ func TestMiddlewareContext(t *testing.T) {
return nil return nil
}) })
setting.SessionOptions = msession.Options{}
sc.fakeReq("GET", "/") sc.fakeReq("GET", "/")
sc.req.Header.Add("X-WEBAUTH-USER", "torkelo") sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
sc.exec() sc.exec()
@ -479,6 +470,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
defer bus.ClearBusHandlers() defer bus.ClearBusHandlers()
sc := &scenarioContext{} sc := &scenarioContext{}
viewsPath, _ := filepath.Abs("../../public/views") viewsPath, _ := filepath.Abs("../../public/views")
sc.m = macaron.New() sc.m = macaron.New()
@ -487,10 +479,13 @@ func middlewareScenario(desc string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"}, Delims: macaron.Delims{Left: "[[", Right: "]]"},
})) }))
sc.m.Use(GetContextHandler()) session.Init(&msession.Options{}, 0)
sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine // mock out gc goroutine
session.StartSessionGC = func() {} session.StartSessionGC = func() {}
sc.m.Use(Sessioner(&ms.Options{}, 0)) setting.SessionOptions = msession.Options{}
sc.m.Use(OrgRedirect()) sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders()) sc.m.Use(AddDefaultResponseHeaders())
@ -508,15 +503,16 @@ func middlewareScenario(desc string, fn scenarioFunc) {
} }
type scenarioContext struct { type scenarioContext struct {
m *macaron.Macaron m *macaron.Macaron
context *m.ReqContext context *m.ReqContext
resp *httptest.ResponseRecorder resp *httptest.ResponseRecorder
apiKey string apiKey string
authHeader string authHeader string
respJson map[string]interface{} respJson map[string]interface{}
handlerFunc handlerFunc handlerFunc handlerFunc
defaultHandler macaron.Handler defaultHandler macaron.Handler
url string url string
userAuthTokenService *fakeUserAuthTokenService
req *http.Request req *http.Request
} }
@ -585,3 +581,25 @@ func (sc *scenarioContext) exec() {
type scenarioFunc func(c *scenarioContext) type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext) type handlerFunc func(c *m.ReqContext)
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) UserSignedOutHook(c *m.ReqContext) {}

View File

@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
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"
"gopkg.in/macaron.v1" "gopkg.in/macaron.v1"
) )

View File

@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
@ -15,18 +14,15 @@ 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.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
c.Session.Set(session.SESS_KEY_USERID, int64(12))
}).exec()
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error { bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return nil return nil
}) })
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12} ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil ctx.IsSignedIn = true
}) return true
}
sc.m.Get("/", sc.defaultHandler) sc.m.Get("/", sc.defaultHandler)
sc.fakeReq("GET", "/?orgId=3").exec() sc.fakeReq("GET", "/?orgId=3").exec()
@ -37,14 +33,16 @@ 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.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
c.Session.Set(session.SESS_KEY_USERID, int64(12))
}).exec()
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

View File

@ -74,15 +74,12 @@ func TestMiddlewareQuota(t *testing.T) {
}) })
middlewareScenario("with user logged in", func(sc *scenarioContext) { middlewareScenario("with user logged in", func(sc *scenarioContext) {
// log us in, so we have a user_id and org_id in the context sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) { ctx.SignedInUser = &m.SignedInUser{OrgId: 2, UserId: 12}
c.Session.Set(session.SESS_KEY_USERID, int64(12)) ctx.IsSignedIn = true
}).exec() return true
}
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error { bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
query.Result = &m.GlobalQuotaDTO{ query.Result = &m.GlobalQuotaDTO{
Target: query.Target, Target: query.Target,

View File

@ -4,13 +4,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
ms "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"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/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
"gopkg.in/macaron.v1" macaron "gopkg.in/macaron.v1"
) )
func TestRecoveryMiddleware(t *testing.T) { func TestRecoveryMiddleware(t *testing.T) {
@ -64,10 +63,10 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"}, Delims: macaron.Delims{Left: "[[", Right: "]]"},
})) }))
sc.m.Use(GetContextHandler()) sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine // mock out gc goroutine
session.StartSessionGC = func() {} session.StartSessionGC = func() {}
sc.m.Use(Sessioner(&ms.Options{}, 0))
sc.m.Use(OrgRedirect()) sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders()) sc.m.Use(AddDefaultResponseHeaders())

View File

@ -1,21 +0,0 @@
package middleware
import (
ms "github.com/go-macaron/session"
"gopkg.in/macaron.v1"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
)
func Sessioner(options *ms.Options, sessionConnMaxLifetime int64) macaron.Handler {
session.Init(options, sessionConnMaxLifetime)
return func(ctx *m.ReqContext) {
ctx.Next()
if err := ctx.Session.Release(); err != nil {
panic("session(release): " + err.Error())
}
}
}

View File

@ -8,10 +8,11 @@ import (
) )
var ( var (
ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified") ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
ErrAlertNotificationStateNotFound = errors.New("alert notification state not found") ErrAlertNotificationStateNotFound = errors.New("alert notification state not found")
ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict") ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.") ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.")
ErrAlertNotificationFailedGenerateUniqueUid = errors.New("Failed to generate unique alert notification uid")
) )
type AlertNotificationStateType string type AlertNotificationStateType string
@ -24,6 +25,7 @@ var (
type AlertNotification struct { type AlertNotification struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Uid string `json:"-"`
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
@ -37,6 +39,7 @@ type AlertNotification struct {
} }
type CreateAlertNotificationCommand struct { type CreateAlertNotificationCommand struct {
Uid string `json:"-"`
Name string `json:"name" binding:"Required"` Name string `json:"name" binding:"Required"`
Type string `json:"type" binding:"Required"` Type string `json:"type" binding:"Required"`
SendReminder bool `json:"sendReminder"` SendReminder bool `json:"sendReminder"`
@ -63,10 +66,28 @@ type UpdateAlertNotificationCommand struct {
Result *AlertNotification Result *AlertNotification
} }
type UpdateAlertNotificationWithUidCommand struct {
Uid string
Name string
Type string
SendReminder bool
DisableResolveMessage bool
Frequency string
IsDefault bool
Settings *simplejson.Json
OrgId int64
Result *AlertNotification
}
type DeleteAlertNotificationCommand struct { type DeleteAlertNotificationCommand struct {
Id int64 Id int64
OrgId int64 OrgId int64
} }
type DeleteAlertNotificationWithUidCommand struct {
Uid string
OrgId int64
}
type GetAlertNotificationsQuery struct { type GetAlertNotificationsQuery struct {
Name string Name string
@ -76,8 +97,15 @@ type GetAlertNotificationsQuery struct {
Result *AlertNotification Result *AlertNotification
} }
type GetAlertNotificationsToSendQuery struct { type GetAlertNotificationsWithUidQuery struct {
Ids []int64 Uid string
OrgId int64
Result *AlertNotification
}
type GetAlertNotificationsWithUidToSendQuery struct {
Uids []string
OrgId int64 OrgId int64
Result []*AlertNotification Result []*AlertNotification

View File

@ -3,18 +3,18 @@ package models
import ( import (
"strings" "strings"
"github.com/prometheus/client_golang/prometheus"
"gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/log"
"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/prometheus/client_golang/prometheus"
"gopkg.in/macaron.v1"
) )
type ReqContext struct { type ReqContext struct {
*macaron.Context *macaron.Context
*SignedInUser *SignedInUser
// This should only be used by the auth_proxy
Session session.SessionStore Session session.SessionStore
IsSignedIn bool IsSignedIn bool

View File

@ -105,8 +105,9 @@ func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
var ( var (
unfinishedWorkTimeout = time.Second * 5 unfinishedWorkTimeout = time.Second * 5
// TODO: Make alertTimeout and alertMaxAttempts configurable in the config file. // TODO: Make alertTimeout and alertMaxAttempts configurable in the config file.
alertTimeout = time.Second * 30 alertTimeout = time.Second * 30
alertMaxAttempts = 3 resultHandleTimeout = time.Second * 30
alertMaxAttempts = 3
) )
func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error { func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
@ -116,7 +117,7 @@ func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *J
} }
}() }()
cancelChan := make(chan context.CancelFunc, alertMaxAttempts) cancelChan := make(chan context.CancelFunc, alertMaxAttempts*2)
attemptChan := make(chan int, 1) attemptChan := make(chan int, 1)
// Initialize with first attemptID=1 // Initialize with first attemptID=1
@ -204,6 +205,15 @@ func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancel
} }
} }
// create new context with timeout for notifications
resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), resultHandleTimeout)
cancelChan <- resultHandleCancelFn
// override the context used for evaluation with a new context for notifications.
// This makes it possible for notifiers to execute when datasources
// dont respond within the timeout limit. We should rewrite this so notifications
// dont reuse the evalContext and get its own context.
evalContext.Ctx = resultHandleCtx
evalContext.Rule.State = evalContext.GetNewState() evalContext.Rule.State = evalContext.GetNewState()
e.resultHandler.Handle(evalContext) e.resultHandler.Handle(evalContext)
span.Finish() span.Finish()

View File

@ -0,0 +1,148 @@
// +build integration
package alerting
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
. "github.com/smartystreets/goconvey/convey"
)
func TestEngineTimeouts(t *testing.T) {
Convey("Alerting engine timeout tests", t, func() {
engine := NewEngine()
engine.resultHandler = &FakeResultHandler{}
job := &Job{Running: true, Rule: &Rule{}}
Convey("Should trigger as many retries as needed", func() {
Convey("pended alert for datasource -> result handler should be worked", func() {
// reduce alert timeout to test quickly
originAlertTimeout := alertTimeout
alertTimeout = 2 * time.Second
transportTimeoutInterval := 2 * time.Second
serverBusySleepDuration := 1 * time.Second
evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
engine.evalHandler = evalHandler
engine.resultHandler = resultHandler
engine.processJobWithRetry(context.TODO(), job)
So(evalHandler.EvalSucceed, ShouldEqual, true)
So(resultHandler.ResultHandleSucceed, ShouldEqual, true)
// initialize for other tests.
alertTimeout = originAlertTimeout
engine.resultHandler = &FakeResultHandler{}
})
})
})
}
type FakeCommonTimeoutHandler struct {
TransportTimeoutDuration time.Duration
ServerBusySleepDuration time.Duration
EvalSucceed bool
ResultHandleSucceed bool
}
func NewFakeCommonTimeoutHandler(transportTimeoutDuration time.Duration, serverBusySleepDuration time.Duration) *FakeCommonTimeoutHandler {
return &FakeCommonTimeoutHandler{
TransportTimeoutDuration: transportTimeoutDuration,
ServerBusySleepDuration: serverBusySleepDuration,
EvalSucceed: false,
ResultHandleSucceed: false,
}
}
func (handler *FakeCommonTimeoutHandler) Eval(evalContext *EvalContext) {
// 1. prepare mock server
path := "/evaltimeout"
srv := runBusyServer(path, handler.ServerBusySleepDuration)
defer srv.Close()
// 2. send requests
url := srv.URL + path
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
if res != nil {
defer res.Body.Close()
}
if err != nil {
evalContext.Error = errors.New("Fake evaluation timeout test failure")
return
}
if res.StatusCode == 200 {
handler.EvalSucceed = true
}
evalContext.Error = errors.New("Fake evaluation timeout test failure; wrong response")
}
func (handler *FakeCommonTimeoutHandler) Handle(evalContext *EvalContext) error {
// 1. prepare mock server
path := "/resulthandle"
srv := runBusyServer(path, handler.ServerBusySleepDuration)
defer srv.Close()
// 2. send requests
url := srv.URL + path
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
if res != nil {
defer res.Body.Close()
}
if err != nil {
evalContext.Error = errors.New("Fake result handle timeout test failure")
return evalContext.Error
}
if res.StatusCode == 200 {
handler.ResultHandleSucceed = true
return nil
}
evalContext.Error = errors.New("Fake result handle timeout test failure; wrong response")
return evalContext.Error
}
func runBusyServer(path string, serverBusySleepDuration time.Duration) *httptest.Server {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
time.Sleep(serverBusySleepDuration)
})
return server
}
func sendRequest(context context.Context, url string, transportTimeoutInterval time.Duration) (resp *http.Response, err error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(context)
transport := http.Transport{
Dial: (&net.Dialer{
Timeout: transportTimeoutInterval,
KeepAlive: transportTimeoutInterval,
}).Dial,
}
client := http.Client{
Transport: &transport,
}
return client.Do(req)
}

View File

@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
@ -197,74 +198,84 @@ func TestAlertRuleExtraction(t *testing.T) {
}) })
}) })
Convey("Parse and validate dashboard containing influxdb alert", func() { Convey("Alert notifications are in DB", func() {
json, err := ioutil.ReadFile("./testdata/influxdb-alert.json") sqlstore.InitTestDB(t)
firstNotification := m.CreateAlertNotificationCommand{Uid: "notifier1", OrgId: 1, Name: "1"}
err = sqlstore.CreateAlertNotificationCommand(&firstNotification)
So(err, ShouldBeNil)
secondNotification := m.CreateAlertNotificationCommand{Uid: "notifier2", OrgId: 1, Name: "2"}
err = sqlstore.CreateAlertNotificationCommand(&secondNotification)
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(json) Convey("Parse and validate dashboard containing influxdb alert", func() {
So(err, ShouldBeNil) json, err := ioutil.ReadFile("./testdata/influxdb-alert.json")
dash := m.NewDashboardFromJson(dashJson)
extractor := NewDashAlertExtractor(dash, 1, nil)
alerts, err := extractor.GetAlerts()
Convey("Get rules without error", func() {
So(err, ShouldBeNil) So(err, ShouldBeNil)
})
Convey("should be able to read interval", func() { dashJson, err := simplejson.NewJson(json)
So(len(alerts), ShouldEqual, 1)
for _, alert := range alerts {
So(alert.DashboardId, ShouldEqual, 4)
conditions := alert.Settings.Get("conditions").MustArray()
cond := simplejson.NewFromAny(conditions[0])
So(cond.Get("query").Get("model").Get("interval").MustString(), ShouldEqual, ">10s")
}
})
})
Convey("Should be able to extract collapsed panels", func() {
json, err := ioutil.ReadFile("./testdata/collapsed-panels.json")
So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(json)
So(err, ShouldBeNil)
dash := m.NewDashboardFromJson(dashJson)
extractor := NewDashAlertExtractor(dash, 1, nil)
alerts, err := extractor.GetAlerts()
Convey("Get rules without error", func() {
So(err, ShouldBeNil) So(err, ShouldBeNil)
dash := m.NewDashboardFromJson(dashJson)
extractor := NewDashAlertExtractor(dash, 1, nil)
alerts, err := extractor.GetAlerts()
Convey("Get rules without error", func() {
So(err, ShouldBeNil)
})
Convey("should be able to read interval", func() {
So(len(alerts), ShouldEqual, 1)
for _, alert := range alerts {
So(alert.DashboardId, ShouldEqual, 4)
conditions := alert.Settings.Get("conditions").MustArray()
cond := simplejson.NewFromAny(conditions[0])
So(cond.Get("query").Get("model").Get("interval").MustString(), ShouldEqual, ">10s")
}
})
}) })
Convey("should be able to extract collapsed alerts", func() { Convey("Should be able to extract collapsed panels", func() {
So(len(alerts), ShouldEqual, 4) json, err := ioutil.ReadFile("./testdata/collapsed-panels.json")
})
})
Convey("Parse and validate dashboard without id and containing an alert", func() {
json, err := ioutil.ReadFile("./testdata/dash-without-id.json")
So(err, ShouldBeNil)
dashJSON, err := simplejson.NewJson(json)
So(err, ShouldBeNil)
dash := m.NewDashboardFromJson(dashJSON)
extractor := NewDashAlertExtractor(dash, 1, nil)
err = extractor.ValidateAlerts()
Convey("Should validate without error", func() {
So(err, ShouldBeNil) So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(json)
So(err, ShouldBeNil)
dash := m.NewDashboardFromJson(dashJson)
extractor := NewDashAlertExtractor(dash, 1, nil)
alerts, err := extractor.GetAlerts()
Convey("Get rules without error", func() {
So(err, ShouldBeNil)
})
Convey("should be able to extract collapsed alerts", func() {
So(len(alerts), ShouldEqual, 4)
})
}) })
Convey("Should fail on save", func() { Convey("Parse and validate dashboard without id and containing an alert", func() {
_, err := extractor.GetAlerts() json, err := ioutil.ReadFile("./testdata/dash-without-id.json")
So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1") So(err, ShouldBeNil)
dashJSON, err := simplejson.NewJson(json)
So(err, ShouldBeNil)
dash := m.NewDashboardFromJson(dashJSON)
extractor := NewDashAlertExtractor(dash, 1, nil)
err = extractor.ValidateAlerts()
Convey("Should validate without error", func() {
So(err, ShouldBeNil)
})
Convey("Should fail on save", func() {
_, err := extractor.GetAlerts()
So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
})
}) })
}) })
}) })

View File

@ -24,7 +24,7 @@ type Notifier interface {
// ShouldNotify checks this evaluation should send an alert notification // ShouldNotify checks this evaluation should send an alert notification
ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool
GetNotifierId() int64 GetNotifierUid() string
GetIsDefault() bool GetIsDefault() bool
GetSendReminder() bool GetSendReminder() bool
GetDisableResolveMessage() bool GetDisableResolveMessage() bool

View File

@ -60,13 +60,13 @@ func (n *notificationService) SendIfNeeded(context *EvalContext) error {
func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error { func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error {
notifier := notifierState.notifier notifier := notifierState.notifier
n.log.Debug("Sending notification", "type", notifier.GetType(), "id", notifier.GetNotifierId(), "isDefault", notifier.GetIsDefault()) n.log.Debug("Sending notification", "type", notifier.GetType(), "uid", notifier.GetNotifierUid(), "isDefault", notifier.GetIsDefault())
metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc() metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc()
err := notifier.Notify(evalContext) err := notifier.Notify(evalContext)
if err != nil { if err != nil {
n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err) n.log.Error("failed to send notification", "uid", notifier.GetNotifierUid(), "error", err)
} }
if evalContext.IsTestRun { if evalContext.IsTestRun {
@ -110,7 +110,7 @@ func (n *notificationService) sendNotifications(evalContext *EvalContext, notifi
for _, notifierState := range notifierStates { for _, notifierState := range notifierStates {
err := n.sendNotification(evalContext, notifierState) err := n.sendNotification(evalContext, notifierState)
if err != nil { if err != nil {
n.log.Error("failed to send notification", "id", notifierState.notifier.GetNotifierId(), "error", err) n.log.Error("failed to send notification", "uid", notifierState.notifier.GetNotifierUid(), "error", err)
} }
} }
@ -157,8 +157,8 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
return nil return nil
} }
func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (notifierStateSlice, error) { func (n *notificationService) getNeededNotifiers(orgId int64, notificationUids []string, evalContext *EvalContext) (notifierStateSlice, error) {
query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds} query := &m.GetAlertNotificationsWithUidToSendQuery{OrgId: orgId, Uids: notificationUids}
if err := bus.Dispatch(query); err != nil { if err := bus.Dispatch(query); err != nil {
return nil, err return nil, err
@ -168,7 +168,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
for _, notification := range query.Result { for _, notification := range query.Result {
not, err := InitNotifier(notification) not, err := InitNotifier(notification)
if err != nil { if err != nil {
n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err) n.log.Error("Could not create notifier", "notifier", notification.Uid, "error", err)
continue continue
} }

View File

@ -16,7 +16,7 @@ const (
type NotifierBase struct { type NotifierBase struct {
Name string Name string
Type string Type string
Id int64 Uid string
IsDeault bool IsDeault bool
UploadImage bool UploadImage bool
SendReminder bool SendReminder bool
@ -34,7 +34,7 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
} }
return NotifierBase{ return NotifierBase{
Id: model.Id, Uid: model.Uid,
Name: model.Name, Name: model.Name,
IsDeault: model.IsDefault, IsDeault: model.IsDefault,
Type: model.Type, Type: model.Type,
@ -110,8 +110,8 @@ func (n *NotifierBase) NeedsImage() bool {
return n.UploadImage return n.UploadImage
} }
func (n *NotifierBase) GetNotifierId() int64 { func (n *NotifierBase) GetNotifierUid() string {
return n.Id return n.Uid
} }
func (n *NotifierBase) GetIsDefault() bool { func (n *NotifierBase) GetIsDefault() bool {

View File

@ -173,7 +173,7 @@ func TestBaseNotifier(t *testing.T) {
bJson := simplejson.New() bJson := simplejson.New()
model := &m.AlertNotification{ model := &m.AlertNotification{
Id: 1, Uid: "1",
Name: "name", Name: "name",
Type: "email", Type: "email",
Settings: bJson, Settings: bJson,

View File

@ -30,7 +30,7 @@ type Rule struct {
ExecutionErrorState m.ExecutionErrorOption ExecutionErrorState m.ExecutionErrorOption
State m.AlertStateType State m.AlertStateType
Conditions []Condition Conditions []Condition
Notifications []int64 Notifications []string
StateChanges int64 StateChanges int64
} }
@ -126,11 +126,15 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
for _, v := range ruleDef.Settings.Get("notifications").MustArray() { for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
jsonModel := simplejson.NewFromAny(v) jsonModel := simplejson.NewFromAny(v)
id, err := jsonModel.Get("id").Int64() if id, err := jsonModel.Get("id").Int64(); err == nil {
if err != nil { model.Notifications = append(model.Notifications, fmt.Sprintf("%09d", id))
return nil, ValidationError{Reason: "Invalid notification schema", DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId} } else {
if uid, err := jsonModel.Get("uid").String(); err != nil {
return nil, ValidationError{Reason: "Neither id nor uid is specified, " + err.Error(), DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId}
} else {
model.Notifications = append(model.Notifications, uid)
}
} }
model.Notifications = append(model.Notifications, id)
} }
for index, condition := range ruleDef.Settings.Get("conditions").MustArray() { for index, condition := range ruleDef.Settings.Get("conditions").MustArray() {

View File

@ -5,6 +5,7 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
) )
@ -45,6 +46,7 @@ func TestAlertRuleFrequencyParsing(t *testing.T) {
} }
func TestAlertRuleModel(t *testing.T) { func TestAlertRuleModel(t *testing.T) {
sqlstore.InitTestDB(t)
Convey("Testing alert rule", t, func() { Convey("Testing alert rule", t, func() {
RegisterCondition("test", func(model *simplejson.Json, index int) (Condition, error) { RegisterCondition("test", func(model *simplejson.Json, index int) (Condition, error) {
@ -57,46 +59,57 @@ func TestAlertRuleModel(t *testing.T) {
}) })
Convey("can construct alert rule model", func() { Convey("can construct alert rule model", func() {
json := ` firstNotification := m.CreateAlertNotificationCommand{OrgId: 1, Name: "1"}
{ err := sqlstore.CreateAlertNotificationCommand(&firstNotification)
"name": "name2", So(err, ShouldBeNil)
"description": "desc2", secondNotification := m.CreateAlertNotificationCommand{Uid: "notifier2", OrgId: 1, Name: "2"}
"handler": 0, err = sqlstore.CreateAlertNotificationCommand(&secondNotification)
"noDataMode": "critical",
"enabled": true,
"frequency": "60s",
"conditions": [
{
"type": "test",
"prop": 123
}
],
"notifications": [
{"id": 1134},
{"id": 22}
]
}
`
alertJSON, jsonErr := simplejson.NewJson([]byte(json))
So(jsonErr, ShouldBeNil)
alert := &m.Alert{
Id: 1,
OrgId: 1,
DashboardId: 1,
PanelId: 1,
Settings: alertJSON,
}
alertRule, err := NewRuleFromDBAlert(alert)
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(alertRule.Conditions), ShouldEqual, 1) Convey("with notification id and uid", func() {
json := `
{
"name": "name2",
"description": "desc2",
"handler": 0,
"noDataMode": "critical",
"enabled": true,
"frequency": "60s",
"conditions": [
{
"type": "test",
"prop": 123
}
],
"notifications": [
{"id": 1},
{"uid": "notifier2"}
]
}
`
Convey("Can read notifications", func() { alertJSON, jsonErr := simplejson.NewJson([]byte(json))
So(len(alertRule.Notifications), ShouldEqual, 2) So(jsonErr, ShouldBeNil)
alert := &m.Alert{
Id: 1,
OrgId: 1,
DashboardId: 1,
PanelId: 1,
Settings: alertJSON,
}
alertRule, err := NewRuleFromDBAlert(alert)
So(err, ShouldBeNil)
So(len(alertRule.Conditions), ShouldEqual, 1)
Convey("Can read notifications", func() {
So(len(alertRule.Notifications), ShouldEqual, 2)
So(alertRule.Notifications, ShouldContain, "000000001")
So(alertRule.Notifications, ShouldContain, "notifier2")
})
}) })
}) })
@ -108,8 +121,8 @@ func TestAlertRuleModel(t *testing.T) {
"noDataMode": "critical", "noDataMode": "critical",
"enabled": true, "enabled": true,
"frequency": "0s", "frequency": "0s",
"conditions": [ { "type": "test", "prop": 123 } ], "conditions": [ { "type": "test", "prop": 123 } ],
"notifications": [] "notifications": []
}` }`
alertJSON, jsonErr := simplejson.NewJson([]byte(json)) alertJSON, jsonErr := simplejson.NewJson([]byte(json))
@ -129,5 +142,43 @@ func TestAlertRuleModel(t *testing.T) {
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(alertRule.Frequency, ShouldEqual, 60) So(alertRule.Frequency, ShouldEqual, 60)
}) })
Convey("raise error in case of missing notification id and uid", func() {
json := `
{
"name": "name2",
"description": "desc2",
"noDataMode": "critical",
"enabled": true,
"frequency": "60s",
"conditions": [
{
"type": "test",
"prop": 123
}
],
"notifications": [
{"not_id_uid": "1134"}
]
}
`
alertJSON, jsonErr := simplejson.NewJson([]byte(json))
So(jsonErr, ShouldBeNil)
alert := &m.Alert{
Id: 1,
OrgId: 1,
DashboardId: 1,
PanelId: 1,
Frequency: 0,
Settings: alertJSON,
}
_, err := NewRuleFromDBAlert(alert)
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, "Alert validation error: Neither id nor uid is specified, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1")
})
}) })
} }

View File

@ -44,7 +44,10 @@
"noDataState": "no_data", "noDataState": "no_data",
"notifications": [ "notifications": [
{ {
"id": 6 "uid": "notifier1"
},
{
"id": 2
} }
] ]
}, },

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