Merge branch 'master' into tooltip-restyling

This commit is contained in:
Torkel Ödegaard 2019-01-29 11:02:40 +01:00
commit 0e2310de8e
365 changed files with 12968 additions and 6206 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra
Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
## 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.

View File

@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan.
### Short term (1-2 months)
- PRs & Bugs
- Multi-Stat panel
- React Panel Support
- React Query Editor Support
- Metrics & Log Explore UI
- Grafana UI library shared between grafana & plugins
- Seperate visualization from panels
- More reuse between Explore & dashboard
- Explore logging support for more data sources
### Mid term (2-4 months)
- React Panels
- Change visualization (panel type) on the fly.
- Templating Query Editor UI Plugin hook
- Backend plugins
- Drilldown links
- Dashboards as code workflows
- React migration
- New panels
### Long term (4 - 8 months)
- Alerting improvements (silence, per series tracking, etc)
- Progress on React migration
### In a distant future far far away
- Meta queries

View File

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

View File

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

View File

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

View File

@ -102,6 +102,22 @@ log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -231,3 +231,187 @@ By default Grafana will delete dashboards in the database if the file is removed
> which leads to problems if you re-use settings that are supposed to be unique.
> 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 |

View File

@ -110,6 +110,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
*$__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+).

View File

@ -144,6 +144,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
*$__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+).

View File

@ -154,6 +154,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamps. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183*
*$__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+).

View File

@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"defaultRegion": "us-west-1"
},
"secureJsonData": {
"accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded
"accessKey": "Ol4pIDpeKSA6XikgOl4p",
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs"
}
}
```

View File

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

View File

@ -391,6 +391,12 @@ value is `true`.
If you want to track Grafana usage via Google analytics specify *your* Universal
Analytics ID here. By default this feature is disabled.
### check_for_updates
Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used
in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor
send any sensitive information.
<hr />
## [dashboards]

View File

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

View File

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

View File

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

View File

@ -5,19 +5,20 @@
"main": "src/index.ts",
"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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,112 +1,129 @@
import React from 'react';
import $ 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<T> extends ColorPickerProps, PopperContentProps {
customPickers?: T;
}
export class ColorPickerPopover extends React.Component<Props, any> {
pickerNavElem: any;
type PickerType = 'palette' | 'spectrum';
constructor(props: Props) {
interface CustomPickersDescriptor {
[key: string]: {
tabComponent: React.ComponentType<ColorPickerProps>;
name: string;
};
}
interface State<T> {
activePicker: PickerType | keyof T;
}
export class ColorPickerPopover<T extends CustomPickersDescriptor> extends React.Component<Props<T>, State<T>> {
constructor(props: Props<T>) {
super(props);
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 <SpectrumPalette color={color} onChange={this.handleChange} theme={theme} />;
case 'palette':
return <NamedColorsPalette color={getColorName(color, theme)} onChange={this.handleChange} theme={theme} />;
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 = (
<div id="palette">
<ColorPalette color={this.state.color} onColorSelect={this.sampleColorSelected.bind(this)} />
</div>
);
const spectrumTab = (
<div id="spectrum">
<SpectrumPicker color={this.state.color} onColorSelect={this.spectrumColorSelected.bind(this)} options={{}} />
</div>
);
const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab;
renderCustomPickerTabs = () => {
const { customPickers } = this.props;
if (!customPickers) {
return null;
}
return (
<div className="gf-color-picker">
<ul className="nav nav-tabs" id="colorpickernav" ref={this.setPickerNavElem.bind(this)}>
<li className="gf-tabs-item-colorpicker">
<a href="#palette" data-toggle="tab">
Colors
</a>
</li>
<li className="gf-tabs-item-colorpicker">
<a href="#spectrum" data-toggle="tab">
Custom
</a>
</li>
</ul>
<div className="gf-color-picker__body">{currentTab}</div>
<div>
<input
className="gf-form-input gf-form-input--small"
value={this.state.colorString}
onChange={this.onColorStringChange.bind(this)}
onBlur={this.onColorStringBlur.bind(this)}
/>
<>
{Object.keys(customPickers).map(key => {
return (
<div
className={this.getTabClassName(key)}
onClick={this.handleTabChange(key)}
key={key}
>
{customPickers[key].name}
</div>
);
})}
</>
);
};
render() {
const { theme } = this.props;
const colorPickerTheme = theme || GrafanaTheme.Dark;
return (
<div className={`ColorPickerPopover ColorPickerPopover--${colorPickerTheme}`}>
<div className="ColorPickerPopover__tabs">
<div
className={this.getTabClassName('palette')}
onClick={this.handleTabChange('palette')}
>
Colors
</div>
<div
className={this.getTabClassName('spectrum')}
onClick={this.handleTabChange('spectrum')}
>
Custom
</div>
{this.renderCustomPickerTabs()}
</div>
<div className="ColorPickerPopover__content">{this.renderPicker()}</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,172 @@
$arrowSize: 15px;
.ColorPicker {
@extend .popper;
font-size: 12px;
}
.ColorPicker__arrow {
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 0px;
&[data-placement^='top'] {
border-width: $arrowSize $arrowSize 0 $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
bottom: -$arrowSize;
left: calc(50%-#{$arrowSize});
padding-top: $arrowSize;
}
&[data-placement^='bottom'] {
border-width: 0 $arrowSize $arrowSize $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
top: 0;
left: calc(50%-#{$arrowSize});
}
&[data-placement^='bottom-start'] {
border-width: 0 $arrowSize $arrowSize $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
top: 0;
left: $arrowSize;
}
&[data-placement^='bottom-end'] & {
border-width: 0 $arrowSize $arrowSize $arrowSize;
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
top: 0;
left: calc(100% -$arrowSize);
}
&[data-placement^='right'] {
border-width: $arrowSize $arrowSize $arrowSize 0;
border-left-color: transparent;
border-top-color: transparent;
border-bottom-color: transparent;
left: 0;
top: calc(50%-#{$arrowSize});
}
&[data-placement^='left'] {
border-width: $arrowSize 0 $arrowSize $arrowSize;
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
right: -$arrowSize;
top: calc(50%-#{$arrowSize});
}
}
.ColorPicker__arrow--light {
border-color: #ffffff;
}
.ColorPicker__arrow--dark {
border-color: #1e2028;
}
// Top
.ColorPicker[data-placement^='top'] {
padding-bottom: $arrowSize;
}
// Bottom
.ColorPicker[data-placement^='bottom'] {
padding-top: $arrowSize;
}
.ColorPicker[data-placement^='bottom-start'] {
padding-top: $arrowSize;
}
.ColorPicker[data-placement^='bottom-end'] {
padding-top: $arrowSize;
}
// Right
.ColorPicker[data-placement^='right'] {
padding-left: $arrowSize;
}
// Left
.ColorPicker[data-placement^='left'] {
padding-right: $arrowSize;
}
.ColorPickerPopover {
border-radius: 3px;
}
.ColorPickerPopover--light {
color: black;
background: linear-gradient(180deg, #ffffff 0%, #f7f8fa 104.25%);
box-shadow: 0px 2px 4px #dde4ed, 0px 0px 2px #dde4ed;
}
.ColorPickerPopover--dark {
color: #d8d9da;
background: linear-gradient(180deg, #1e2028 0%, #161719 104.25%);
box-shadow: 0px 2px 4px #000000, 0px 0px 2px #000000;
.ColorPickerPopover__tab {
background: #303133;
color: white;
cursor: pointer;
}
.ColorPickerPopover__tab--active {
background: none;
}
}
.ColorPickerPopover__content {
width: 336px;
min-height: 184px;
padding: 24px;
}
.ColorPickerPopover__tabs {
display: flex;
width: 100%;
border-radius: 3px 3px 0 0;
overflow: hidden;
}
.ColorPickerPopover__tab {
width: 50%;
text-align: center;
padding: 8px 0;
background: #dde4ed;
}
.ColorPickerPopover__tab--active {
background: white;
}
.ColorPicker__axisSwitch {
width: 100%;
}
.ColorPicker__axisSwitchLabel {
display: flex;
flex-grow: 1;
}
.sp-replacer {
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;
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,10 +1,14 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
import { ValueMapping, Threshold, BasicGaugeColor } from '../../types/panel';
import { TimeSeriesVMs } from '../../types/series';
import { GrafanaTheme } from '../../types';
import { getMappedValue } from '../../utils/valueMappings';
import { getValueFormat } from '../../utils/valueFormats/valueFormats';
import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
type TimeSeriesValue = string | number | null;
export interface Props {
decimals: number;
@ -21,7 +25,7 @@ export interface Props {
suffix: string;
unit: string;
width: number;
theme?: ThemeName;
theme?: GrafanaTheme;
}
export class Gauge extends PureComponent<Props> {
@ -38,7 +42,7 @@ export class Gauge extends PureComponent<Props> {
thresholds: [],
unit: 'none',
stat: 'avg',
theme: ThemeNames.Dark,
theme: GrafanaTheme.Dark,
};
componentDidMount() {
@ -71,29 +75,29 @@ export class Gauge extends PureComponent<Props> {
}
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];
@ -101,13 +105,13 @@ export class Gauge extends PureComponent<Props> {
const formattedThresholds = [
...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) },
];
return formattedThresholds;
@ -135,7 +139,7 @@ export class Gauge extends PureComponent<Props> {
}
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 fontScale = parseInt('80', 10) / 100;
const fontSize = Math.min(dimension / 5, 100) * fontScale;
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;

View File

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

View File

@ -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<any> = {
data: jest.fn(),
cx: jest.fn(),

View File

@ -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<HTMLInputElement>) => void;
}
export interface State {
@ -19,20 +20,21 @@ export class Switch extends PureComponent<Props, State> {
id: _.uniqueId(),
};
internalOnChange = event => {
internalOnChange = (event: React.FormEvent<HTMLInputElement>) => {
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 (
<label htmlFor={labelId} className="gf-form gf-form-switch-container">
<label htmlFor={labelId} className={`gf-form gf-form-switch-container ${className}`}>
{label && <div className={labelClassName}>{label}</div>}
<div className={switchClassName}>
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
$popper-margin-from-ref: 5px;
@mixin popper-theme($backgroundColor, $arrowColor) {
background: $backgroundColor;
.popper__arrow {
@ -24,6 +23,10 @@ $popper-margin-from-ref: 5px;
color: $tooltipColor;
font-weight: 500;
.popper__arrow {
border-color: $tooltipBackground;
}
// Themes
&.popper__background--error {
@include popper-theme($tooltipBackgroundError, $tooltipBackgroundError);
@ -43,10 +46,6 @@ $popper-margin-from-ref: 5px;
margin: 0px;
}
.popper__arrow {
border-color: $tooltipBackground;
}
// Top
.popper[data-placement^='top'] {
padding-bottom: $popper-margin-from-ref;

View File

@ -14,12 +14,12 @@ export { FormLabel } from './FormLabel/FormLabel';
export { FormField } from './FormField/FormField';
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
export { ColorPicker } from './ColorPicker/ColorPicker';
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
export { Graph } from './Graph/Graph';
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
export { Gauge } from './Gauge/Gauge';
export { Switch } from './Switch/Switch';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,14 @@
import { select } from '@storybook/addon-knobs';
import { GrafanaTheme } from '../../types';
export const getThemeKnob = (defaultTheme: GrafanaTheme = GrafanaTheme.Dark) => {
return select(
'Theme',
{
Default: defaultTheme,
Light: GrafanaTheme.Light,
Dark: GrafanaTheme.Dark,
},
defaultTheme
);
};

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import (
"net/http/httptest"
"path/filepath"
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
@ -95,13 +94,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
}
type scenarioContext struct {
m *macaron.Macaron
context *m.ReqContext
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
m *macaron.Macaron
context *m.ReqContext
resp *httptest.ResponseRecorder
handlerFunc handlerFunc
defaultHandler macaron.Handler
req *http.Request
url string
userAuthTokenService *fakeUserAuthTokenService
}
func (sc *scenarioContext) exec() {
@ -123,8 +123,30 @@ func setupScenarioContext(url string) *scenarioContext {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
sc.m.Use(middleware.GetContextHandler())
sc.m.Use(middleware.Sessioner(&session.Options{}, 0))
sc.userAuthTokenService = newFakeUserAuthTokenService()
sc.m.Use(middleware.GetContextHandler(sc.userAuthTokenService))
return sc
}
type fakeUserAuthTokenService struct {
initContextWithTokenProvider func(ctx *m.ReqContext, orgID int64) bool
}
func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
return &fakeUserAuthTokenService{
initContextWithTokenProvider: func(ctx *m.ReqContext, orgID int64) bool {
return false
},
}
}
func (s *fakeUserAuthTokenService) InitContextWithToken(ctx *m.ReqContext, orgID int64) bool {
return s.initContextWithTokenProvider(ctx, orgID)
}
func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqContext) error {
return nil
}
func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}

View File

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

View File

@ -11,14 +11,8 @@ import (
"path"
"time"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
macaron "gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/api/live"
"github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
@ -27,11 +21,16 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cache"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
macaron "gopkg.in/macaron.v1"
)
func init() {
@ -49,13 +48,14 @@ type HTTPServer struct {
streamManager *live.StreamManager
httpSrv *http.Server
RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
HooksService *hooks.HooksService `inject:""`
CacheService *cache.CacheService `inject:""`
DatasourceCache datasources.CacheService `inject:""`
RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
HooksService *hooks.HooksService `inject:""`
CacheService *cache.CacheService `inject:""`
DatasourceCache datasources.CacheService `inject:""`
AuthTokenService auth.UserAuthTokenService `inject:""`
}
func (hs *HTTPServer) Init() error {
@ -65,6 +65,8 @@ func (hs *HTTPServer) Init() error {
hs.macaron = hs.newMacaron()
hs.registerRoutes()
session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
return nil
}
@ -223,8 +225,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
m.Use(hs.healthHandler)
m.Use(hs.metricsEndpoint)
m.Use(middleware.GetContextHandler())
m.Use(middleware.Sessioner(&setting.SessionOptions, setting.SessionConnMaxLifetime))
m.Use(middleware.GetContextHandler(hs.AuthTokenService))
m.Use(middleware.OrgRedirect())
// needs to be after context handler

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"gopkg.in/macaron.v1"
)

View File

@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
. "github.com/smartystreets/goconvey/convey"
)
@ -15,18 +14,15 @@ func TestOrgRedirectMiddleware(t *testing.T) {
Convey("Can redirect to correct org", t, func() {
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
c.Session.Set(session.SESS_KEY_USERID, int64(12))
}).exec()
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return nil
})
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil
})
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
ctx.IsSignedIn = true
return true
}
sc.m.Get("/", sc.defaultHandler)
sc.fakeReq("GET", "/?orgId=3").exec()
@ -37,14 +33,16 @@ func TestOrgRedirectMiddleware(t *testing.T) {
})
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
sc.fakeReq("GET", "/").handler(func(c *m.ReqContext) {
c.Session.Set(session.SESS_KEY_USERID, int64(12))
}).exec()
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return fmt.Errorf("")
})
sc.userAuthTokenService.initContextWithTokenProvider = func(ctx *m.ReqContext, orgId int64) bool {
ctx.SignedInUser = &m.SignedInUser{OrgId: 1, UserId: 12}
ctx.IsSignedIn = true
return true
}
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 1, UserId: 12}
return nil

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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