mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'master' into 15053/gauge-value-font-size
This commit is contained in:
commit
b0984cd503
@ -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:
|
||||||
|
32
CHANGELOG.md
32
CHANGELOG.md
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 ./
|
||||||
|
@ -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
|
||||||
|
10
README.md
10
README.md
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
25
conf/provisioning/notifiers/sample.yaml
Normal file
25
conf/provisioning/notifiers/sample.yaml
Normal 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
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
69
devenv/docker/loadtest/README.md
Normal file
69
devenv/docker/loadtest/README.md
Normal 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
|
||||||
|
```
|
71
devenv/docker/loadtest/auth_token_test.js
Normal file
71
devenv/docker/loadtest/auth_token_test.js
Normal 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) => {}
|
187
devenv/docker/loadtest/modules/client.js
Normal file
187
devenv/docker/loadtest/modules/client.js
Normal 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));
|
||||||
|
}
|
35
devenv/docker/loadtest/modules/util.js
Normal file
35
devenv/docker/loadtest/modules/util.js
Normal 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
24
devenv/docker/loadtest/run.sh
Executable 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 "$@"
|
@ -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 |
|
@ -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+).
|
||||||
|
|
||||||
|
@ -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+).
|
||||||
|
|
||||||
|
@ -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+).
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2
packages/grafana-ui/.storybook/addons.ts
Normal file
2
packages/grafana-ui/.storybook/addons.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import '@storybook/addon-knobs/register';
|
||||||
|
import '@storybook/addon-actions/register';
|
12
packages/grafana-ui/.storybook/config.ts
Normal file
12
packages/grafana-ui/.storybook/config.ts
Normal 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);
|
56
packages/grafana-ui/.storybook/webpack.config.js
Normal file
56
packages/grafana-ui/.storybook/webpack.config.js
Normal 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;
|
||||||
|
};
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
@ -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');
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
@ -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;
|
@ -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;
|
@ -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} />;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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" />}
|
||||||
|
@ -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,
|
||||||
|
@ -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={() => {}} />;
|
||||||
|
});
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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(),
|
||||||
|
@ -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} />
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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, {
|
||||||
|
@ -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;
|
||||||
|
@ -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';
|
||||||
|
@ -1,3 +1 @@
|
|||||||
@import 'vendor/spectrum';
|
|
||||||
@import 'components/index';
|
@import 'components/index';
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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',
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
@ -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';
|
||||||
|
66
packages/grafana-ui/src/utils/namedColorsPalette.test.ts
Normal file
66
packages/grafana-ui/src/utils/namedColorsPalette.test.ts
Normal 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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
182
packages/grafana-ui/src/utils/namedColorsPalette.ts
Normal file
182
packages/grafana-ui/src/utils/namedColorsPalette.ts
Normal 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;
|
||||||
|
};
|
6
packages/grafana-ui/src/utils/propDeprecationWarning.ts
Normal file
6
packages/grafana-ui/src/utils/propDeprecationWarning.ts
Normal 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;
|
38
packages/grafana-ui/src/utils/storybook/UseState.tsx
Normal file
38
packages/grafana-ui/src/utils/storybook/UseState.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
14
packages/grafana-ui/src/utils/storybook/themeKnob.ts
Normal file
14
packages/grafana-ui/src/utils/storybook/themeKnob.ts
Normal 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
|
||||||
|
);
|
||||||
|
};
|
@ -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>;
|
509
packages/grafana-ui/src/vendor/spectrum.css
vendored
509
packages/grafana-ui/src/vendor/spectrum.css
vendored
@ -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==);
|
|
||||||
}
|
|
2317
packages/grafana-ui/src/vendor/spectrum.js
vendored
2317
packages/grafana-ui/src/vendor/spectrum.js
vendored
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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) {}
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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"`
|
||||||
|
@ -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
|
||||||
|
126
pkg/api/login.go
126
pkg/api/login.go
@ -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
|
||||||
|
}
|
||||||
|
@ -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")
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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 == "" {
|
||||||
|
@ -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) {}
|
||||||
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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())
|
||||||
|
|
||||||
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
148
pkg/services/alerting/engine_integration_test.go
Normal file
148
pkg/services/alerting/engine_integration_test.go
Normal 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)
|
||||||
|
}
|
@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
@ -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() {
|
||||||
|
@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user