diff --git a/.circleci/config.yml b/.circleci/config.yml index f6d66daab84..209cf5c98cc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,7 +19,7 @@ version: 2 jobs: mysql-integration-test: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 - image: circleci/mysql:5.6-ram environment: MYSQL_ROOT_PASSWORD: rootpass @@ -39,7 +39,7 @@ jobs: postgres-integration-test: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 - image: circleci/postgres:9.3-ram environment: POSTGRES_USER: grafanatest @@ -74,7 +74,7 @@ jobs: gometalinter: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 environment: # we need CGO because of go-sqlite3 CGO_ENABLED: 1 @@ -106,7 +106,7 @@ jobs: test-backend: docker: - - image: circleci/golang:1.11.4 + - image: circleci/golang:1.11.5 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -116,7 +116,7 @@ jobs: build-all: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -147,9 +147,6 @@ jobs: - run: name: sha-sum packages 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: name: Test and build Grafana.com release publisher command: 'cd scripts/build/release_publisher && go test . && go build -o release_publisher .' @@ -158,13 +155,12 @@ jobs: paths: - dist/grafana* - scripts/*.sh - - scripts/publish - scripts/build/release_publisher/release_publisher - scripts/build/publish.sh build: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -233,7 +229,7 @@ jobs: build-enterprise: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -265,7 +261,7 @@ jobs: build-all-enterprise: docker: - - image: grafana/build-container:1.2.2 + - image: grafana/build-container:1.2.3 working_directory: /go/src/github.com/grafana/grafana steps: - checkout @@ -393,7 +389,8 @@ jobs: name: Publish to Grafana.com command: | 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: docker: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a691465626..be6141a4bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,34 +3,46 @@ ### New Features * **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) +* **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) +* **Provisioning**: Provisioning support for alert notifiers [#10487](https://github.com/grafana/grafana/issues/10487), thx [@pbakulev](https://github.com/pbakulev) ### 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 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) -* **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) -* **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) * **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) +* **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) -* **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) -* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864) +* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo) +* **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 * **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) +* **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 -* **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`. +* **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`. +* **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) # 5.4.3 (2019-01-14) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 94daa2dc4d5..62f610cab59 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,7 +2,7 @@ ## 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 diff --git a/Dockerfile b/Dockerfile index c3af89b6092..c3e59c8048e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Golang build container -FROM golang:1.11.4 +FROM golang:1.11.5 WORKDIR $GOPATH/src/github.com/grafana/grafana @@ -19,11 +19,13 @@ COPY package.json package.json RUN go run build.go build # Node build container -FROM node:8 +FROM node:10.14.2 WORKDIR /usr/src/app/ COPY package.json yarn.lock ./ +COPY packages packages + RUN yarn install --pure-lockfile --no-progress COPY Gruntfile.js tsconfig.json tslint.json ./ diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md index 168b21dbd88..47743e5fbf9 100644 --- a/PLUGIN_DEV.md +++ b/PLUGIN_DEV.md @@ -1,7 +1,7 @@ # Plugin Development -This document is not meant as 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 +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. Whenever you as a plugin author encounter an issue with your plugin after upgrading Grafana please check here before creating an issue. ## Links diff --git a/README.md b/README.md index ff5da04f209..3df6a383e05 100644 --- a/README.md +++ b/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. ## 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) ### Dependencies @@ -71,7 +71,7 @@ Open grafana in your browser (default: `http://localhost:3000`) and login with a ### 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` @@ -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` -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 @@ -129,8 +129,8 @@ GRAFANA_TEST_DB=postgres go test ./pkg/... ## Contribute -If you have any idea for an improvement or 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 +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 to help me make Grafana 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. diff --git a/appveyor.yml b/appveyor.yml index 5f97784dd38..ccf9b5a06e1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana environment: nodejs_version: "8" GOPATH: C:\gopath - GOVERSION: 1.11.4 + GOVERSION: 1.11.5 install: - rmdir c:\go /s /q diff --git a/conf/defaults.ini b/conf/defaults.ini index 37e1ee2c7df..788112ae67e 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -106,6 +106,22 @@ path = grafana.db # For "sqlite3" only. cache mode setting used for connecting to the database cache_mode = private +#################################### Login ############################### + +[login] + +# Login cookie name +cookie_name = grafana_session + +# How many days an session can be unused before we inactivate it +login_remember_days = 7 + +# How often should the login token be rotated. default to '10m' +rotate_token_minutes = 10 + +# How long should Grafana keep expired tokens before deleting them +delete_expired_token_after_days = 30 + #################################### Session ############################# [session] # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file" @@ -143,6 +159,9 @@ conn_max_lifetime = 14400 # This enables data proxy logging, default is false logging = false +# How long the data proxy should wait before timing out default is 30 (seconds) +timeout = 30 + #################################### Analytics ########################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. @@ -175,11 +194,6 @@ admin_password = admin # used for signing 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 = false @@ -189,6 +203,9 @@ data_source_proxy_whitelist = # disable protection against brute force login attempts disable_brute_force_login_protection = false +# set cookies as https only. default is false +https_flag_cookies = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -490,7 +507,7 @@ concurrent_render_limit = 5 #################################### Explore ############################# [explore] # Enable the Explore section -enabled = false +enabled = true #################################### Internal Grafana Metrics ############ # Metrics available at HTTP API Url /metrics diff --git a/conf/provisioning/notifiers/sample.yaml b/conf/provisioning/notifiers/sample.yaml new file mode 100644 index 00000000000..7d909839412 --- /dev/null +++ b/conf/provisioning/notifiers/sample.yaml @@ -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 \ No newline at end of file diff --git a/conf/sample.ini b/conf/sample.ini index 96b92db6f48..89880106345 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -102,6 +102,22 @@ log_queries = # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared) ;cache_mode = private +#################################### Login ############################### + +[login] + +# Login cookie name +;cookie_name = grafana_session + +# How many days an session can be unused before we inactivate it +;login_remember_days = 7 + +# How often should the login token be rotated. default to '10' +;rotate_token_minutes = 10 + +# How long should Grafana keep expired tokens before deleting them +;delete_expired_token_after_days = 30 + #################################### Session #################################### [session] # Either "memory", "file", "redis", "mysql", "postgres", default is "file" @@ -130,6 +146,9 @@ log_queries = # This enables data proxy logging, default is false ;logging = false +# How long the data proxy should wait before timing out default is 30 (seconds) +;timeout = 30 + #################################### Analytics #################################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. @@ -162,11 +181,6 @@ log_queries = # used for signing ;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 = false @@ -176,6 +190,9 @@ log_queries = # disable protection against brute force login attempts ;disable_brute_force_login_protection = false +# set cookies as https only. default is false +;https_flag_cookies = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options @@ -415,7 +432,7 @@ log_queries = #################################### Explore ############################# [explore] # Enable the Explore section -;enabled = false +;enabled = true #################################### Internal Grafana Metrics ########################## # Metrics available at HTTP API Url /metrics diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml index 1195e2a977c..504ee86404d 100644 --- a/devenv/docker/ha_test/docker-compose.yaml +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -54,7 +54,8 @@ services: # - GF_DATABASE_SSL_MODE=disable # - GF_SESSION_PROVIDER=postgres # - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable - - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug + - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug + - GF_LOGIN_ROTATE_TOKEN_MINUTES=2 ports: - 3000 depends_on: diff --git a/devenv/docker/loadtest/README.md b/devenv/docker/loadtest/README.md new file mode 100644 index 00000000000..8e724637acb --- /dev/null +++ b/devenv/docker/loadtest/README.md @@ -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 +``` diff --git a/devenv/docker/loadtest/auth_token_test.js b/devenv/docker/loadtest/auth_token_test.js new file mode 100644 index 00000000000..e1356fb6f9a --- /dev/null +++ b/devenv/docker/loadtest/auth_token_test.js @@ -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) => {} diff --git a/devenv/docker/loadtest/modules/client.js b/devenv/docker/loadtest/modules/client.js new file mode 100644 index 00000000000..bda0da64564 --- /dev/null +++ b/devenv/docker/loadtest/modules/client.js @@ -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)); +} diff --git a/devenv/docker/loadtest/modules/util.js b/devenv/docker/loadtest/modules/util.js new file mode 100644 index 00000000000..af6d4cdac09 --- /dev/null +++ b/devenv/docker/loadtest/modules/util.js @@ -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; +} diff --git a/devenv/docker/loadtest/run.sh b/devenv/docker/loadtest/run.sh new file mode 100755 index 00000000000..474d75383b6 --- /dev/null +++ b/devenv/docker/loadtest/run.sh @@ -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 "$@" diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index b2a1b1f42e7..cce25e4cf2b 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -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. > 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. + +## 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 | \ No newline at end of file diff --git a/docs/sources/features/datasources/mssql.md b/docs/sources/features/datasources/mssql.md index cd191f14273..5bab14c05fa 100644 --- a/docs/sources/features/datasources/mssql.md +++ b/docs/sources/features/datasources/mssql.md @@ -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* *$__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* +*$__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+). *$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md index 371c92cde70..bafe79bd09a 100644 --- a/docs/sources/features/datasources/mysql.md +++ b/docs/sources/features/datasources/mysql.md @@ -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* *$__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* +*$__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+). *$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md index 52f8804f27f..df6bf555077 100644 --- a/docs/sources/features/datasources/postgres.md +++ b/docs/sources/features/datasources/postgres.md @@ -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* *$__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* +*$__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+). *$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above, but also adds a column alias (only available in Grafana 5.3+). diff --git a/docs/sources/http_api/other.md b/docs/sources/http_api/other.md index 5bf0cde05fe..ea905bf88f0 100644 --- a/docs/sources/http_api/other.md +++ b/docs/sources/http_api/other.md @@ -82,4 +82,29 @@ HTTP/1.1 200 Content-Type: application/json {"message": "Logged in"} -``` \ No newline at end of file +``` + +# 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" +} +``` diff --git a/packages/grafana-ui/.storybook/addons.ts b/packages/grafana-ui/.storybook/addons.ts new file mode 100644 index 00000000000..2efe80ebffa --- /dev/null +++ b/packages/grafana-ui/.storybook/addons.ts @@ -0,0 +1,2 @@ +import '@storybook/addon-knobs/register'; +import '@storybook/addon-actions/register'; diff --git a/packages/grafana-ui/.storybook/config.ts b/packages/grafana-ui/.storybook/config.ts new file mode 100644 index 00000000000..9e50c6b501a --- /dev/null +++ b/packages/grafana-ui/.storybook/config.ts @@ -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); diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js new file mode 100644 index 00000000000..44de73a1e18 --- /dev/null +++ b/packages/grafana-ui/.storybook/webpack.config.js @@ -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; +}; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 91695dc5647..0d1b14a7150 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -5,19 +5,20 @@ "main": "src/index.ts", "scripts": { "tslint": "tslint -c tslint.json --project tsconfig.json", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "storybook": "start-storybook -p 9001 -c .storybook -s ../../public" }, "author": "", "license": "ISC", "dependencies": { "@torkelo/react-select": "2.1.1", - "@types/react-test-renderer": "^16.0.3", - "@types/react-transition-group": "^2.0.15", + "@types/react-color": "^2.14.0", "classnames": "^2.2.5", "jquery": "^3.2.1", "lodash": "^4.17.10", "moment": "^2.22.2", "react": "^16.6.3", + "react-color": "^2.17.0", "react-custom-scrollbars": "^4.2.1", "react-dom": "^16.6.3", "react-highlight-words": "0.11.0", @@ -29,16 +30,32 @@ "tinycolor2": "^1.4.1" }, "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/jest": "^23.3.2", "@types/jquery": "^1.10.35", "@types/lodash": "^4.14.119", + "@types/node": "^10.12.18", "@types/react": "^16.7.6", "@types/react-custom-scrollbars": "^4.0.5", "@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/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", "typescript": "^3.2.2" + }, + "resolutions": { + "@types/lodash": "4.14.119" } } diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx new file mode 100644 index 00000000000..4c5df5314b8 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx @@ -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 { + 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) => { + 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 ( +
+
+
+ +
+
+ ); + } +} + +export default ColorInput; diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx new file mode 100644 index 00000000000..19ae2fda978 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx @@ -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 ( + + {(selectedColor, updateSelectedColor) => { + return ( + { + action('Color changed')(color); + updateSelectedColor(color); + }} + theme={selectedTheme || undefined} + /> + ); + }} + + ); +}); + +ColorPickerStories.add('Series color picker', () => { + const { selectedTheme, enableNamedColors } = getColorPickerKnobs(); + + return ( + + {(selectedColor, updateSelectedColor) => { + return ( + {}} + color={selectedColor} + onChange={color => updateSelectedColor(color)} + theme={selectedTheme || undefined} + > +
Open color picker
+
+ ); + }} +
+ ); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx index 485aa5f03d3..b6cf176a24b 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx @@ -1,61 +1,114 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import Drop from 'tether-drop'; +import React, { Component, createRef } from 'react'; +import PopperController from '../Tooltip/PopperController'; +import Popper, { RenderPopperArrowFn } from '../Tooltip/Popper'; 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; - 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 { - pickerElem: HTMLElement | null; - colorPickerDrop: any; - - openColorPicker = () => { - const dropContent = ; - - 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 ( -
(this.pickerElem = element)}> -
-
-
-
- ); +export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => { + const { onColorChange } = props; + if (onColorChange) { + propDeprecationWarning(componentName, 'onColorChange', 'onChange'); } -} +}; + +export const colorPickerFactory = ( + popover: React.ComponentType, + displayName = 'ColorPicker', + renderPopoverArrowFunction?: RenderPopperArrowFn +) => { + return class ColorPicker extends Component { + static displayName = displayName; + pickerTriggerRef = createRef(); + + 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 ( +
+ ); + }; + + return ( + + {(showPopper, hidePopper, popperProps) => { + return ( + <> + {this.pickerTriggerRef.current && ( + + )} + + {children ? ( + React.cloneElement(children as JSX.Element, { + ref: this.pickerTriggerRef, + onClick: showPopper, + onMouseLeave: hidePopper, + }) + ) : ( +
+
+
+
+
+ )} + + ); + }} + + ); + } + }; +}; + +export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker'); +export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker'); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx new file mode 100644 index 00000000000..dc51819a413 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx @@ -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 ( + { + console.log(color); + }} + theme={selectedTheme || undefined} + /> + ); +}); + +ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => { + const selectedTheme = getThemeKnob(); + + return ( + { + console.log(color); + }} + theme={selectedTheme || undefined} + /> + ); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx new file mode 100644 index 00000000000..28d66e7af86 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx @@ -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( {}} />); + 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( {}} />); + 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( + + ); + 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( + + ); + const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name); + + basicBlueSwatch.simulate('click'); + + expect(onChangeSpy).toBeCalledTimes(1); + expect(onChangeSpy).toBeCalledWith(BasicBlue.name); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx index e8305c99319..d2937a1caba 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx @@ -1,112 +1,129 @@ import React from 'react'; -import $ from 'jquery'; -import tinycolor from 'tinycolor2'; -import { ColorPalette } from './ColorPalette'; -import { SpectrumPicker } from './SpectrumPicker'; +import { NamedColorsPalette } from './NamedColorsPalette'; +import { getColorName, getColorFromHexRgbOrName } from '../../utils/namedColorsPalette'; +import { ColorPickerProps, warnAboutColorPickerPropsDeprecation } from './ColorPicker'; +import { GrafanaTheme } from '../../types'; +import { PopperContentProps } from '../Tooltip/PopperController'; +import SpectrumPalette from './SpectrumPalette'; -const DEFAULT_COLOR = '#000000'; - -export interface Props { - color: string; - onColorSelect: (c: string) => void; +export interface Props extends ColorPickerProps, PopperContentProps { + customPickers?: T; } -export class ColorPickerPopover extends React.Component { - pickerNavElem: any; +type PickerType = 'palette' | 'spectrum'; - constructor(props: Props) { +interface CustomPickersDescriptor { + [key: string]: { + tabComponent: React.ComponentType; + name: string; + }; +} +interface State { + activePicker: PickerType | keyof T; +} + +export class ColorPickerPopover extends React.Component, State> { + constructor(props: Props) { super(props); this.state = { - tab: 'palette', - color: this.props.color || DEFAULT_COLOR, - colorString: this.props.color || DEFAULT_COLOR, + activePicker: 'palette', }; + warnAboutColorPickerPropsDeprecation('ColorPickerPopover', props); } - setPickerNavElem(elem: any) { - this.pickerNavElem = $(elem); - } + getTabClassName = (tabName: PickerType | keyof T) => { + const { activePicker } = this.state; + return `ColorPickerPopover__tab ${activePicker === tabName && 'ColorPickerPopover__tab--active'}`; + }; - setColor(color: string) { - const newColor = tinycolor(color); - if (newColor.isValid()) { - this.setState({ color: newColor.toString(), colorString: newColor.toString() }); - this.props.onColorSelect(color); + handleChange = (color: any) => { + const { onColorChange, onChange, enableNamedColors, theme } = this.props; + const changeHandler = onColorChange || onChange; + + if (enableNamedColors) { + return changeHandler(color); } - } + changeHandler(getColorFromHexRgbOrName(color, theme)); + }; - sampleColorSelected(color: string) { - this.setColor(color); - } + handleTabChange = (tab: PickerType | keyof T) => { + return () => this.setState({ activePicker: tab }); + }; - spectrumColorSelected(color: any) { - const rgbColor = color.toRgbString(); - this.setColor(rgbColor); - } + renderPicker = () => { + const { activePicker } = this.state; + const { color, theme } = this.props; - onColorStringChange(e: any) { - const colorString = e.target.value; - this.setState({ colorString: colorString }); - - const newColor = tinycolor(colorString); - if (newColor.isValid()) { - // Update only color state - const newColorString = newColor.toString(); - this.setState({ color: newColorString }); - this.props.onColorSelect(newColorString); + switch (activePicker) { + case 'spectrum': + return ; + case 'palette': + return ; + default: + return this.renderCustomPicker(activePicker); } - } + }; - onColorStringBlur(e: any) { - const colorString = e.target.value; - this.setColor(colorString); - } + renderCustomPicker = (tabKey: keyof T) => { + const { customPickers, color, theme } = this.props; + if (!customPickers) { + return null; + } - componentDidMount() { - this.pickerNavElem.find('li:first').addClass('active'); - this.pickerNavElem.on('show', (e: any) => { - // use href attr (#name => name) - const tab = e.target.hash.slice(1); - this.setState({ tab: tab }); + return React.createElement(customPickers[tabKey].tabComponent, { + color, + theme, + onChange: this.handleChange, }); - } + }; - render() { - const paletteTab = ( -
- -
- ); - const spectrumTab = ( -
- -
- ); - const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab; + renderCustomPickerTabs = () => { + const { customPickers } = this.props; + + if (!customPickers) { + return null; + } return ( -
- -
{currentTab}
-
- + <> + {Object.keys(customPickers).map(key => { + return ( +
+ {customPickers[key].name} +
+ ); + })} + + ); + }; + + render() { + const { theme } = this.props; + const colorPickerTheme = theme || GrafanaTheme.Dark; + + return ( +
+
+
+ Colors +
+
+ Custom +
+ {this.renderCustomPickerTabs()}
+ +
{this.renderPicker()}
); } diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx new file mode 100644 index 00000000000..91c4f21642a --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx @@ -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 { + color: string; + label?: string; + variant?: ColorSwatchVariant; + isSelected?: boolean; +} + +export const ColorSwatch: FunctionComponent = ({ + 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 ( +
+
+ {variant === ColorSwatchVariant.Large && {label}} +
+ ); +}; + +interface NamedColorsGroupProps extends Themeable { + colors: ColorDefinition[]; + selectedColor?: Color; + onColorSelect: ColorChangeHandler; + key?: string; +} + +const NamedColorsGroup: FunctionComponent = ({ + colors, + selectedColor, + onColorSelect, + theme, + ...otherProps +}) => { + const primaryColor = find(colors, color => !!color.isPrimary); + + return ( +
+ {primaryColor && ( + onColorSelect(primaryColor)} + theme={theme} + /> + )} +
+ {colors.map( + color => + !color.isPrimary && ( +
+ onColorSelect(color)} + theme={theme} + /> +
+ ) + )} +
+
+ ); +}; + +export default NamedColorsGroup; diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx new file mode 100644 index 00000000000..af5de3b2a2d --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx @@ -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 ( + + {(selectedColor, updateSelectedColor) => { + return ; + }} + + ); +}).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 ( + + {(selectedColor, updateSelectedColor) => { + return ; + }} + + ); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx new file mode 100644 index 00000000000..171d26f5c56 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx @@ -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( {}} />); + selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name); + expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark); + + wrapper.unmount(); + wrapper = mount( {}} />); + selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name); + expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light); + }); + + it('should render dar variant of provided color when theme not provided', () => { + wrapper = mount( {}} />); + selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name); + expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx new file mode 100644 index 00000000000..7bdd3c690b9 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx @@ -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( + { + onChange(color.name); + }} + /> + ); + }); + + return ( +
+ {swatches} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx deleted file mode 100644 index 7c3848f6868..00000000000 --- a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx +++ /dev/null @@ -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 { - 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 = ( - - ); - 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 ( -
(this.pickerElem = e)} onClick={this.onClickToOpen}> - {children} -
- ); - } -} diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx index 541a77ddabc..3fa7a1f4a45 100644 --- a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx @@ -1,23 +1,44 @@ -import React from 'react'; -import { ColorPickerPopover } from './ColorPickerPopover'; +import React, { FunctionComponent } from 'react'; -export interface SeriesColorPickerPopoverProps { - color: string; +import { ColorPickerPopover } from './ColorPickerPopover'; +import { ColorPickerProps } from './ColorPicker'; +import { PopperContentProps } from '../Tooltip/PopperController'; +import { Switch } from '../Switch/Switch'; + +export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopperContentProps { yaxis?: number; - onColorChange: (color: string) => void; onToggleAxis?: () => void; } -export class SeriesColorPickerPopover extends React.PureComponent { - render() { - return ( -
- {this.props.yaxis && } - -
- ); - } -} +export const SeriesColorPickerPopover: FunctionComponent = props => { + const { yaxis, onToggleAxis, color, ...colorPickerProps } = props; + + return ( + ( + { + if (onToggleAxis) { + onToggleAxis(); + } + }} + /> + ), + }, + }} + /> + ); +}; interface AxisSelectorProps { yaxis: number; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx new file mode 100644 index 00000000000..407564cdfb2 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx @@ -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 ( + + {(selectedColor, updateSelectedColor) => { + return ; + }} + + ); +}); diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx new file mode 100644 index 00000000000..cf001cf5629 --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx @@ -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) => ( + +); + +// @ts-ignore +const SpectrumPicker = CustomPicker(({ rgb, hsl, onChange, theme }) => { + return ( +
+
+
+
+ {/* + // @ts-ignore */} + +
+
+ {/* + // @ts-ignore */} + +
+
+ +
+ {/* + // @ts-ignore */} + +
+
+
+ ); +}); + +const SpectrumPalette: React.FunctionComponent = ({ color, onChange, theme }) => { + return ( +
+ { + onChange(tinycolor(a.rgb).toString()); + }} + theme={theme} + /> + +
+ ); +}; + +export default SpectrumPalette; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx new file mode 100644 index 00000000000..d0b2cbc4bff --- /dev/null +++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { GrafanaTheme, Themeable } from '../../types'; + +export interface SpectrumPalettePointerProps extends Themeable { + direction?: string; +} + +const SpectrumPalettePointer: React.FunctionComponent = ({ + 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 ( +
+
+
+
+ ); +}; + +export default SpectrumPalettePointer; diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx deleted file mode 100644 index a225db09046..00000000000 --- a/packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx +++ /dev/null @@ -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 { - 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
; - } -} diff --git a/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss index c0643342307..46eed5f7ff1 100644 --- a/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss +++ b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss @@ -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 { background: inherit; border: none; color: inherit; padding: 0; + border-radius: 10px; } .sp-replacer:hover, @@ -35,10 +199,22 @@ margin: 0; float: left; 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 { - padding-bottom: 10px; + padding-bottom: $arrowSize; padding-left: 6px; } @@ -47,3 +223,18 @@ 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; + } + } +} diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index eb50944ad35..40f6c6c3c37 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -7,10 +7,12 @@ interface Props { autoHide?: boolean; autoHideTimeout?: number; autoHideDuration?: number; - autoMaxHeight?: string; + autoHeightMax?: string; hideTracksWhenNotNeeded?: boolean; + renderTrackHorizontal?: React.FunctionComponent; + renderTrackVertical?: React.FunctionComponent; scrollTop?: number; - setScrollTop: (value: React.MouseEvent) => void; + setScrollTop: (event: any) => void; autoHeightMin?: number | string; } @@ -20,13 +22,13 @@ interface Props { export class CustomScrollbar extends PureComponent { static defaultProps: Partial = { customClassName: 'custom-scrollbars', - autoHide: true, + autoHide: false, autoHideTimeout: 200, autoHideDuration: 200, - autoMaxHeight: '100%', - hideTracksWhenNotNeeded: false, setScrollTop: () => {}, - autoHeightMin: '0' + hideTracksWhenNotNeeded: false, + autoHeightMin: '0', + autoHeightMax: '100%', }; private ref: React.RefObject; @@ -45,7 +47,7 @@ export class CustomScrollbar extends PureComponent { } else { ref.scrollTop(this.props.scrollTop); } - } + } } componentDidMount() { @@ -57,18 +59,34 @@ export class CustomScrollbar extends PureComponent { } render() { - const { customClassName, children, autoMaxHeight } = this.props; + const { + customClassName, + children, + autoHeightMax, + autoHeightMin, + setScrollTop, + autoHide, + autoHideTimeout, + hideTracksWhenNotNeeded, + renderTrackHorizontal, + renderTrackVertical, + } = this.props; return (
} - renderTrackVertical={props =>
} + autoHeightMax={autoHeightMax} + autoHeightMin={autoHeightMin} + renderTrackHorizontal={renderTrackHorizontal || (props =>
)} + renderTrackVertical={renderTrackVertical || (props =>
)} renderThumbHorizontal={props =>
} renderThumbVertical={props =>
} renderView={props =>
} diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap index aabe3dd98c5..60b4a2e0aa5 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -7,7 +7,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` Object { "height": "auto", "maxHeight": "100%", - "minHeight": 0, + "minHeight": "0", "overflow": "hidden", "position": "relative", "width": "100%", @@ -24,7 +24,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` "marginBottom": 0, "marginRight": 0, "maxHeight": "calc(100% + 0px)", - "minHeight": 0, + "minHeight": "calc(0 + 0px)", "overflow": "scroll", "position": "relative", "right": undefined, diff --git a/packages/grafana-ui/src/components/DeleteButton/DeleteButton.story.tsx b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.story.tsx new file mode 100644 index 00000000000..e086663f065 --- /dev/null +++ b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.story.tsx @@ -0,0 +1,24 @@ +import React, { FunctionComponent } from 'react'; +import { storiesOf } from '@storybook/react'; +import { DeleteButton } from '@grafana/ui'; + +const CenteredStory: FunctionComponent<{}> = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +storiesOf('UI/DeleteButton', module) + .addDecorator(story => {story()}) + .add('default', () => { + return {}} />; + }); diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index e1f634b8071..d4d8442593e 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -1,10 +1,11 @@ import React, { PureComponent } from 'react'; import $ from 'jquery'; -import { BasicGaugeColor, ThemeName, ThemeNames, Threshold, ValueMapping } from '../../types'; -import { TimeSeriesVMs } from '../../types'; -import { getValueFormat } from '../../utils'; -import { getMappedValue, TimeSeriesValue } from '../../utils/valueMappings'; +import { ValueMapping, Threshold, BasicGaugeColor, TimeSeriesVMs, GrafanaTheme } from '../../types'; +import { getMappedValue } from '../../utils/valueMappings'; +import { getColorFromHexRgbOrName, getValueFormat } from '../../utils'; + +type TimeSeriesValue = string | number | null; export interface Props { decimals: number; @@ -21,7 +22,7 @@ export interface Props { suffix: string; unit: string; width: number; - theme?: ThemeName; + theme?: GrafanaTheme; } const FONT_SCALE = 1; @@ -40,7 +41,7 @@ export class Gauge extends PureComponent { thresholds: [], unit: 'none', stat: 'avg', - theme: ThemeNames.Dark, + theme: GrafanaTheme.Dark, }; componentDidMount() { @@ -73,29 +74,29 @@ export class Gauge extends PureComponent { } getFontColor(value: TimeSeriesValue) { - const { thresholds } = this.props; + const { thresholds, theme } = this.props; 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]; if (atThreshold) { - return atThreshold.color; + return getColorFromHexRgbOrName(atThreshold.color, theme); } const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value); if (belowThreshold.length > 0) { const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0]; - return nearestThreshold.color; + return getColorFromHexRgbOrName(nearestThreshold.color, theme); } return BasicGaugeColor.Red; } 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 lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1]; @@ -103,13 +104,13 @@ export class Gauge extends PureComponent { return [ ...thresholdsSortedByIndex.map(threshold => { if (threshold.index === 0) { - return { value: minValue, color: threshold.color }; + return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme) }; } 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 { const formattedValue = this.formatValue(value) as string; 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 gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio; const thresholdMarkersWidth = gaugeWidth / 5; diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index 5246c7cbf15..6d83968d546 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -61,7 +61,7 @@ interface AsyncProps { export const MenuList = (props: any) => { return ( - {props.children} + {props.children} ); }; diff --git a/packages/grafana-ui/src/components/Select/SelectOption.test.tsx b/packages/grafana-ui/src/components/Select/SelectOption.test.tsx index 1876e438d75..0a5a8864d64 100644 --- a/packages/grafana-ui/src/components/Select/SelectOption.test.tsx +++ b/packages/grafana-ui/src/components/Select/SelectOption.test.tsx @@ -3,6 +3,7 @@ import renderer from 'react-test-renderer'; import SelectOption from './SelectOption'; import { OptionProps } from 'react-select/lib/components/Option'; +// @ts-ignore const model: OptionProps = { data: jest.fn(), cx: jest.fn(), diff --git a/public/app/core/components/Switch/Switch.tsx b/packages/grafana-ui/src/components/Switch/Switch.tsx similarity index 76% rename from public/app/core/components/Switch/Switch.tsx rename to packages/grafana-ui/src/components/Switch/Switch.tsx index d53a3d68878..36475e55acb 100644 --- a/public/app/core/components/Switch/Switch.tsx +++ b/packages/grafana-ui/src/components/Switch/Switch.tsx @@ -4,10 +4,11 @@ import _ from 'lodash'; export interface Props { label: string; checked: boolean; + className?: string; labelClass?: string; switchClass?: string; transparent?: boolean; - onChange: (event) => any; + onChange: (event?: React.SyntheticEvent) => void; } export interface State { @@ -19,20 +20,21 @@ export class Switch extends PureComponent { id: _.uniqueId(), }; - internalOnChange = event => { + internalOnChange = (event: React.FormEvent) => { event.stopPropagation(); - this.props.onChange(event); + + this.props.onChange(); }; 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 labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`; const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`; return ( -