diff --git a/.circleci/config.yml b/.circleci/config.yml
index dba6c5f8bd0..8144956773b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -19,7 +19,7 @@ version: 2
jobs:
mysql-integration-test:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
- image: circleci/mysql:5.6-ram
environment:
MYSQL_ROOT_PASSWORD: rootpass
@@ -39,7 +39,7 @@ jobs:
postgres-integration-test:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
- image: circleci/postgres:9.3-ram
environment:
POSTGRES_USER: grafanatest
@@ -74,27 +74,16 @@ 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
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
- - run: 'go get -u github.com/alecthomas/gometalinter'
- - run: 'go get -u github.com/tsenart/deadcode'
- - run: 'go get -u github.com/jgautheron/goconst/cmd/goconst'
- - run: 'go get -u github.com/gordonklaus/ineffassign'
- - run: 'go get -u honnef.co/go/tools/cmd/megacheck'
- - run: 'go get -u github.com/opennota/check/cmd/structcheck'
- - run: 'go get -u github.com/mdempsky/unconvert'
- - run: 'go get -u github.com/opennota/check/cmd/varcheck'
- run:
- name: run linters
- command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=goconst --enable=gofmt --enable=ineffassign --enable=megacheck --enable=structcheck --enable=unconvert --enable=varcheck ./...'
- - run:
- name: run go vet
- command: 'go vet ./pkg/...'
+ name: Gometalinter tests
+ command: './scripts/gometalinter.sh'
test-frontend:
docker:
@@ -117,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
@@ -127,7 +116,7 @@ jobs:
build-all:
docker:
- - image: grafana/build-container:1.2.1
+ - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -158,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 .'
@@ -169,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
@@ -200,51 +185,51 @@ jobs:
- dist/grafana*
grafana-docker-master:
- docker:
- - image: docker:stable-git
+ machine:
+ image: circleci/classic:201808-01
steps:
- checkout
- attach_workspace:
at: .
- - setup_remote_docker
- run: docker info
- - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
+ - run: docker run --privileged linuxkit/binfmt:v0.6
+ - run: cp dist/grafana-latest.linux-*.tar.gz packaging/docker
- run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}"
- - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
+ - run: rm packaging/docker/grafana-latest.linux-*.tar.gz
- run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
- run: cd packaging/docker && ./build-enterprise.sh "master"
grafana-docker-pr:
- docker:
- - image: docker:stable-git
+ machine:
+ image: circleci/classic:201808-01
steps:
- checkout
- attach_workspace:
at: .
- - setup_remote_docker
- run: docker info
- - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
+ - run: docker run --privileged linuxkit/binfmt:v0.6
+ - run: cp dist/grafana-latest.linux-*.tar.gz packaging/docker
- run: cd packaging/docker && ./build.sh "${CIRCLE_SHA1}"
grafana-docker-release:
- docker:
- - image: docker:stable-git
- steps:
- - checkout
- - attach_workspace:
- at: .
- - setup_remote_docker
- - run: docker info
- - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
- - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}"
- - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
- - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
- - run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}"
+ machine:
+ image: circleci/classic:201808-01
+ steps:
+ - checkout
+ - attach_workspace:
+ at: .
+ - run: docker info
+ - run: docker run --privileged linuxkit/binfmt:v0.6
+ - run: cp dist/grafana-latest.linux-*.tar.gz packaging/docker
+ - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}"
+ - run: rm packaging/docker/grafana-latest.linux-*.tar.gz
+ - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
+ - run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}"
build-enterprise:
docker:
- - image: grafana/build-container:1.2.1
+ - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -276,7 +261,7 @@ jobs:
build-all-enterprise:
docker:
- - image: grafana/build-container:1.2.1
+ - image: grafana/build-container:1.2.3
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -323,7 +308,7 @@ jobs:
deploy-enterprise-master:
docker:
- - image: grafana/grafana-ci-deploy:1.0.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
- attach_workspace:
at: .
@@ -346,8 +331,9 @@ jobs:
deploy-enterprise-release:
docker:
- - image: grafana/grafana-ci-deploy:1.0.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
+ - checkout
- attach_workspace:
at: .
- run:
@@ -365,10 +351,20 @@ jobs:
- run:
name: Deploy to Grafana.com
command: './scripts/build/publish.sh --enterprise'
+ - run:
+ name: Load GPG private key
+ command: './scripts/build/load-signing-key.sh'
+ - run:
+ name: Update Debian repository
+ command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
+ - run:
+ name: Update RPM repository
+ command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
+
deploy-master:
docker:
- - image: grafana/grafana-ci-deploy:1.0.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
- attach_workspace:
at: .
@@ -394,12 +390,14 @@ 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:
- - image: grafana/grafana-ci-deploy:1.0.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
+ - checkout
- attach_workspace:
at: .
- run:
@@ -417,6 +415,15 @@ jobs:
- run:
name: Deploy to Grafana.com
command: './scripts/build/publish.sh'
+ - run:
+ name: Load GPG private key
+ command: './scripts/build/load-signing-key.sh'
+ - run:
+ name: Update Debian repository
+ command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
+ - run:
+ name: Update RPM repository
+ command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
workflows:
version: 2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 867253ccd18..7164f5d99a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,25 +1,74 @@
-# 5.5.0 (unreleased)
+# 6.0.0-beta2 (unreleased)
+
+### Minor
+* **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
+
+# 6.0.0-beta1 (2019-01-30)
### 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)
+* **Explore**: A whole new way to do ad-hoc metric queries and exploration. Split view in half and compare metrics & logs and much much more. [Read more here](http://docs.grafana.org/features/explore/)
### Minor
+* **Templating**: Built in time range variables `$__from` and `$__to`, [#1909](https://github.com/grafana/grafana/issues/1909)
+* **Alerting**: Use separate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
+* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
+* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
+* **Postgres/MySQL/MSSQL**: Nanosecond timestamp support (`$__unixEpochNanoFilter`, `$__unixEpochNanoFrom`, `$__unixEpochNanoTo`) [#14711](https://github.com/grafana/grafana/pull/14711), thx [@ander26](https://github.com/ander26)
+* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
-* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
-* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
-* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
+* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
+* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
+* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
+* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
+* **Templating**: Add percentencode formatting to variable interpolation to be used mainly for url escaping [#12764](https://github.com/grafana/grafana/issues/12764), thx [@cxcv](https://github.com/cxcv)
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
+* **Units**: Add 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)
+* **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)
+* **Database**: Support defining additonal database connection string args when using `url` property in database settings [#14709](https://github.com/grafana/grafana/pull/14709), thx [@tpetr](https://github.com/tpetr)
+* **Stackdriver**: crossSeriesAggregation not being sent with the query [#15129](https://github.com/grafana/grafana/issues/15129), thx [@Legogris](https://github.com/Legogris)
### 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)
+* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
+
+### 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`.
+* **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)
+
+### Tech
+
+* **Docker**: Build and publish docker images for armv7 and arm64 [#14617](https://github.com/grafana/grafana/pull/14617), thx [@johanneswuerbach](https://github.com/johanneswuerbach)
+* **Backend**: Upgrade to golang 1.11.4 [#14580](https://github.com/grafana/grafana/issues/14580)
+* **MySQL** only update session in mysql database when required [#14540](https://github.com/grafana/grafana/pull/14540)
+
+### Bug fixes
+* **Alerting** Invalid frequency causes division by zero in alert scheduler [#14810](https://github.com/grafana/grafana/issues/14810)
+* **Dashboard** Dashboard links do not update when time range changes [#14493](https://github.com/grafana/grafana/issues/14493)
+* **Limits** Support more than 1000 datasources per org [#13883](https://github.com/grafana/grafana/issues/13883)
+* **Backend** fix signed in user for orgId=0 result should return active org id [#14574](https://github.com/grafana/grafana/pull/14574)
+* **Provisioning** Adds orgId to user dto for provisioned dashboards [#14678](https://github.com/grafana/grafana/pull/14678)
# 5.4.2 (2018-12-13)
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 94daa2dc4d5..62f610cab59 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -2,7 +2,7 @@
## Our Pledge
-In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
+In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
diff --git a/Dockerfile b/Dockerfile
index c3af89b6092..c3e59c8048e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Golang build container
-FROM golang:1.11.4
+FROM golang:1.11.5
WORKDIR $GOPATH/src/github.com/grafana/grafana
@@ -19,11 +19,13 @@ COPY package.json package.json
RUN go run build.go build
# Node build container
-FROM node:8
+FROM node:10.14.2
WORKDIR /usr/src/app/
COPY package.json yarn.lock ./
+COPY packages packages
+
RUN yarn install --pure-lockfile --no-progress
COPY Gruntfile.js tsconfig.json tslint.json ./
diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md
index 168b21dbd88..47743e5fbf9 100644
--- a/PLUGIN_DEV.md
+++ b/PLUGIN_DEV.md
@@ -1,7 +1,7 @@
# Plugin Development
-This document is not meant as complete guide for developing plugins but more as a changelog for changes in
-Grafana that can impact plugin development. When ever you as plugin author encounter an issue with your plugin after
+This document is not meant as a complete guide for developing plugins but more as a changelog for changes in
+Grafana that can impact plugin development. Whenever you as a plugin author encounter an issue with your plugin after
upgrading Grafana please check here before creating an issue.
## Links
diff --git a/README.md b/README.md
index 1ce4ffbe109..3df6a383e05 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ If you have any problems please read the [troubleshooting guide](http://docs.gra
Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
## Run from master
-If you want to build a package yourself, or contribute - Here is a guide for how to do that. You can always find
+If you want to build a package yourself, or contribute - here is a guide for how to do that. You can always find
the latest master builds [here](https://grafana.com/grafana/download)
### Dependencies
@@ -71,7 +71,7 @@ Open grafana in your browser (default: `http://localhost:3000`) and login with a
### Building a Docker image
-There are two different ways to build a Grafana docker image. If you're machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker.
+There are two different ways to build a Grafana docker image. If your machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker.
Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev`
@@ -90,7 +90,7 @@ Choose this option to build on platforms other than linux/amd64 and/or not have
The resulting image will be tagged as `grafana/grafana:dev`
-Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Preferences -> Advanced), otherwize you may faild at `grunt build`
+Notice: If you are using Docker for MacOS, be sure to set the memory limit to be larger than 2 GiB (at docker -> Preferences -> Advanced), otherwise `grunt build` may fail.
### Dev config
@@ -129,9 +129,11 @@ 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
-the kickass metrics & devops dashboard we all dream about!
+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.
## Plugin development
diff --git a/ROADMAP.md b/ROADMAP.md
index 891bc9f790b..b5e62578475 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
diff --git a/appveyor.yml b/appveyor.yml
index 5f97784dd38..ccf9b5a06e1 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment:
nodejs_version: "8"
GOPATH: C:\gopath
- GOVERSION: 1.11.4
+ GOVERSION: 1.11.5
install:
- rmdir c:\go /s /q
diff --git a/build.go b/build.go
index 9d5216de1d0..ebe240d97ef 100644
--- a/build.go
+++ b/build.go
@@ -46,6 +46,8 @@ var (
binaries []string = []string{"grafana-server", "grafana-cli"}
isDev bool = false
enterprise bool = false
+ skipRpmGen bool = false
+ skipDebGen bool = false
)
func main() {
@@ -67,6 +69,8 @@ func main() {
flag.BoolVar(&enterprise, "enterprise", enterprise, "Build enterprise version of Grafana")
flag.StringVar(&buildIdRaw, "buildId", "0", "Build ID from CI system")
flag.BoolVar(&isDev, "dev", isDev, "optimal for development, skips certain steps")
+ flag.BoolVar(&skipRpmGen, "skipRpm", skipRpmGen, "skip rpm package generation (default: false)")
+ flag.BoolVar(&skipDebGen, "skipDeb", skipDebGen, "skip deb package generation (default: false)")
flag.Parse()
buildId = shortenBuildId(buildIdRaw)
@@ -164,6 +168,9 @@ func makeLatestDistCopies() {
"_amd64.deb": "dist/grafana_latest_amd64.deb",
".x86_64.rpm": "dist/grafana-latest-1.x86_64.rpm",
".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
+ ".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
+ ".linux-armv6.tar.gz": "dist/grafana-latest.linux-armv6.tar.gz",
+ ".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
}
for _, file := range files {
@@ -237,6 +244,8 @@ func createDebPackages() {
previousPkgArch := pkgArch
if pkgArch == "armv7" {
pkgArch = "armhf"
+ } else if pkgArch == "armv6" {
+ pkgArch = "armel"
}
createPackage(linuxPackageOptions{
packageType: "deb",
@@ -287,8 +296,13 @@ func createRpmPackages() {
}
func createLinuxPackages() {
- createDebPackages()
- createRpmPackages()
+ if !skipDebGen {
+ createDebPackages()
+ }
+
+ if !skipRpmGen {
+ createRpmPackages()
+ }
}
func createPackage(options linuxPackageOptions) {
diff --git a/conf/defaults.ini b/conf/defaults.ini
index 7f61ac96870..788112ae67e 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -106,6 +106,22 @@ path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+login_remember_days = 7
+
+# How often should the login token be rotated. default to '10m'
+rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+delete_expired_token_after_days = 30
+
#################################### Session #############################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -143,6 +159,9 @@ conn_max_lifetime = 14400
# This enables data proxy logging, default is false
logging = false
+# How long the data proxy should wait before timing out default is 30 (seconds)
+timeout = 30
+
#################################### Analytics ###########################
[analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@@ -175,11 +194,6 @@ admin_password = admin
# used for signing
secret_key = SW2YcwTIb9zpOOhoPsMm
-# Auto-login remember days
-login_remember_days = 7
-cookie_username = grafana_user
-cookie_remember_name = grafana_remember
-
# disable gravatar profile images
disable_gravatar = false
@@ -189,6 +203,9 @@ data_source_proxy_whitelist =
# disable protection against brute force login attempts
disable_brute_force_login_protection = false
+# set cookies as https only. default is false
+https_flag_cookies = false
+
#################################### Snapshots ###########################
[snapshots]
# snapshot sharing options
@@ -490,7 +507,7 @@ concurrent_render_limit = 5
#################################### Explore #############################
[explore]
# Enable the Explore section
-enabled = false
+enabled = true
#################################### Internal Grafana Metrics ############
# Metrics available at HTTP API Url /metrics
@@ -570,6 +587,7 @@ callback_url =
[panels]
enable_alpha = false
+disable_sanitize_html = false
[enterprise]
license_path =
diff --git a/conf/provisioning/notifiers/sample.yaml b/conf/provisioning/notifiers/sample.yaml
new file mode 100644
index 00000000000..7d909839412
--- /dev/null
+++ b/conf/provisioning/notifiers/sample.yaml
@@ -0,0 +1,25 @@
+# # config file version
+apiVersion: 1
+
+# notifiers:
+# - name: default-slack-temp
+# type: slack
+# org_name: Main Org.
+# is_default: true
+# uid: notifier1
+# settings:
+# recipient: "XXX"
+# token: "xoxb"
+# uploadImage: true
+# url: https://slack.com
+# - name: default-email
+# type: email
+# org_id: 1
+# uid: notifier2
+# is_default: false
+# settings:
+# addresses: example11111@example.com
+# delete_notifiers:
+# - name: default-slack-temp
+# org_name: Main Org.
+# uid: notifier1
\ No newline at end of file
diff --git a/conf/sample.ini b/conf/sample.ini
index 014016d45bc..89880106345 100644
--- a/conf/sample.ini
+++ b/conf/sample.ini
@@ -102,6 +102,22 @@ log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private
+#################################### Login ###############################
+
+[login]
+
+# Login cookie name
+;cookie_name = grafana_session
+
+# How many days an session can be unused before we inactivate it
+;login_remember_days = 7
+
+# How often should the login token be rotated. default to '10'
+;rotate_token_minutes = 10
+
+# How long should Grafana keep expired tokens before deleting them
+;delete_expired_token_after_days = 30
+
#################################### Session ####################################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -130,6 +146,9 @@ log_queries =
# This enables data proxy logging, default is false
;logging = false
+# How long the data proxy should wait before timing out default is 30 (seconds)
+;timeout = 30
+
#################################### Analytics ####################################
[analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
@@ -162,11 +181,6 @@ log_queries =
# used for signing
;secret_key = SW2YcwTIb9zpOOhoPsMm
-# Auto-login remember days
-;login_remember_days = 7
-;cookie_username = grafana_user
-;cookie_remember_name = grafana_remember
-
# disable gravatar profile images
;disable_gravatar = false
@@ -176,6 +190,9 @@ log_queries =
# disable protection against brute force login attempts
;disable_brute_force_login_protection = false
+# set cookies as https only. default is false
+;https_flag_cookies = false
+
#################################### Snapshots ###########################
[snapshots]
# snapshot sharing options
@@ -415,7 +432,7 @@ log_queries =
#################################### Explore #############################
[explore]
# Enable the Explore section
-;enabled = false
+;enabled = true
#################################### Internal Grafana Metrics ##########################
# Metrics available at HTTP API Url /metrics
@@ -495,3 +512,8 @@ log_queries =
# Path to a valid Grafana Enterprise license.jwt file
;license_path =
+[panels]
+;enable_alpha = false
+# If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities.
+;disable_sanitize_html = false
+
diff --git a/devenv/dashboards.yaml b/devenv/dashboards.yaml
index 226c1a8b335..c59d7127769 100644
--- a/devenv/dashboards.yaml
+++ b/devenv/dashboards.yaml
@@ -4,6 +4,6 @@ providers:
- name: 'gdev dashboards'
folder: 'gdev dashboards'
type: file
+ updateIntervalSeconds: 15
options:
path: devenv/dev-dashboards
-
diff --git a/devenv/datasources.yaml b/devenv/datasources.yaml
index a264bb503bd..d4457faaebf 100644
--- a/devenv/datasources.yaml
+++ b/devenv/datasources.yaml
@@ -152,4 +152,10 @@ datasources:
authType: credentials
defaultRegion: eu-west-2
+ - name: gdev-loki
+ type: loki
+ access: proxy
+ url: http://localhost:3100
+ editable: false
+
diff --git a/devenv/dev-dashboards-without-uid/panel_tests_graph.json b/devenv/dev-dashboards-without-uid/panel_tests_graph.json
new file mode 100644
index 00000000000..c47a932ce7f
--- /dev/null
+++ b/devenv/dev-dashboards-without-uid/panel_tests_graph.json
@@ -0,0 +1,1674 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "connected",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenario": "random_walk",
+ "scenarioId": "no_data_points",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "No Data Points Warning",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 8,
+ "y": 0
+ },
+ "id": 2,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "connected",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenario": "random_walk",
+ "scenarioId": "datapoints_outside_range",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Datapoints Outside Range Warning",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 0
+ },
+ "id": 3,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "connected",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenario": "random_walk",
+ "scenarioId": "random_walk",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Random walk series",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 16,
+ "x": 0,
+ "y": 7
+ },
+ "id": 4,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "connected",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenario": "random_walk",
+ "scenarioId": "random_walk",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": "2s",
+ "timeShift": null,
+ "title": "Millisecond res x-axis and tooltip",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "content": "Just verify that the tooltip time has millisecond resolution ",
+ "editable": true,
+ "error": false,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 7
+ },
+ "id": 6,
+ "links": [],
+ "mode": "markdown",
+ "title": "",
+ "type": "text"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 9,
+ "w": 16,
+ "x": 0,
+ "y": 14
+ },
+ "id": 5,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "connected",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [
+ {
+ "alias": "B-series",
+ "yaxis": 2
+ }
+ ],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "2000,3000,4000,1000,3000,10000",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "2 yaxis and axis labels",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "percent",
+ "label": "Perecent",
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": "Pressure",
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "content": "Verify that axis labels look ok",
+ "editable": true,
+ "error": false,
+ "gridPos": {
+ "h": 9,
+ "w": 8,
+ "x": 16,
+ "y": 14
+ },
+ "id": 7,
+ "links": [],
+ "mode": "markdown",
+ "title": "",
+ "type": "text"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 0,
+ "y": 23
+ },
+ "id": 8,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "connected",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "null value connected",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 8,
+ "y": 23
+ },
+ "id": 10,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null as zero",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "null value null as zero",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "content": "Should be a long line connecting the null region in the `connected` mode, and in zero it should just be a line with zero value at the null points. ",
+ "editable": true,
+ "error": false,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 23
+ },
+ "id": 13,
+ "links": [],
+ "mode": "markdown",
+ "title": "",
+ "type": "text"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 16,
+ "x": 0,
+ "y": 30
+ },
+ "id": 9,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [
+ {
+ "alias": "B-series",
+ "zindex": -3
+ }
+ ],
+ "spaceLength": 10,
+ "stack": true,
+ "steppedLine": false,
+ "targets": [
+ {
+ "hide": false,
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,null,null,null,null,null,null,100,10,10,20,30,40,10",
+ "target": ""
+ },
+ {
+ "alias": "",
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20",
+ "target": ""
+ },
+ {
+ "alias": "",
+ "hide": false,
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,20,30,40,40,40,100,10,20,20",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Stacking value ontop of nulls",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "content": "Stacking values on top of nulls, should treat the null values as zero. ",
+ "editable": true,
+ "error": false,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 30
+ },
+ "id": 14,
+ "links": [],
+ "mode": "markdown",
+ "title": "",
+ "type": "text"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 16,
+ "x": 0,
+ "y": 37
+ },
+ "id": 12,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [
+ {
+ "alias": "B-series",
+ "zindex": -3
+ }
+ ],
+ "spaceLength": 10,
+ "stack": true,
+ "steppedLine": false,
+ "targets": [
+ {
+ "alias": "",
+ "hide": false,
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+ "target": ""
+ },
+ {
+ "alias": "",
+ "hide": false,
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+ "target": ""
+ },
+ {
+ "alias": "",
+ "hide": false,
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Stacking all series null segment",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "content": "Stacking when all values are null should leave a gap in the graph",
+ "editable": true,
+ "error": false,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 37
+ },
+ "id": 15,
+ "links": [],
+ "mode": "markdown",
+ "title": "",
+ "type": "text"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "editable": true,
+ "error": false,
+ "fill": 0,
+ "gridPos": {
+ "h": 7,
+ "w": 16,
+ "x": 0,
+ "y": 44
+ },
+ "id": 21,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [
+ {
+ "alias": "C-series",
+ "steppedLine": true
+ }
+ ],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "alias": "",
+ "hide": false,
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,null,40,null,90,null,null,100,null,null,100,null,null,80,null",
+ "target": ""
+ },
+ {
+ "alias": "",
+ "hide": false,
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "20,null40,null,null,50,null,70,null,100,null,10,null,30,null",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Null between points",
+ "tooltip": {
+ "msResolution": false,
+ "shared": true,
+ "sort": 0,
+ "value_type": "cumulative"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points",
+ "editable": true,
+ "error": false,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 44
+ },
+ "id": 22,
+ "links": [],
+ "mode": "markdown",
+ "title": "",
+ "type": "text"
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "decimals": 3,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 24,
+ "x": 0,
+ "y": 51
+ },
+ "id": 20,
+ "legend": {
+ "alignAsTable": true,
+ "avg": true,
+ "current": true,
+ "max": true,
+ "min": true,
+ "show": true,
+ "total": true,
+ "values": true
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Legend Table Single Series Should Take Minimum Height",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "decimals": 3,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 0,
+ "y": 58
+ },
+ "id": 16,
+ "legend": {
+ "alignAsTable": true,
+ "avg": true,
+ "current": true,
+ "max": true,
+ "min": true,
+ "show": true,
+ "total": true,
+ "values": true
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Legend Table No Scroll Visible",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "decimals": 3,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 12,
+ "y": 58
+ },
+ "id": 17,
+ "legend": {
+ "alignAsTable": true,
+ "avg": true,
+ "current": true,
+ "max": true,
+ "min": true,
+ "show": true,
+ "total": true,
+ "values": true
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "E",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "F",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "G",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "H",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "I",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "J",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Legend Table Should Scroll",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "decimals": 3,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 0,
+ "y": 65
+ },
+ "id": 18,
+ "legend": {
+ "alignAsTable": true,
+ "avg": true,
+ "current": true,
+ "max": true,
+ "min": true,
+ "rightSide": true,
+ "show": true,
+ "total": true,
+ "values": true
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Legend Table No Scroll Visible",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "decimals": 3,
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 12,
+ "y": 65
+ },
+ "id": 19,
+ "legend": {
+ "alignAsTable": true,
+ "avg": true,
+ "current": true,
+ "max": true,
+ "min": true,
+ "rightSide": true,
+ "show": true,
+ "total": true,
+ "values": true
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "E",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "F",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "G",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "H",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "I",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "J",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "K",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ },
+ {
+ "refId": "L",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Legend Table No Scroll Visible",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ }
+ ],
+ "refresh": false,
+ "revision": 8,
+ "schemaVersion": 16,
+ "style": "dark",
+ "tags": [
+ "gdev",
+ "panel-tests"
+ ],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": [
+ "5s",
+ "10s",
+ "30s",
+ "1m",
+ "5m",
+ "15m",
+ "30m",
+ "1h",
+ "2h",
+ "1d"
+ ],
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "browser",
+ "title": "Panel Tests - Graph",
+ "version": 1
+}
diff --git a/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json b/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json
new file mode 100644
index 00000000000..98121cccba6
--- /dev/null
+++ b/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json
@@ -0,0 +1,510 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "fill": 2,
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 2,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "random_walk",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [
+ {
+ "colorMode": "gray",
+ "fill": true,
+ "fillColor": "rgba(255, 255, 255, 0.03)",
+ "from": "08:30",
+ "fromDayOfWeek": 1,
+ "line": false,
+ "lineColor": "rgba(255, 255, 255, 0.2)",
+ "op": "time",
+ "to": "16:45",
+ "toDayOfWeek": 5
+ }
+ ],
+ "timeShift": null,
+ "title": "Business Hours",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "fill": 2,
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 8
+ },
+ "id": 4,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "A",
+ "scenarioId": "random_walk",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [
+ {
+ "colorMode": "red",
+ "fill": true,
+ "fillColor": "rgba(255, 255, 255, 0.03)",
+ "from": "20:00",
+ "fromDayOfWeek": 7,
+ "line": false,
+ "lineColor": "rgba(255, 255, 255, 0.2)",
+ "op": "time",
+ "to": "23:00",
+ "toDayOfWeek": 7
+ }
+ ],
+ "timeShift": null,
+ "title": "Sunday's 20-23",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {
+ "A-series": "#d683ce"
+ },
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "fill": 2,
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "id": 3,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 0.5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "random_walk",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [
+ {
+ "colorMode": "custom",
+ "fill": true,
+ "fillColor": "rgba(255, 0, 0, 0.22)",
+ "from": "",
+ "fromDayOfWeek": 1,
+ "line": true,
+ "lineColor": "rgba(255, 0, 0, 0.32)",
+ "op": "time",
+ "to": "",
+ "toDayOfWeek": 1
+ },
+ {
+ "colorMode": "custom",
+ "fill": true,
+ "fillColor": "rgba(255, 127, 0, 0.22)",
+ "fromDayOfWeek": 2,
+ "line": true,
+ "lineColor": "rgba(255, 127, 0, 0.32)",
+ "op": "time",
+ "toDayOfWeek": 2
+ },
+ {
+ "colorMode": "custom",
+ "fill": true,
+ "fillColor": "rgba(255, 255, 0, 0.22)",
+ "fromDayOfWeek": 3,
+ "line": true,
+ "lineColor": "rgba(255, 255, 0, 0.22)",
+ "op": "time",
+ "toDayOfWeek": 3
+ },
+ {
+ "colorMode": "custom",
+ "fill": true,
+ "fillColor": "rgba(0, 255, 0, 0.22)",
+ "fromDayOfWeek": 4,
+ "line": true,
+ "lineColor": "rgba(0, 255, 0, 0.32)",
+ "op": "time",
+ "toDayOfWeek": 4
+ },
+ {
+ "colorMode": "custom",
+ "fill": true,
+ "fillColor": "rgba(0, 0, 255, 0.22)",
+ "fromDayOfWeek": 5,
+ "line": true,
+ "lineColor": "rgba(0, 0, 255, 0.32)",
+ "op": "time",
+ "toDayOfWeek": 5
+ },
+ {
+ "colorMode": "custom",
+ "fill": true,
+ "fillColor": "rgba(75, 0, 130, 0.22)",
+ "fromDayOfWeek": 6,
+ "line": true,
+ "lineColor": "rgba(75, 0, 130, 0.32)",
+ "op": "time",
+ "toDayOfWeek": 6
+ },
+ {
+ "colorMode": "custom",
+ "fill": true,
+ "fillColor": "rgba(148, 0, 211, 0.22)",
+ "fromDayOfWeek": 7,
+ "line": true,
+ "lineColor": "rgba(148, 0, 211, 0.32)",
+ "op": "time",
+ "toDayOfWeek": 7
+ }
+ ],
+ "timeShift": null,
+ "title": "Each day of week",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "fill": 2,
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 24
+ },
+ "id": 5,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "A",
+ "scenarioId": "random_walk",
+ "target": ""
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [
+ {
+ "colorMode": "red",
+ "fill": false,
+ "from": "05:00",
+ "line": true,
+ "op": "time"
+ }
+ ],
+ "timeShift": null,
+ "title": "05:00",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ }
+ ],
+ "refresh": false,
+ "schemaVersion": 16,
+ "style": "dark",
+ "tags": [
+ "gdev",
+ "panel-tests"
+ ],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-30d",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": [
+ "5s",
+ "10s",
+ "30s",
+ "1m",
+ "5m",
+ "15m",
+ "30m",
+ "1h",
+ "2h",
+ "1d"
+ ],
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "browser",
+ "title": "Panel Tests - Graph (Time Regions)",
+ "version": 1
+}
\ No newline at end of file
diff --git a/devenv/dev-dashboards-without-uid/panel_tests_polystat.json b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json
new file mode 100644
index 00000000000..d18586f88d4
--- /dev/null
+++ b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json
@@ -0,0 +1,3342 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "animationModes": [
+ {
+ "text": "Show All",
+ "value": "all"
+ },
+ {
+ "text": "Show Triggered",
+ "value": "triggered"
+ }
+ ],
+ "colors": [
+ "#299c46",
+ "rgba(237, 129, 40, 0.89)",
+ "#d44a3a"
+ ],
+ "d3DivId": "d3_svg_4",
+ "datasource": "gdev-testdata",
+ "decimals": 2,
+ "displayModes": [
+ {
+ "text": "Show All",
+ "value": "all"
+ },
+ {
+ "text": "Show Triggered",
+ "value": "triggered"
+ }
+ ],
+ "fontSizes": [
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 22,
+ 24,
+ 26,
+ 28,
+ 30,
+ 32,
+ 34,
+ 36,
+ 38,
+ 40,
+ 42,
+ 44,
+ 46,
+ 48,
+ 50,
+ 52,
+ 54,
+ 56,
+ 58,
+ 60,
+ 62,
+ 64,
+ 66,
+ 68,
+ 70
+ ],
+ "fontTypes": [
+ "Open Sans",
+ "Arial",
+ "Avant Garde",
+ "Bookman",
+ "Consolas",
+ "Courier",
+ "Courier New",
+ "Futura",
+ "Garamond",
+ "Helvetica",
+ "Palatino",
+ "Times",
+ "Times New Roman",
+ "Verdana"
+ ],
+ "format": "none",
+ "gridPos": {
+ "h": 9,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 4,
+ "links": [],
+ "notcolors": [
+ "rgba(245, 54, 54, 0.9)",
+ "rgba(237, 129, 40, 0.89)",
+ "rgba(50, 172, 45, 0.97)"
+ ],
+ "operatorName": "avg",
+ "operatorOptions": [
+ {
+ "text": "Average",
+ "value": "avg"
+ },
+ {
+ "text": "Count",
+ "value": "count"
+ },
+ {
+ "text": "Current",
+ "value": "current"
+ },
+ {
+ "text": "Delta",
+ "value": "delta"
+ },
+ {
+ "text": "Difference",
+ "value": "diff"
+ },
+ {
+ "text": "First",
+ "value": "first"
+ },
+ {
+ "text": "Log Min",
+ "value": "logmin"
+ },
+ {
+ "text": "Max",
+ "value": "max"
+ },
+ {
+ "text": "Min",
+ "value": "min"
+ },
+ {
+ "text": "Name",
+ "value": "name"
+ },
+ {
+ "text": "Time of Last Point",
+ "value": "last_time"
+ },
+ {
+ "text": "Time Step",
+ "value": "time_step"
+ },
+ {
+ "text": "Total",
+ "value": "total"
+ }
+ ],
+ "polystat": {
+ "animationSpeed": 2500,
+ "columnAutoSize": true,
+ "columns": "",
+ "defaultClickThrough": "",
+ "defaultClickThroughSanitize": true,
+ "displayLimit": 100,
+ "fontAutoScale": true,
+ "fontSize": 12,
+ "globalDisplayMode": "all",
+ "globalOperatorName": "avg",
+ "gradientEnabled": true,
+ "hexagonSortByDirection": "asc",
+ "hexagonSortByField": "name",
+ "maxMetrics": 0,
+ "polygonBorderColor": "black",
+ "polygonBorderSize": 2,
+ "radius": "",
+ "radiusAutoSize": true,
+ "rowAutoSize": true,
+ "rows": "",
+ "shape": "hexagon_pointed_top",
+ "tooltipDisplayMode": "all",
+ "tooltipDisplayTextTriggeredEmpty": "OK",
+ "tooltipFontSize": 12,
+ "tooltipFontType": "Open Sans",
+ "tooltipPrimarySortDirection": "desc",
+ "tooltipPrimarySortField": "thresholdLevel",
+ "tooltipSecondarySortDirection": "desc",
+ "tooltipSecondarySortField": "value",
+ "tooltipTimestampEnabled": true
+ },
+ "savedComposites": [],
+ "savedOverrides": [],
+ "shapes": [
+ {
+ "text": "Hexagon Pointed Top",
+ "value": "hexagon_pointed_top"
+ },
+ {
+ "text": "Hexagon Flat Top",
+ "value": "hexagon_flat_top"
+ },
+ {
+ "text": "Circle",
+ "value": "circle"
+ },
+ {
+ "text": "Cross",
+ "value": "cross"
+ },
+ {
+ "text": "Diamond",
+ "value": "diamond"
+ },
+ {
+ "text": "Square",
+ "value": "square"
+ },
+ {
+ "text": "Star",
+ "value": "star"
+ },
+ {
+ "text": "Triangle",
+ "value": "triangle"
+ },
+ {
+ "text": "Wye",
+ "value": "wye"
+ }
+ ],
+ "sortDirections": [
+ {
+ "text": "Ascending",
+ "value": "asc"
+ },
+ {
+ "text": "Descending",
+ "value": "desc"
+ }
+ ],
+ "sortFields": [
+ {
+ "text": "Name",
+ "value": "name"
+ },
+ {
+ "text": "Threshold Level",
+ "value": "thresholdLevel"
+ },
+ {
+ "text": "Value",
+ "value": "value"
+ }
+ ],
+ "svgContainer": {},
+ "targets": [
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "A",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "B",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "C",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "D",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "E",
+ "scenarioId": "random_walk"
+ }
+ ],
+ "thresholdStates": [
+ {
+ "text": "ok",
+ "value": 0
+ },
+ {
+ "text": "warning",
+ "value": 1
+ },
+ {
+ "text": "critical",
+ "value": 2
+ },
+ {
+ "text": "custom",
+ "value": 3
+ }
+ ],
+ "title": "Poor use of space",
+ "type": "grafana-polystat-panel",
+ "unitFormats": [
+ {
+ "submenu": [
+ {
+ "text": "none",
+ "value": "none"
+ },
+ {
+ "text": "short",
+ "value": "short"
+ },
+ {
+ "text": "percent (0-100)",
+ "value": "percent"
+ },
+ {
+ "text": "percent (0.0-1.0)",
+ "value": "percentunit"
+ },
+ {
+ "text": "Humidity (%H)",
+ "value": "humidity"
+ },
+ {
+ "text": "decibel",
+ "value": "dB"
+ },
+ {
+ "text": "hexadecimal (0x)",
+ "value": "hex0x"
+ },
+ {
+ "text": "hexadecimal",
+ "value": "hex"
+ },
+ {
+ "text": "scientific notation",
+ "value": "sci"
+ },
+ {
+ "text": "locale format",
+ "value": "locale"
+ }
+ ],
+ "text": "none"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Dollars ($)",
+ "value": "currencyUSD"
+ },
+ {
+ "text": "Pounds (£)",
+ "value": "currencyGBP"
+ },
+ {
+ "text": "Euro (€)",
+ "value": "currencyEUR"
+ },
+ {
+ "text": "Yen (¥)",
+ "value": "currencyJPY"
+ },
+ {
+ "text": "Rubles (₽)",
+ "value": "currencyRUB"
+ },
+ {
+ "text": "Hryvnias (₴)",
+ "value": "currencyUAH"
+ },
+ {
+ "text": "Real (R$)",
+ "value": "currencyBRL"
+ },
+ {
+ "text": "Danish Krone (kr)",
+ "value": "currencyDKK"
+ },
+ {
+ "text": "Icelandic Króna (kr)",
+ "value": "currencyISK"
+ },
+ {
+ "text": "Norwegian Krone (kr)",
+ "value": "currencyNOK"
+ },
+ {
+ "text": "Swedish Krona (kr)",
+ "value": "currencySEK"
+ },
+ {
+ "text": "Czech koruna (czk)",
+ "value": "currencyCZK"
+ },
+ {
+ "text": "Swiss franc (CHF)",
+ "value": "currencyCHF"
+ },
+ {
+ "text": "Polish Złoty (PLN)",
+ "value": "currencyPLN"
+ },
+ {
+ "text": "Bitcoin (฿)",
+ "value": "currencyBTC"
+ }
+ ],
+ "text": "currency"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Hertz (1/s)",
+ "value": "hertz"
+ },
+ {
+ "text": "nanoseconds (ns)",
+ "value": "ns"
+ },
+ {
+ "text": "microseconds (µs)",
+ "value": "µs"
+ },
+ {
+ "text": "milliseconds (ms)",
+ "value": "ms"
+ },
+ {
+ "text": "seconds (s)",
+ "value": "s"
+ },
+ {
+ "text": "minutes (m)",
+ "value": "m"
+ },
+ {
+ "text": "hours (h)",
+ "value": "h"
+ },
+ {
+ "text": "days (d)",
+ "value": "d"
+ },
+ {
+ "text": "duration (ms)",
+ "value": "dtdurationms"
+ },
+ {
+ "text": "duration (s)",
+ "value": "dtdurations"
+ },
+ {
+ "text": "duration (hh:mm:ss)",
+ "value": "dthms"
+ },
+ {
+ "text": "Timeticks (s/100)",
+ "value": "timeticks"
+ }
+ ],
+ "text": "time"
+ },
+ {
+ "submenu": [
+ {
+ "text": "YYYY-MM-DD HH:mm:ss",
+ "value": "dateTimeAsIso"
+ },
+ {
+ "text": "DD/MM/YYYY h:mm:ss a",
+ "value": "dateTimeAsUS"
+ },
+ {
+ "text": "From Now",
+ "value": "dateTimeFromNow"
+ }
+ ],
+ "text": "date & time"
+ },
+ {
+ "submenu": [
+ {
+ "text": "bits",
+ "value": "bits"
+ },
+ {
+ "text": "bytes",
+ "value": "bytes"
+ },
+ {
+ "text": "kibibytes",
+ "value": "kbytes"
+ },
+ {
+ "text": "mebibytes",
+ "value": "mbytes"
+ },
+ {
+ "text": "gibibytes",
+ "value": "gbytes"
+ }
+ ],
+ "text": "data (IEC)"
+ },
+ {
+ "submenu": [
+ {
+ "text": "bits",
+ "value": "decbits"
+ },
+ {
+ "text": "bytes",
+ "value": "decbytes"
+ },
+ {
+ "text": "kilobytes",
+ "value": "deckbytes"
+ },
+ {
+ "text": "megabytes",
+ "value": "decmbytes"
+ },
+ {
+ "text": "gigabytes",
+ "value": "decgbytes"
+ }
+ ],
+ "text": "data (Metric)"
+ },
+ {
+ "submenu": [
+ {
+ "text": "packets/sec",
+ "value": "pps"
+ },
+ {
+ "text": "bits/sec",
+ "value": "bps"
+ },
+ {
+ "text": "bytes/sec",
+ "value": "Bps"
+ },
+ {
+ "text": "kilobits/sec",
+ "value": "Kbits"
+ },
+ {
+ "text": "kilobytes/sec",
+ "value": "KBs"
+ },
+ {
+ "text": "megabits/sec",
+ "value": "Mbits"
+ },
+ {
+ "text": "megabytes/sec",
+ "value": "MBs"
+ },
+ {
+ "text": "gigabytes/sec",
+ "value": "GBs"
+ },
+ {
+ "text": "gigabits/sec",
+ "value": "Gbits"
+ }
+ ],
+ "text": "data rate"
+ },
+ {
+ "submenu": [
+ {
+ "text": "hashes/sec",
+ "value": "Hs"
+ },
+ {
+ "text": "kilohashes/sec",
+ "value": "KHs"
+ },
+ {
+ "text": "megahashes/sec",
+ "value": "MHs"
+ },
+ {
+ "text": "gigahashes/sec",
+ "value": "GHs"
+ },
+ {
+ "text": "terahashes/sec",
+ "value": "THs"
+ },
+ {
+ "text": "petahashes/sec",
+ "value": "PHs"
+ },
+ {
+ "text": "exahashes/sec",
+ "value": "EHs"
+ }
+ ],
+ "text": "hash rate"
+ },
+ {
+ "submenu": [
+ {
+ "text": "ops/sec (ops)",
+ "value": "ops"
+ },
+ {
+ "text": "requests/sec (rps)",
+ "value": "reqps"
+ },
+ {
+ "text": "reads/sec (rps)",
+ "value": "rps"
+ },
+ {
+ "text": "writes/sec (wps)",
+ "value": "wps"
+ },
+ {
+ "text": "I/O ops/sec (iops)",
+ "value": "iops"
+ },
+ {
+ "text": "ops/min (opm)",
+ "value": "opm"
+ },
+ {
+ "text": "reads/min (rpm)",
+ "value": "rpm"
+ },
+ {
+ "text": "writes/min (wpm)",
+ "value": "wpm"
+ }
+ ],
+ "text": "throughput"
+ },
+ {
+ "submenu": [
+ {
+ "text": "millimetre (mm)",
+ "value": "lengthmm"
+ },
+ {
+ "text": "meter (m)",
+ "value": "lengthm"
+ },
+ {
+ "text": "feet (ft)",
+ "value": "lengthft"
+ },
+ {
+ "text": "kilometer (km)",
+ "value": "lengthkm"
+ },
+ {
+ "text": "mile (mi)",
+ "value": "lengthmi"
+ }
+ ],
+ "text": "length"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Square Meters (m²)",
+ "value": "areaM2"
+ },
+ {
+ "text": "Square Feet (ft²)",
+ "value": "areaF2"
+ },
+ {
+ "text": "Square Miles (mi²)",
+ "value": "areaMI2"
+ }
+ ],
+ "text": "area"
+ },
+ {
+ "submenu": [
+ {
+ "text": "milligram (mg)",
+ "value": "massmg"
+ },
+ {
+ "text": "gram (g)",
+ "value": "massg"
+ },
+ {
+ "text": "kilogram (kg)",
+ "value": "masskg"
+ },
+ {
+ "text": "metric ton (t)",
+ "value": "masst"
+ }
+ ],
+ "text": "mass"
+ },
+ {
+ "submenu": [
+ {
+ "text": "metres/second (m/s)",
+ "value": "velocityms"
+ },
+ {
+ "text": "kilometers/hour (km/h)",
+ "value": "velocitykmh"
+ },
+ {
+ "text": "miles/hour (mph)",
+ "value": "velocitymph"
+ },
+ {
+ "text": "knot (kn)",
+ "value": "velocityknot"
+ }
+ ],
+ "text": "velocity"
+ },
+ {
+ "submenu": [
+ {
+ "text": "millilitre (mL)",
+ "value": "mlitre"
+ },
+ {
+ "text": "litre (L)",
+ "value": "litre"
+ },
+ {
+ "text": "cubic metre",
+ "value": "m3"
+ },
+ {
+ "text": "Normal cubic metre",
+ "value": "Nm3"
+ },
+ {
+ "text": "cubic decimetre",
+ "value": "dm3"
+ },
+ {
+ "text": "gallons",
+ "value": "gallons"
+ }
+ ],
+ "text": "volume"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Watt (W)",
+ "value": "watt"
+ },
+ {
+ "text": "Kilowatt (kW)",
+ "value": "kwatt"
+ },
+ {
+ "text": "Milliwatt (mW)",
+ "value": "mwatt"
+ },
+ {
+ "text": "Watt per square metre (W/m²)",
+ "value": "Wm2"
+ },
+ {
+ "text": "Volt-ampere (VA)",
+ "value": "voltamp"
+ },
+ {
+ "text": "Kilovolt-ampere (kVA)",
+ "value": "kvoltamp"
+ },
+ {
+ "text": "Volt-ampere reactive (var)",
+ "value": "voltampreact"
+ },
+ {
+ "text": "Kilovolt-ampere reactive (kvar)",
+ "value": "kvoltampreact"
+ },
+ {
+ "text": "Watt-hour (Wh)",
+ "value": "watth"
+ },
+ {
+ "text": "Kilowatt-hour (kWh)",
+ "value": "kwatth"
+ },
+ {
+ "text": "Kilowatt-min (kWm)",
+ "value": "kwattm"
+ },
+ {
+ "text": "Joule (J)",
+ "value": "joule"
+ },
+ {
+ "text": "Electron volt (eV)",
+ "value": "ev"
+ },
+ {
+ "text": "Ampere (A)",
+ "value": "amp"
+ },
+ {
+ "text": "Kiloampere (kA)",
+ "value": "kamp"
+ },
+ {
+ "text": "Milliampere (mA)",
+ "value": "mamp"
+ },
+ {
+ "text": "Volt (V)",
+ "value": "volt"
+ },
+ {
+ "text": "Kilovolt (kV)",
+ "value": "kvolt"
+ },
+ {
+ "text": "Millivolt (mV)",
+ "value": "mvolt"
+ },
+ {
+ "text": "Decibel-milliwatt (dBm)",
+ "value": "dBm"
+ },
+ {
+ "text": "Ohm (Ω)",
+ "value": "ohm"
+ },
+ {
+ "text": "Lumens (Lm)",
+ "value": "lumens"
+ }
+ ],
+ "text": "energy"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Celsius (°C)",
+ "value": "celsius"
+ },
+ {
+ "text": "Farenheit (°F)",
+ "value": "farenheit"
+ },
+ {
+ "text": "Kelvin (K)",
+ "value": "kelvin"
+ }
+ ],
+ "text": "temperature"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Millibars",
+ "value": "pressurembar"
+ },
+ {
+ "text": "Bars",
+ "value": "pressurebar"
+ },
+ {
+ "text": "Kilobars",
+ "value": "pressurekbar"
+ },
+ {
+ "text": "Hectopascals",
+ "value": "pressurehpa"
+ },
+ {
+ "text": "Kilopascals",
+ "value": "pressurekpa"
+ },
+ {
+ "text": "Inches of mercury",
+ "value": "pressurehg"
+ },
+ {
+ "text": "PSI",
+ "value": "pressurepsi"
+ }
+ ],
+ "text": "pressure"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Newton-meters (Nm)",
+ "value": "forceNm"
+ },
+ {
+ "text": "Kilonewton-meters (kNm)",
+ "value": "forcekNm"
+ },
+ {
+ "text": "Newtons (N)",
+ "value": "forceN"
+ },
+ {
+ "text": "Kilonewtons (kN)",
+ "value": "forcekN"
+ }
+ ],
+ "text": "force"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Gallons/min (gpm)",
+ "value": "flowgpm"
+ },
+ {
+ "text": "Cubic meters/sec (cms)",
+ "value": "flowcms"
+ },
+ {
+ "text": "Cubic feet/sec (cfs)",
+ "value": "flowcfs"
+ },
+ {
+ "text": "Cubic feet/min (cfm)",
+ "value": "flowcfm"
+ },
+ {
+ "text": "Litre/hour",
+ "value": "litreh"
+ },
+ {
+ "text": "Litre/min (l/min)",
+ "value": "flowlpm"
+ },
+ {
+ "text": "milliLitre/min (mL/min)",
+ "value": "flowmlpm"
+ }
+ ],
+ "text": "flow"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Degrees (°)",
+ "value": "degree"
+ },
+ {
+ "text": "Radians",
+ "value": "radian"
+ },
+ {
+ "text": "Gradian",
+ "value": "grad"
+ }
+ ],
+ "text": "angle"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Meters/sec²",
+ "value": "accMS2"
+ },
+ {
+ "text": "Feet/sec²",
+ "value": "accFS2"
+ },
+ {
+ "text": "G unit",
+ "value": "accG"
+ }
+ ],
+ "text": "acceleration"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Becquerel (Bq)",
+ "value": "radbq"
+ },
+ {
+ "text": "curie (Ci)",
+ "value": "radci"
+ },
+ {
+ "text": "Gray (Gy)",
+ "value": "radgy"
+ },
+ {
+ "text": "rad",
+ "value": "radrad"
+ },
+ {
+ "text": "Sievert (Sv)",
+ "value": "radsv"
+ },
+ {
+ "text": "rem",
+ "value": "radrem"
+ },
+ {
+ "text": "Exposure (C/kg)",
+ "value": "radexpckg"
+ },
+ {
+ "text": "roentgen (R)",
+ "value": "radr"
+ },
+ {
+ "text": "Sievert/hour (Sv/h)",
+ "value": "radsvh"
+ }
+ ],
+ "text": "radiation"
+ },
+ {
+ "submenu": [
+ {
+ "text": "parts-per-million (ppm)",
+ "value": "ppm"
+ },
+ {
+ "text": "parts-per-billion (ppb)",
+ "value": "conppb"
+ },
+ {
+ "text": "nanogram per cubic metre (ng/m³)",
+ "value": "conngm3"
+ },
+ {
+ "text": "nanogram per normal cubic metre (ng/Nm³)",
+ "value": "conngNm3"
+ },
+ {
+ "text": "microgram per cubic metre (μg/m³)",
+ "value": "conμgm3"
+ },
+ {
+ "text": "microgram per normal cubic metre (μg/Nm³)",
+ "value": "conμgNm3"
+ },
+ {
+ "text": "milligram per cubic metre (mg/m³)",
+ "value": "conmgm3"
+ },
+ {
+ "text": "milligram per normal cubic metre (mg/Nm³)",
+ "value": "conmgNm3"
+ },
+ {
+ "text": "gram per cubic metre (g/m³)",
+ "value": "congm3"
+ },
+ {
+ "text": "gram per normal cubic metre (g/Nm³)",
+ "value": "congNm3"
+ }
+ ],
+ "text": "concentration"
+ }
+ ]
+ },
+ {
+ "animationModes": [
+ {
+ "text": "Show All",
+ "value": "all"
+ },
+ {
+ "text": "Show Triggered",
+ "value": "triggered"
+ }
+ ],
+ "colors": [
+ "#299c46",
+ "rgba(237, 129, 40, 0.89)",
+ "#d44a3a"
+ ],
+ "d3DivId": "d3_svg_5",
+ "datasource": "gdev-testdata",
+ "decimals": 2,
+ "displayModes": [
+ {
+ "text": "Show All",
+ "value": "all"
+ },
+ {
+ "text": "Show Triggered",
+ "value": "triggered"
+ }
+ ],
+ "fontSizes": [
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 22,
+ 24,
+ 26,
+ 28,
+ 30,
+ 32,
+ 34,
+ 36,
+ 38,
+ 40,
+ 42,
+ 44,
+ 46,
+ 48,
+ 50,
+ 52,
+ 54,
+ 56,
+ 58,
+ 60,
+ 62,
+ 64,
+ 66,
+ 68,
+ 70
+ ],
+ "fontTypes": [
+ "Open Sans",
+ "Arial",
+ "Avant Garde",
+ "Bookman",
+ "Consolas",
+ "Courier",
+ "Courier New",
+ "Futura",
+ "Garamond",
+ "Helvetica",
+ "Palatino",
+ "Times",
+ "Times New Roman",
+ "Verdana"
+ ],
+ "format": "none",
+ "gridPos": {
+ "h": 9,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "id": 5,
+ "links": [],
+ "notcolors": [
+ "rgba(245, 54, 54, 0.9)",
+ "rgba(237, 129, 40, 0.89)",
+ "rgba(50, 172, 45, 0.97)"
+ ],
+ "operatorName": "avg",
+ "operatorOptions": [
+ {
+ "text": "Average",
+ "value": "avg"
+ },
+ {
+ "text": "Count",
+ "value": "count"
+ },
+ {
+ "text": "Current",
+ "value": "current"
+ },
+ {
+ "text": "Delta",
+ "value": "delta"
+ },
+ {
+ "text": "Difference",
+ "value": "diff"
+ },
+ {
+ "text": "First",
+ "value": "first"
+ },
+ {
+ "text": "Log Min",
+ "value": "logmin"
+ },
+ {
+ "text": "Max",
+ "value": "max"
+ },
+ {
+ "text": "Min",
+ "value": "min"
+ },
+ {
+ "text": "Name",
+ "value": "name"
+ },
+ {
+ "text": "Time of Last Point",
+ "value": "last_time"
+ },
+ {
+ "text": "Time Step",
+ "value": "time_step"
+ },
+ {
+ "text": "Total",
+ "value": "total"
+ }
+ ],
+ "polystat": {
+ "animationSpeed": 2500,
+ "columnAutoSize": true,
+ "columns": "",
+ "defaultClickThrough": "",
+ "defaultClickThroughSanitize": true,
+ "displayLimit": 100,
+ "fontAutoScale": true,
+ "fontSize": 12,
+ "globalDisplayMode": "all",
+ "globalOperatorName": "avg",
+ "gradientEnabled": true,
+ "hexagonSortByDirection": "asc",
+ "hexagonSortByField": "name",
+ "maxMetrics": 0,
+ "polygonBorderColor": "black",
+ "polygonBorderSize": 2,
+ "radius": "",
+ "radiusAutoSize": true,
+ "rowAutoSize": true,
+ "rows": "",
+ "shape": "hexagon_pointed_top",
+ "tooltipDisplayMode": "all",
+ "tooltipDisplayTextTriggeredEmpty": "OK",
+ "tooltipFontSize": 12,
+ "tooltipFontType": "Open Sans",
+ "tooltipPrimarySortDirection": "desc",
+ "tooltipPrimarySortField": "thresholdLevel",
+ "tooltipSecondarySortDirection": "desc",
+ "tooltipSecondarySortField": "value",
+ "tooltipTimestampEnabled": true
+ },
+ "savedComposites": [
+ {
+ "compositeName": "comp",
+ "members": [
+ {
+ "seriesName": "A-series"
+ },
+ {
+ "seriesName": "B-series"
+ }
+ ],
+ "enabled": true,
+ "clickThrough": "",
+ "hideMembers": true,
+ "showName": true,
+ "showValue": true,
+ "animateMode": "all",
+ "thresholdLevel": 0,
+ "sanitizeURLEnabled": true,
+ "sanitizedURL": ""
+ }
+ ],
+ "savedOverrides": [],
+ "shapes": [
+ {
+ "text": "Hexagon Pointed Top",
+ "value": "hexagon_pointed_top"
+ },
+ {
+ "text": "Hexagon Flat Top",
+ "value": "hexagon_flat_top"
+ },
+ {
+ "text": "Circle",
+ "value": "circle"
+ },
+ {
+ "text": "Cross",
+ "value": "cross"
+ },
+ {
+ "text": "Diamond",
+ "value": "diamond"
+ },
+ {
+ "text": "Square",
+ "value": "square"
+ },
+ {
+ "text": "Star",
+ "value": "star"
+ },
+ {
+ "text": "Triangle",
+ "value": "triangle"
+ },
+ {
+ "text": "Wye",
+ "value": "wye"
+ }
+ ],
+ "sortDirections": [
+ {
+ "text": "Ascending",
+ "value": "asc"
+ },
+ {
+ "text": "Descending",
+ "value": "desc"
+ }
+ ],
+ "sortFields": [
+ {
+ "text": "Name",
+ "value": "name"
+ },
+ {
+ "text": "Threshold Level",
+ "value": "thresholdLevel"
+ },
+ {
+ "text": "Value",
+ "value": "value"
+ }
+ ],
+ "svgContainer": {},
+ "targets": [
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "A",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "B",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "C",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "D",
+ "scenarioId": "random_walk"
+ },
+ {
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "E",
+ "scenarioId": "random_walk"
+ }
+ ],
+ "thresholdStates": [
+ {
+ "text": "ok",
+ "value": 0
+ },
+ {
+ "text": "warning",
+ "value": 1
+ },
+ {
+ "text": "critical",
+ "value": 2
+ },
+ {
+ "text": "custom",
+ "value": 3
+ }
+ ],
+ "title": "Composite crash",
+ "type": "grafana-polystat-panel",
+ "unitFormats": [
+ {
+ "submenu": [
+ {
+ "text": "none",
+ "value": "none"
+ },
+ {
+ "text": "short",
+ "value": "short"
+ },
+ {
+ "text": "percent (0-100)",
+ "value": "percent"
+ },
+ {
+ "text": "percent (0.0-1.0)",
+ "value": "percentunit"
+ },
+ {
+ "text": "Humidity (%H)",
+ "value": "humidity"
+ },
+ {
+ "text": "decibel",
+ "value": "dB"
+ },
+ {
+ "text": "hexadecimal (0x)",
+ "value": "hex0x"
+ },
+ {
+ "text": "hexadecimal",
+ "value": "hex"
+ },
+ {
+ "text": "scientific notation",
+ "value": "sci"
+ },
+ {
+ "text": "locale format",
+ "value": "locale"
+ }
+ ],
+ "text": "none"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Dollars ($)",
+ "value": "currencyUSD"
+ },
+ {
+ "text": "Pounds (£)",
+ "value": "currencyGBP"
+ },
+ {
+ "text": "Euro (€)",
+ "value": "currencyEUR"
+ },
+ {
+ "text": "Yen (¥)",
+ "value": "currencyJPY"
+ },
+ {
+ "text": "Rubles (₽)",
+ "value": "currencyRUB"
+ },
+ {
+ "text": "Hryvnias (₴)",
+ "value": "currencyUAH"
+ },
+ {
+ "text": "Real (R$)",
+ "value": "currencyBRL"
+ },
+ {
+ "text": "Danish Krone (kr)",
+ "value": "currencyDKK"
+ },
+ {
+ "text": "Icelandic Króna (kr)",
+ "value": "currencyISK"
+ },
+ {
+ "text": "Norwegian Krone (kr)",
+ "value": "currencyNOK"
+ },
+ {
+ "text": "Swedish Krona (kr)",
+ "value": "currencySEK"
+ },
+ {
+ "text": "Czech koruna (czk)",
+ "value": "currencyCZK"
+ },
+ {
+ "text": "Swiss franc (CHF)",
+ "value": "currencyCHF"
+ },
+ {
+ "text": "Polish Złoty (PLN)",
+ "value": "currencyPLN"
+ },
+ {
+ "text": "Bitcoin (฿)",
+ "value": "currencyBTC"
+ }
+ ],
+ "text": "currency"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Hertz (1/s)",
+ "value": "hertz"
+ },
+ {
+ "text": "nanoseconds (ns)",
+ "value": "ns"
+ },
+ {
+ "text": "microseconds (µs)",
+ "value": "µs"
+ },
+ {
+ "text": "milliseconds (ms)",
+ "value": "ms"
+ },
+ {
+ "text": "seconds (s)",
+ "value": "s"
+ },
+ {
+ "text": "minutes (m)",
+ "value": "m"
+ },
+ {
+ "text": "hours (h)",
+ "value": "h"
+ },
+ {
+ "text": "days (d)",
+ "value": "d"
+ },
+ {
+ "text": "duration (ms)",
+ "value": "dtdurationms"
+ },
+ {
+ "text": "duration (s)",
+ "value": "dtdurations"
+ },
+ {
+ "text": "duration (hh:mm:ss)",
+ "value": "dthms"
+ },
+ {
+ "text": "Timeticks (s/100)",
+ "value": "timeticks"
+ }
+ ],
+ "text": "time"
+ },
+ {
+ "submenu": [
+ {
+ "text": "YYYY-MM-DD HH:mm:ss",
+ "value": "dateTimeAsIso"
+ },
+ {
+ "text": "DD/MM/YYYY h:mm:ss a",
+ "value": "dateTimeAsUS"
+ },
+ {
+ "text": "From Now",
+ "value": "dateTimeFromNow"
+ }
+ ],
+ "text": "date & time"
+ },
+ {
+ "submenu": [
+ {
+ "text": "bits",
+ "value": "bits"
+ },
+ {
+ "text": "bytes",
+ "value": "bytes"
+ },
+ {
+ "text": "kibibytes",
+ "value": "kbytes"
+ },
+ {
+ "text": "mebibytes",
+ "value": "mbytes"
+ },
+ {
+ "text": "gibibytes",
+ "value": "gbytes"
+ }
+ ],
+ "text": "data (IEC)"
+ },
+ {
+ "submenu": [
+ {
+ "text": "bits",
+ "value": "decbits"
+ },
+ {
+ "text": "bytes",
+ "value": "decbytes"
+ },
+ {
+ "text": "kilobytes",
+ "value": "deckbytes"
+ },
+ {
+ "text": "megabytes",
+ "value": "decmbytes"
+ },
+ {
+ "text": "gigabytes",
+ "value": "decgbytes"
+ }
+ ],
+ "text": "data (Metric)"
+ },
+ {
+ "submenu": [
+ {
+ "text": "packets/sec",
+ "value": "pps"
+ },
+ {
+ "text": "bits/sec",
+ "value": "bps"
+ },
+ {
+ "text": "bytes/sec",
+ "value": "Bps"
+ },
+ {
+ "text": "kilobits/sec",
+ "value": "Kbits"
+ },
+ {
+ "text": "kilobytes/sec",
+ "value": "KBs"
+ },
+ {
+ "text": "megabits/sec",
+ "value": "Mbits"
+ },
+ {
+ "text": "megabytes/sec",
+ "value": "MBs"
+ },
+ {
+ "text": "gigabytes/sec",
+ "value": "GBs"
+ },
+ {
+ "text": "gigabits/sec",
+ "value": "Gbits"
+ }
+ ],
+ "text": "data rate"
+ },
+ {
+ "submenu": [
+ {
+ "text": "hashes/sec",
+ "value": "Hs"
+ },
+ {
+ "text": "kilohashes/sec",
+ "value": "KHs"
+ },
+ {
+ "text": "megahashes/sec",
+ "value": "MHs"
+ },
+ {
+ "text": "gigahashes/sec",
+ "value": "GHs"
+ },
+ {
+ "text": "terahashes/sec",
+ "value": "THs"
+ },
+ {
+ "text": "petahashes/sec",
+ "value": "PHs"
+ },
+ {
+ "text": "exahashes/sec",
+ "value": "EHs"
+ }
+ ],
+ "text": "hash rate"
+ },
+ {
+ "submenu": [
+ {
+ "text": "ops/sec (ops)",
+ "value": "ops"
+ },
+ {
+ "text": "requests/sec (rps)",
+ "value": "reqps"
+ },
+ {
+ "text": "reads/sec (rps)",
+ "value": "rps"
+ },
+ {
+ "text": "writes/sec (wps)",
+ "value": "wps"
+ },
+ {
+ "text": "I/O ops/sec (iops)",
+ "value": "iops"
+ },
+ {
+ "text": "ops/min (opm)",
+ "value": "opm"
+ },
+ {
+ "text": "reads/min (rpm)",
+ "value": "rpm"
+ },
+ {
+ "text": "writes/min (wpm)",
+ "value": "wpm"
+ }
+ ],
+ "text": "throughput"
+ },
+ {
+ "submenu": [
+ {
+ "text": "millimetre (mm)",
+ "value": "lengthmm"
+ },
+ {
+ "text": "meter (m)",
+ "value": "lengthm"
+ },
+ {
+ "text": "feet (ft)",
+ "value": "lengthft"
+ },
+ {
+ "text": "kilometer (km)",
+ "value": "lengthkm"
+ },
+ {
+ "text": "mile (mi)",
+ "value": "lengthmi"
+ }
+ ],
+ "text": "length"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Square Meters (m²)",
+ "value": "areaM2"
+ },
+ {
+ "text": "Square Feet (ft²)",
+ "value": "areaF2"
+ },
+ {
+ "text": "Square Miles (mi²)",
+ "value": "areaMI2"
+ }
+ ],
+ "text": "area"
+ },
+ {
+ "submenu": [
+ {
+ "text": "milligram (mg)",
+ "value": "massmg"
+ },
+ {
+ "text": "gram (g)",
+ "value": "massg"
+ },
+ {
+ "text": "kilogram (kg)",
+ "value": "masskg"
+ },
+ {
+ "text": "metric ton (t)",
+ "value": "masst"
+ }
+ ],
+ "text": "mass"
+ },
+ {
+ "submenu": [
+ {
+ "text": "metres/second (m/s)",
+ "value": "velocityms"
+ },
+ {
+ "text": "kilometers/hour (km/h)",
+ "value": "velocitykmh"
+ },
+ {
+ "text": "miles/hour (mph)",
+ "value": "velocitymph"
+ },
+ {
+ "text": "knot (kn)",
+ "value": "velocityknot"
+ }
+ ],
+ "text": "velocity"
+ },
+ {
+ "submenu": [
+ {
+ "text": "millilitre (mL)",
+ "value": "mlitre"
+ },
+ {
+ "text": "litre (L)",
+ "value": "litre"
+ },
+ {
+ "text": "cubic metre",
+ "value": "m3"
+ },
+ {
+ "text": "Normal cubic metre",
+ "value": "Nm3"
+ },
+ {
+ "text": "cubic decimetre",
+ "value": "dm3"
+ },
+ {
+ "text": "gallons",
+ "value": "gallons"
+ }
+ ],
+ "text": "volume"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Watt (W)",
+ "value": "watt"
+ },
+ {
+ "text": "Kilowatt (kW)",
+ "value": "kwatt"
+ },
+ {
+ "text": "Milliwatt (mW)",
+ "value": "mwatt"
+ },
+ {
+ "text": "Watt per square metre (W/m²)",
+ "value": "Wm2"
+ },
+ {
+ "text": "Volt-ampere (VA)",
+ "value": "voltamp"
+ },
+ {
+ "text": "Kilovolt-ampere (kVA)",
+ "value": "kvoltamp"
+ },
+ {
+ "text": "Volt-ampere reactive (var)",
+ "value": "voltampreact"
+ },
+ {
+ "text": "Kilovolt-ampere reactive (kvar)",
+ "value": "kvoltampreact"
+ },
+ {
+ "text": "Watt-hour (Wh)",
+ "value": "watth"
+ },
+ {
+ "text": "Kilowatt-hour (kWh)",
+ "value": "kwatth"
+ },
+ {
+ "text": "Kilowatt-min (kWm)",
+ "value": "kwattm"
+ },
+ {
+ "text": "Joule (J)",
+ "value": "joule"
+ },
+ {
+ "text": "Electron volt (eV)",
+ "value": "ev"
+ },
+ {
+ "text": "Ampere (A)",
+ "value": "amp"
+ },
+ {
+ "text": "Kiloampere (kA)",
+ "value": "kamp"
+ },
+ {
+ "text": "Milliampere (mA)",
+ "value": "mamp"
+ },
+ {
+ "text": "Volt (V)",
+ "value": "volt"
+ },
+ {
+ "text": "Kilovolt (kV)",
+ "value": "kvolt"
+ },
+ {
+ "text": "Millivolt (mV)",
+ "value": "mvolt"
+ },
+ {
+ "text": "Decibel-milliwatt (dBm)",
+ "value": "dBm"
+ },
+ {
+ "text": "Ohm (Ω)",
+ "value": "ohm"
+ },
+ {
+ "text": "Lumens (Lm)",
+ "value": "lumens"
+ }
+ ],
+ "text": "energy"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Celsius (°C)",
+ "value": "celsius"
+ },
+ {
+ "text": "Farenheit (°F)",
+ "value": "farenheit"
+ },
+ {
+ "text": "Kelvin (K)",
+ "value": "kelvin"
+ }
+ ],
+ "text": "temperature"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Millibars",
+ "value": "pressurembar"
+ },
+ {
+ "text": "Bars",
+ "value": "pressurebar"
+ },
+ {
+ "text": "Kilobars",
+ "value": "pressurekbar"
+ },
+ {
+ "text": "Hectopascals",
+ "value": "pressurehpa"
+ },
+ {
+ "text": "Kilopascals",
+ "value": "pressurekpa"
+ },
+ {
+ "text": "Inches of mercury",
+ "value": "pressurehg"
+ },
+ {
+ "text": "PSI",
+ "value": "pressurepsi"
+ }
+ ],
+ "text": "pressure"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Newton-meters (Nm)",
+ "value": "forceNm"
+ },
+ {
+ "text": "Kilonewton-meters (kNm)",
+ "value": "forcekNm"
+ },
+ {
+ "text": "Newtons (N)",
+ "value": "forceN"
+ },
+ {
+ "text": "Kilonewtons (kN)",
+ "value": "forcekN"
+ }
+ ],
+ "text": "force"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Gallons/min (gpm)",
+ "value": "flowgpm"
+ },
+ {
+ "text": "Cubic meters/sec (cms)",
+ "value": "flowcms"
+ },
+ {
+ "text": "Cubic feet/sec (cfs)",
+ "value": "flowcfs"
+ },
+ {
+ "text": "Cubic feet/min (cfm)",
+ "value": "flowcfm"
+ },
+ {
+ "text": "Litre/hour",
+ "value": "litreh"
+ },
+ {
+ "text": "Litre/min (l/min)",
+ "value": "flowlpm"
+ },
+ {
+ "text": "milliLitre/min (mL/min)",
+ "value": "flowmlpm"
+ }
+ ],
+ "text": "flow"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Degrees (°)",
+ "value": "degree"
+ },
+ {
+ "text": "Radians",
+ "value": "radian"
+ },
+ {
+ "text": "Gradian",
+ "value": "grad"
+ }
+ ],
+ "text": "angle"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Meters/sec²",
+ "value": "accMS2"
+ },
+ {
+ "text": "Feet/sec²",
+ "value": "accFS2"
+ },
+ {
+ "text": "G unit",
+ "value": "accG"
+ }
+ ],
+ "text": "acceleration"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Becquerel (Bq)",
+ "value": "radbq"
+ },
+ {
+ "text": "curie (Ci)",
+ "value": "radci"
+ },
+ {
+ "text": "Gray (Gy)",
+ "value": "radgy"
+ },
+ {
+ "text": "rad",
+ "value": "radrad"
+ },
+ {
+ "text": "Sievert (Sv)",
+ "value": "radsv"
+ },
+ {
+ "text": "rem",
+ "value": "radrem"
+ },
+ {
+ "text": "Exposure (C/kg)",
+ "value": "radexpckg"
+ },
+ {
+ "text": "roentgen (R)",
+ "value": "radr"
+ },
+ {
+ "text": "Sievert/hour (Sv/h)",
+ "value": "radsvh"
+ }
+ ],
+ "text": "radiation"
+ },
+ {
+ "submenu": [
+ {
+ "text": "parts-per-million (ppm)",
+ "value": "ppm"
+ },
+ {
+ "text": "parts-per-billion (ppb)",
+ "value": "conppb"
+ },
+ {
+ "text": "nanogram per cubic metre (ng/m³)",
+ "value": "conngm3"
+ },
+ {
+ "text": "nanogram per normal cubic metre (ng/Nm³)",
+ "value": "conngNm3"
+ },
+ {
+ "text": "microgram per cubic metre (μg/m³)",
+ "value": "conμgm3"
+ },
+ {
+ "text": "microgram per normal cubic metre (μg/Nm³)",
+ "value": "conμgNm3"
+ },
+ {
+ "text": "milligram per cubic metre (mg/m³)",
+ "value": "conmgm3"
+ },
+ {
+ "text": "milligram per normal cubic metre (mg/Nm³)",
+ "value": "conmgNm3"
+ },
+ {
+ "text": "gram per cubic metre (g/m³)",
+ "value": "congm3"
+ },
+ {
+ "text": "gram per normal cubic metre (g/Nm³)",
+ "value": "congNm3"
+ }
+ ],
+ "text": "concentration"
+ }
+ ]
+ },
+ {
+ "animationModes": [
+ {
+ "text": "Show All",
+ "value": "all"
+ },
+ {
+ "text": "Show Triggered",
+ "value": "triggered"
+ }
+ ],
+ "colors": [
+ "#299c46",
+ "rgba(237, 129, 40, 0.89)",
+ "#d44a3a"
+ ],
+ "d3DivId": "d3_svg_2",
+ "datasource": "gdev-testdata",
+ "decimals": 2,
+ "displayModes": [
+ {
+ "text": "Show All",
+ "value": "all"
+ },
+ {
+ "text": "Show Triggered",
+ "value": "triggered"
+ }
+ ],
+ "fontSizes": [
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 22,
+ 24,
+ 26,
+ 28,
+ 30,
+ 32,
+ 34,
+ 36,
+ 38,
+ 40,
+ 42,
+ 44,
+ 46,
+ 48,
+ 50,
+ 52,
+ 54,
+ 56,
+ 58,
+ 60,
+ 62,
+ 64,
+ 66,
+ 68,
+ 70
+ ],
+ "fontTypes": [
+ "Open Sans",
+ "Arial",
+ "Avant Garde",
+ "Bookman",
+ "Consolas",
+ "Courier",
+ "Courier New",
+ "Futura",
+ "Garamond",
+ "Helvetica",
+ "Palatino",
+ "Times",
+ "Times New Roman",
+ "Verdana"
+ ],
+ "format": "none",
+ "gridPos": {
+ "h": 10,
+ "w": 12,
+ "x": 0,
+ "y": 9
+ },
+ "id": 2,
+ "links": [],
+ "notcolors": [
+ "rgba(245, 54, 54, 0.9)",
+ "rgba(237, 129, 40, 0.89)",
+ "rgba(50, 172, 45, 0.97)"
+ ],
+ "operatorName": "avg",
+ "operatorOptions": [
+ {
+ "text": "Average",
+ "value": "avg"
+ },
+ {
+ "text": "Count",
+ "value": "count"
+ },
+ {
+ "text": "Current",
+ "value": "current"
+ },
+ {
+ "text": "Delta",
+ "value": "delta"
+ },
+ {
+ "text": "Difference",
+ "value": "diff"
+ },
+ {
+ "text": "First",
+ "value": "first"
+ },
+ {
+ "text": "Log Min",
+ "value": "logmin"
+ },
+ {
+ "text": "Max",
+ "value": "max"
+ },
+ {
+ "text": "Min",
+ "value": "min"
+ },
+ {
+ "text": "Name",
+ "value": "name"
+ },
+ {
+ "text": "Time of Last Point",
+ "value": "last_time"
+ },
+ {
+ "text": "Time Step",
+ "value": "time_step"
+ },
+ {
+ "text": "Total",
+ "value": "total"
+ }
+ ],
+ "polystat": {
+ "animationSpeed": 2500,
+ "columnAutoSize": true,
+ "columns": 1,
+ "defaultClickThrough": "",
+ "defaultClickThroughSanitize": true,
+ "displayLimit": 100,
+ "fontAutoScale": true,
+ "fontSize": 12,
+ "globalDisplayMode": "all",
+ "globalOperatorName": "avg",
+ "gradientEnabled": true,
+ "hexagonSortByDirection": "asc",
+ "hexagonSortByField": "name",
+ "maxMetrics": 0,
+ "polygonBorderColor": "black",
+ "polygonBorderSize": 2,
+ "radius": "",
+ "radiusAutoSize": true,
+ "rowAutoSize": true,
+ "rows": 1,
+ "shape": "hexagon_pointed_top",
+ "tooltipDisplayMode": "all",
+ "tooltipDisplayTextTriggeredEmpty": "OK",
+ "tooltipFontSize": 12,
+ "tooltipFontType": "Open Sans",
+ "tooltipPrimarySortDirection": "desc",
+ "tooltipPrimarySortField": "thresholdLevel",
+ "tooltipSecondarySortDirection": "desc",
+ "tooltipSecondarySortField": "value",
+ "tooltipTimestampEnabled": true
+ },
+ "savedComposites": [],
+ "savedOverrides": [],
+ "shapes": [
+ {
+ "text": "Hexagon Pointed Top",
+ "value": "hexagon_pointed_top"
+ },
+ {
+ "text": "Hexagon Flat Top",
+ "value": "hexagon_flat_top"
+ },
+ {
+ "text": "Circle",
+ "value": "circle"
+ },
+ {
+ "text": "Cross",
+ "value": "cross"
+ },
+ {
+ "text": "Diamond",
+ "value": "diamond"
+ },
+ {
+ "text": "Square",
+ "value": "square"
+ },
+ {
+ "text": "Star",
+ "value": "star"
+ },
+ {
+ "text": "Triangle",
+ "value": "triangle"
+ },
+ {
+ "text": "Wye",
+ "value": "wye"
+ }
+ ],
+ "sortDirections": [
+ {
+ "text": "Ascending",
+ "value": "asc"
+ },
+ {
+ "text": "Descending",
+ "value": "desc"
+ }
+ ],
+ "sortFields": [
+ {
+ "text": "Name",
+ "value": "name"
+ },
+ {
+ "text": "Threshold Level",
+ "value": "thresholdLevel"
+ },
+ {
+ "text": "Value",
+ "value": "value"
+ }
+ ],
+ "svgContainer": {},
+ "targets": [
+ {
+ "alias": "Sensor-A",
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ },
+ {
+ "alias": "Sensor-B",
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "B",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "3433,23432,55"
+ },
+ {
+ "alias": "Sensor-C",
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "C",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,2,3,4,5,6"
+ },
+ {
+ "alias": "Sensor-E",
+ "expr": "",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "D",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "thresholdStates": [
+ {
+ "text": "ok",
+ "value": 0
+ },
+ {
+ "text": "warning",
+ "value": 1
+ },
+ {
+ "text": "critical",
+ "value": 2
+ },
+ {
+ "text": "custom",
+ "value": 3
+ }
+ ],
+ "title": "No Value in Sensor-C Bug",
+ "type": "grafana-polystat-panel",
+ "unitFormats": [
+ {
+ "submenu": [
+ {
+ "text": "none",
+ "value": "none"
+ },
+ {
+ "text": "short",
+ "value": "short"
+ },
+ {
+ "text": "percent (0-100)",
+ "value": "percent"
+ },
+ {
+ "text": "percent (0.0-1.0)",
+ "value": "percentunit"
+ },
+ {
+ "text": "Humidity (%H)",
+ "value": "humidity"
+ },
+ {
+ "text": "decibel",
+ "value": "dB"
+ },
+ {
+ "text": "hexadecimal (0x)",
+ "value": "hex0x"
+ },
+ {
+ "text": "hexadecimal",
+ "value": "hex"
+ },
+ {
+ "text": "scientific notation",
+ "value": "sci"
+ },
+ {
+ "text": "locale format",
+ "value": "locale"
+ }
+ ],
+ "text": "none"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Dollars ($)",
+ "value": "currencyUSD"
+ },
+ {
+ "text": "Pounds (£)",
+ "value": "currencyGBP"
+ },
+ {
+ "text": "Euro (€)",
+ "value": "currencyEUR"
+ },
+ {
+ "text": "Yen (¥)",
+ "value": "currencyJPY"
+ },
+ {
+ "text": "Rubles (₽)",
+ "value": "currencyRUB"
+ },
+ {
+ "text": "Hryvnias (₴)",
+ "value": "currencyUAH"
+ },
+ {
+ "text": "Real (R$)",
+ "value": "currencyBRL"
+ },
+ {
+ "text": "Danish Krone (kr)",
+ "value": "currencyDKK"
+ },
+ {
+ "text": "Icelandic Króna (kr)",
+ "value": "currencyISK"
+ },
+ {
+ "text": "Norwegian Krone (kr)",
+ "value": "currencyNOK"
+ },
+ {
+ "text": "Swedish Krona (kr)",
+ "value": "currencySEK"
+ },
+ {
+ "text": "Czech koruna (czk)",
+ "value": "currencyCZK"
+ },
+ {
+ "text": "Swiss franc (CHF)",
+ "value": "currencyCHF"
+ },
+ {
+ "text": "Polish Złoty (PLN)",
+ "value": "currencyPLN"
+ },
+ {
+ "text": "Bitcoin (฿)",
+ "value": "currencyBTC"
+ }
+ ],
+ "text": "currency"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Hertz (1/s)",
+ "value": "hertz"
+ },
+ {
+ "text": "nanoseconds (ns)",
+ "value": "ns"
+ },
+ {
+ "text": "microseconds (µs)",
+ "value": "µs"
+ },
+ {
+ "text": "milliseconds (ms)",
+ "value": "ms"
+ },
+ {
+ "text": "seconds (s)",
+ "value": "s"
+ },
+ {
+ "text": "minutes (m)",
+ "value": "m"
+ },
+ {
+ "text": "hours (h)",
+ "value": "h"
+ },
+ {
+ "text": "days (d)",
+ "value": "d"
+ },
+ {
+ "text": "duration (ms)",
+ "value": "dtdurationms"
+ },
+ {
+ "text": "duration (s)",
+ "value": "dtdurations"
+ },
+ {
+ "text": "duration (hh:mm:ss)",
+ "value": "dthms"
+ },
+ {
+ "text": "Timeticks (s/100)",
+ "value": "timeticks"
+ }
+ ],
+ "text": "time"
+ },
+ {
+ "submenu": [
+ {
+ "text": "YYYY-MM-DD HH:mm:ss",
+ "value": "dateTimeAsIso"
+ },
+ {
+ "text": "DD/MM/YYYY h:mm:ss a",
+ "value": "dateTimeAsUS"
+ },
+ {
+ "text": "From Now",
+ "value": "dateTimeFromNow"
+ }
+ ],
+ "text": "date & time"
+ },
+ {
+ "submenu": [
+ {
+ "text": "bits",
+ "value": "bits"
+ },
+ {
+ "text": "bytes",
+ "value": "bytes"
+ },
+ {
+ "text": "kibibytes",
+ "value": "kbytes"
+ },
+ {
+ "text": "mebibytes",
+ "value": "mbytes"
+ },
+ {
+ "text": "gibibytes",
+ "value": "gbytes"
+ }
+ ],
+ "text": "data (IEC)"
+ },
+ {
+ "submenu": [
+ {
+ "text": "bits",
+ "value": "decbits"
+ },
+ {
+ "text": "bytes",
+ "value": "decbytes"
+ },
+ {
+ "text": "kilobytes",
+ "value": "deckbytes"
+ },
+ {
+ "text": "megabytes",
+ "value": "decmbytes"
+ },
+ {
+ "text": "gigabytes",
+ "value": "decgbytes"
+ }
+ ],
+ "text": "data (Metric)"
+ },
+ {
+ "submenu": [
+ {
+ "text": "packets/sec",
+ "value": "pps"
+ },
+ {
+ "text": "bits/sec",
+ "value": "bps"
+ },
+ {
+ "text": "bytes/sec",
+ "value": "Bps"
+ },
+ {
+ "text": "kilobits/sec",
+ "value": "Kbits"
+ },
+ {
+ "text": "kilobytes/sec",
+ "value": "KBs"
+ },
+ {
+ "text": "megabits/sec",
+ "value": "Mbits"
+ },
+ {
+ "text": "megabytes/sec",
+ "value": "MBs"
+ },
+ {
+ "text": "gigabytes/sec",
+ "value": "GBs"
+ },
+ {
+ "text": "gigabits/sec",
+ "value": "Gbits"
+ }
+ ],
+ "text": "data rate"
+ },
+ {
+ "submenu": [
+ {
+ "text": "hashes/sec",
+ "value": "Hs"
+ },
+ {
+ "text": "kilohashes/sec",
+ "value": "KHs"
+ },
+ {
+ "text": "megahashes/sec",
+ "value": "MHs"
+ },
+ {
+ "text": "gigahashes/sec",
+ "value": "GHs"
+ },
+ {
+ "text": "terahashes/sec",
+ "value": "THs"
+ },
+ {
+ "text": "petahashes/sec",
+ "value": "PHs"
+ },
+ {
+ "text": "exahashes/sec",
+ "value": "EHs"
+ }
+ ],
+ "text": "hash rate"
+ },
+ {
+ "submenu": [
+ {
+ "text": "ops/sec (ops)",
+ "value": "ops"
+ },
+ {
+ "text": "requests/sec (rps)",
+ "value": "reqps"
+ },
+ {
+ "text": "reads/sec (rps)",
+ "value": "rps"
+ },
+ {
+ "text": "writes/sec (wps)",
+ "value": "wps"
+ },
+ {
+ "text": "I/O ops/sec (iops)",
+ "value": "iops"
+ },
+ {
+ "text": "ops/min (opm)",
+ "value": "opm"
+ },
+ {
+ "text": "reads/min (rpm)",
+ "value": "rpm"
+ },
+ {
+ "text": "writes/min (wpm)",
+ "value": "wpm"
+ }
+ ],
+ "text": "throughput"
+ },
+ {
+ "submenu": [
+ {
+ "text": "millimetre (mm)",
+ "value": "lengthmm"
+ },
+ {
+ "text": "meter (m)",
+ "value": "lengthm"
+ },
+ {
+ "text": "feet (ft)",
+ "value": "lengthft"
+ },
+ {
+ "text": "kilometer (km)",
+ "value": "lengthkm"
+ },
+ {
+ "text": "mile (mi)",
+ "value": "lengthmi"
+ }
+ ],
+ "text": "length"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Square Meters (m²)",
+ "value": "areaM2"
+ },
+ {
+ "text": "Square Feet (ft²)",
+ "value": "areaF2"
+ },
+ {
+ "text": "Square Miles (mi²)",
+ "value": "areaMI2"
+ }
+ ],
+ "text": "area"
+ },
+ {
+ "submenu": [
+ {
+ "text": "milligram (mg)",
+ "value": "massmg"
+ },
+ {
+ "text": "gram (g)",
+ "value": "massg"
+ },
+ {
+ "text": "kilogram (kg)",
+ "value": "masskg"
+ },
+ {
+ "text": "metric ton (t)",
+ "value": "masst"
+ }
+ ],
+ "text": "mass"
+ },
+ {
+ "submenu": [
+ {
+ "text": "metres/second (m/s)",
+ "value": "velocityms"
+ },
+ {
+ "text": "kilometers/hour (km/h)",
+ "value": "velocitykmh"
+ },
+ {
+ "text": "miles/hour (mph)",
+ "value": "velocitymph"
+ },
+ {
+ "text": "knot (kn)",
+ "value": "velocityknot"
+ }
+ ],
+ "text": "velocity"
+ },
+ {
+ "submenu": [
+ {
+ "text": "millilitre (mL)",
+ "value": "mlitre"
+ },
+ {
+ "text": "litre (L)",
+ "value": "litre"
+ },
+ {
+ "text": "cubic metre",
+ "value": "m3"
+ },
+ {
+ "text": "Normal cubic metre",
+ "value": "Nm3"
+ },
+ {
+ "text": "cubic decimetre",
+ "value": "dm3"
+ },
+ {
+ "text": "gallons",
+ "value": "gallons"
+ }
+ ],
+ "text": "volume"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Watt (W)",
+ "value": "watt"
+ },
+ {
+ "text": "Kilowatt (kW)",
+ "value": "kwatt"
+ },
+ {
+ "text": "Milliwatt (mW)",
+ "value": "mwatt"
+ },
+ {
+ "text": "Watt per square metre (W/m²)",
+ "value": "Wm2"
+ },
+ {
+ "text": "Volt-ampere (VA)",
+ "value": "voltamp"
+ },
+ {
+ "text": "Kilovolt-ampere (kVA)",
+ "value": "kvoltamp"
+ },
+ {
+ "text": "Volt-ampere reactive (var)",
+ "value": "voltampreact"
+ },
+ {
+ "text": "Kilovolt-ampere reactive (kvar)",
+ "value": "kvoltampreact"
+ },
+ {
+ "text": "Watt-hour (Wh)",
+ "value": "watth"
+ },
+ {
+ "text": "Kilowatt-hour (kWh)",
+ "value": "kwatth"
+ },
+ {
+ "text": "Kilowatt-min (kWm)",
+ "value": "kwattm"
+ },
+ {
+ "text": "Joule (J)",
+ "value": "joule"
+ },
+ {
+ "text": "Electron volt (eV)",
+ "value": "ev"
+ },
+ {
+ "text": "Ampere (A)",
+ "value": "amp"
+ },
+ {
+ "text": "Kiloampere (kA)",
+ "value": "kamp"
+ },
+ {
+ "text": "Milliampere (mA)",
+ "value": "mamp"
+ },
+ {
+ "text": "Volt (V)",
+ "value": "volt"
+ },
+ {
+ "text": "Kilovolt (kV)",
+ "value": "kvolt"
+ },
+ {
+ "text": "Millivolt (mV)",
+ "value": "mvolt"
+ },
+ {
+ "text": "Decibel-milliwatt (dBm)",
+ "value": "dBm"
+ },
+ {
+ "text": "Ohm (Ω)",
+ "value": "ohm"
+ },
+ {
+ "text": "Lumens (Lm)",
+ "value": "lumens"
+ }
+ ],
+ "text": "energy"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Celsius (°C)",
+ "value": "celsius"
+ },
+ {
+ "text": "Farenheit (°F)",
+ "value": "farenheit"
+ },
+ {
+ "text": "Kelvin (K)",
+ "value": "kelvin"
+ }
+ ],
+ "text": "temperature"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Millibars",
+ "value": "pressurembar"
+ },
+ {
+ "text": "Bars",
+ "value": "pressurebar"
+ },
+ {
+ "text": "Kilobars",
+ "value": "pressurekbar"
+ },
+ {
+ "text": "Hectopascals",
+ "value": "pressurehpa"
+ },
+ {
+ "text": "Kilopascals",
+ "value": "pressurekpa"
+ },
+ {
+ "text": "Inches of mercury",
+ "value": "pressurehg"
+ },
+ {
+ "text": "PSI",
+ "value": "pressurepsi"
+ }
+ ],
+ "text": "pressure"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Newton-meters (Nm)",
+ "value": "forceNm"
+ },
+ {
+ "text": "Kilonewton-meters (kNm)",
+ "value": "forcekNm"
+ },
+ {
+ "text": "Newtons (N)",
+ "value": "forceN"
+ },
+ {
+ "text": "Kilonewtons (kN)",
+ "value": "forcekN"
+ }
+ ],
+ "text": "force"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Gallons/min (gpm)",
+ "value": "flowgpm"
+ },
+ {
+ "text": "Cubic meters/sec (cms)",
+ "value": "flowcms"
+ },
+ {
+ "text": "Cubic feet/sec (cfs)",
+ "value": "flowcfs"
+ },
+ {
+ "text": "Cubic feet/min (cfm)",
+ "value": "flowcfm"
+ },
+ {
+ "text": "Litre/hour",
+ "value": "litreh"
+ },
+ {
+ "text": "Litre/min (l/min)",
+ "value": "flowlpm"
+ },
+ {
+ "text": "milliLitre/min (mL/min)",
+ "value": "flowmlpm"
+ }
+ ],
+ "text": "flow"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Degrees (°)",
+ "value": "degree"
+ },
+ {
+ "text": "Radians",
+ "value": "radian"
+ },
+ {
+ "text": "Gradian",
+ "value": "grad"
+ }
+ ],
+ "text": "angle"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Meters/sec²",
+ "value": "accMS2"
+ },
+ {
+ "text": "Feet/sec²",
+ "value": "accFS2"
+ },
+ {
+ "text": "G unit",
+ "value": "accG"
+ }
+ ],
+ "text": "acceleration"
+ },
+ {
+ "submenu": [
+ {
+ "text": "Becquerel (Bq)",
+ "value": "radbq"
+ },
+ {
+ "text": "curie (Ci)",
+ "value": "radci"
+ },
+ {
+ "text": "Gray (Gy)",
+ "value": "radgy"
+ },
+ {
+ "text": "rad",
+ "value": "radrad"
+ },
+ {
+ "text": "Sievert (Sv)",
+ "value": "radsv"
+ },
+ {
+ "text": "rem",
+ "value": "radrem"
+ },
+ {
+ "text": "Exposure (C/kg)",
+ "value": "radexpckg"
+ },
+ {
+ "text": "roentgen (R)",
+ "value": "radr"
+ },
+ {
+ "text": "Sievert/hour (Sv/h)",
+ "value": "radsvh"
+ }
+ ],
+ "text": "radiation"
+ },
+ {
+ "submenu": [
+ {
+ "text": "parts-per-million (ppm)",
+ "value": "ppm"
+ },
+ {
+ "text": "parts-per-billion (ppb)",
+ "value": "conppb"
+ },
+ {
+ "text": "nanogram per cubic metre (ng/m³)",
+ "value": "conngm3"
+ },
+ {
+ "text": "nanogram per normal cubic metre (ng/Nm³)",
+ "value": "conngNm3"
+ },
+ {
+ "text": "microgram per cubic metre (μg/m³)",
+ "value": "conμgm3"
+ },
+ {
+ "text": "microgram per normal cubic metre (μg/Nm³)",
+ "value": "conμgNm3"
+ },
+ {
+ "text": "milligram per cubic metre (mg/m³)",
+ "value": "conmgm3"
+ },
+ {
+ "text": "milligram per normal cubic metre (mg/Nm³)",
+ "value": "conmgNm3"
+ },
+ {
+ "text": "gram per cubic metre (g/m³)",
+ "value": "congm3"
+ },
+ {
+ "text": "gram per normal cubic metre (g/Nm³)",
+ "value": "congNm3"
+ }
+ ],
+ "text": "concentration"
+ }
+ ]
+ }
+ ],
+ "schemaVersion": 16,
+ "style": "dark",
+ "tags": [
+ "panel-test",
+ "gdev"
+ ],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": [
+ "5s",
+ "10s",
+ "30s",
+ "1m",
+ "5m",
+ "15m",
+ "30m",
+ "1h",
+ "2h",
+ "1d"
+ ],
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "",
+ "title": "Panel Tests - Polystat",
+ "version": 5
+}
diff --git a/devenv/dev-dashboards/datasource_tests_elasticsearch_compare.json b/devenv/dev-dashboards/datasource_tests_elasticsearch_compare.json
index f07c6f46332..c631ea0a151 100644
--- a/devenv/dev-dashboards/datasource_tests_elasticsearch_compare.json
+++ b/devenv/dev-dashboards/datasource_tests_elasticsearch_compare.json
@@ -17,7 +17,7 @@
"editable": true,
"gnetId": null,
"graphTooltip": 0,
- "iteration": 1542304484522,
+ "iteration": 1545263815779,
"links": [
{
"icon": "external link",
@@ -66,6 +66,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -168,6 +169,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -270,6 +272,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -372,6 +375,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -474,6 +478,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -576,6 +581,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -2249,6 +2255,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -2366,6 +2373,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -2483,6 +2491,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -2600,6 +2609,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -2717,6 +2727,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -2834,6 +2845,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -2951,6 +2963,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3068,6 +3081,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3185,6 +3199,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3302,6 +3317,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3419,6 +3435,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3536,6 +3553,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3667,6 +3685,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3780,6 +3799,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -3893,6 +3913,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4006,6 +4027,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4119,6 +4141,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4232,6 +4255,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4345,6 +4369,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4458,6 +4483,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4571,6 +4597,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4684,6 +4711,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4797,6 +4825,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -4910,6 +4939,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -5008,6 +5038,512 @@
"x": 0,
"y": 4
},
+ "id": 60,
+ "panels": [
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "$version_one",
+ "fill": 1,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 5
+ },
+ "id": 63,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "paceLength": 10,
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "bucketAggs": [
+ {
+ "field": "@timestamp",
+ "id": "2",
+ "settings": {
+ "interval": "auto",
+ "min_doc_count": 0,
+ "trimEdges": 0
+ },
+ "type": "date_histogram"
+ }
+ ],
+ "metrics": [
+ {
+ "field": "select field",
+ "hide": true,
+ "id": "1",
+ "type": "count"
+ },
+ {
+ "field": "select field",
+ "id": "3",
+ "meta": {},
+ "pipelineVariables": [
+ {
+ "name": "var1",
+ "pipelineAgg": "1"
+ }
+ ],
+ "settings": {
+ "script": "params.var1 * 1000"
+ },
+ "type": "bucket_script"
+ }
+ ],
+ "refId": "A",
+ "timeField": "@timestamp"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "count * 1000 (version one) - interval auto",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "$version_two",
+ "fill": 1,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 5
+ },
+ "id": 64,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "paceLength": 10,
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "bucketAggs": [
+ {
+ "field": "@timestamp",
+ "id": "2",
+ "settings": {
+ "interval": "auto",
+ "min_doc_count": 0,
+ "trimEdges": 0
+ },
+ "type": "date_histogram"
+ }
+ ],
+ "metrics": [
+ {
+ "field": "select field",
+ "hide": true,
+ "id": "1",
+ "type": "count"
+ },
+ {
+ "field": "select field",
+ "id": "3",
+ "meta": {},
+ "pipelineVariables": [
+ {
+ "name": "var1",
+ "pipelineAgg": "1"
+ }
+ ],
+ "settings": {
+ "script": "params.var1 * 1000"
+ },
+ "type": "bucket_script"
+ }
+ ],
+ "refId": "A",
+ "timeField": "@timestamp"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "count * 1000 (version two) - interval auto",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "$version_one",
+ "fill": 1,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 13
+ },
+ "id": 65,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "paceLength": 10,
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "bucketAggs": [
+ {
+ "field": "@timestamp",
+ "id": "2",
+ "settings": {
+ "interval": "auto",
+ "min_doc_count": 0,
+ "trimEdges": 0
+ },
+ "type": "date_histogram"
+ }
+ ],
+ "metrics": [
+ {
+ "field": "select field",
+ "hide": true,
+ "id": "1",
+ "type": "count"
+ },
+ {
+ "field": "@value",
+ "hide": true,
+ "id": "3",
+ "meta": {},
+ "settings": {},
+ "type": "avg"
+ },
+ {
+ "field": "select field",
+ "id": "4",
+ "meta": {},
+ "pipelineVariables": [
+ {
+ "name": "var1",
+ "pipelineAgg": "1"
+ },
+ {
+ "name": "var2",
+ "pipelineAgg": "3"
+ }
+ ],
+ "settings": {
+ "script": "params.var1 * params.var2"
+ },
+ "type": "bucket_script"
+ }
+ ],
+ "refId": "A",
+ "timeField": "@timestamp"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "count * avg (version one) - interval auto",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ },
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "$version_two",
+ "fill": 1,
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 13
+ },
+ "id": 66,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "links": [],
+ "nullPointMode": "null",
+ "paceLength": 10,
+ "percentage": false,
+ "pointradius": 2,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "bucketAggs": [
+ {
+ "field": "@timestamp",
+ "id": "2",
+ "settings": {
+ "interval": "auto",
+ "min_doc_count": 0,
+ "trimEdges": 0
+ },
+ "type": "date_histogram"
+ }
+ ],
+ "metrics": [
+ {
+ "field": "select field",
+ "hide": true,
+ "id": "1",
+ "type": "count"
+ },
+ {
+ "field": "@value",
+ "hide": true,
+ "id": "3",
+ "meta": {},
+ "settings": {},
+ "type": "avg"
+ },
+ {
+ "field": "select field",
+ "id": "4",
+ "meta": {},
+ "pipelineVariables": [
+ {
+ "name": "var1",
+ "pipelineAgg": "1"
+ },
+ {
+ "name": "var2",
+ "pipelineAgg": "3"
+ }
+ ],
+ "settings": {
+ "script": "params.var1 * params.var2"
+ },
+ "type": "bucket_script"
+ }
+ ],
+ "refId": "A",
+ "timeField": "@timestamp"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeRegions": [],
+ "timeShift": null,
+ "title": "count * avg (version two) - interval auto",
+ "tooltip": {
+ "shared": true,
+ "sort": 0,
+ "value_type": "individual"
+ },
+ "type": "graph",
+ "xaxis": {
+ "buckets": null,
+ "mode": "time",
+ "name": null,
+ "show": true,
+ "values": []
+ },
+ "yaxes": [
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": null
+ }
+ }
+ ],
+ "title": "Basic date histogram with bucket script aggregation",
+ "type": "row"
+ },
+ {
+ "collapsed": true,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 5
+ },
"id": 54,
"panels": [
{
@@ -5042,6 +5578,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -5193,6 +5730,7 @@
"linewidth": 1,
"links": [],
"nullPointMode": "null",
+ "paceLength": 10,
"percentage": false,
"pointradius": 5,
"points": false,
@@ -5328,8 +5866,8 @@
"list": [
{
"current": {
- "text": "gdev-elasticsearch-v2-metrics",
- "value": "gdev-elasticsearch-v2-metrics"
+ "text": "gdev-elasticsearch-v5-metrics",
+ "value": "gdev-elasticsearch-v5-metrics"
},
"hide": 0,
"label": "Version One",
@@ -5343,8 +5881,8 @@
},
{
"current": {
- "text": "gdev-elasticsearch-v5-metrics",
- "value": "gdev-elasticsearch-v5-metrics"
+ "text": "gdev-elasticsearch-v6-metrics",
+ "value": "gdev-elasticsearch-v6-metrics"
},
"hide": 0,
"label": "Version Two",
@@ -5359,7 +5897,7 @@
]
},
"time": {
- "from": "now-3h",
+ "from": "now-1h",
"to": "now"
},
"timepicker": {
@@ -5390,5 +5928,5 @@
"timezone": "",
"title": "Datasource tests - Elasticsearch comparison",
"uid": "fuFWehBmk",
- "version": 10
+ "version": 4
}
\ No newline at end of file
diff --git a/devenv/dev-dashboards/panel_tests_gauge.json b/devenv/dev-dashboards/panel_tests_gauge.json
new file mode 100644
index 00000000000..c6e81ececc8
--- /dev/null
+++ b/devenv/dev-dashboards/panel_tests_gauge.json
@@ -0,0 +1,1250 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "iteration": 1547810606599,
+ "links": [],
+ "panels": [
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 11,
+ "panels": [],
+ "title": "Value options tests",
+ "type": "row"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 5,
+ "x": 0,
+ "y": 1
+ },
+ "id": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Average, 2 decimals, ms unit",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 5,
+ "y": 1
+ },
+ "id": 5,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "max",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Max (90 ms), no decimals",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 5,
+ "x": 11,
+ "y": 1
+ },
+ "id": 6,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "p",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "s",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Current (10 ms), no unit, prefix (p), suffix (s)",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 3,
+ "x": 16,
+ "y": 1
+ },
+ "id": 16,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 5,
+ "x": 19,
+ "y": 1
+ },
+ "id": 18,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,91"
+ }
+ ],
+ "timeFrom": "1h",
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 3,
+ "x": 16,
+ "y": 5
+ },
+ "id": 17,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 4,
+ "w": 5,
+ "x": 19,
+ "y": 5
+ },
+ "id": 19,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,81"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "",
+ "type": "gauge"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 9
+ },
+ "id": 15,
+ "panels": [],
+ "title": "Value Mappings",
+ "type": "row"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 4,
+ "x": 0,
+ "y": 10
+ },
+ "id": 12,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "",
+ "id": 1,
+ "operator": "",
+ "text": "TEN",
+ "to": "",
+ "type": 1,
+ "value": "10"
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping 10 -> TEN",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "description": "should read N/A",
+ "gridPos": {
+ "h": 8,
+ "w": 4,
+ "x": 4,
+ "y": 10
+ },
+ "id": 13,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "",
+ "id": 1,
+ "operator": "",
+ "text": "N/A",
+ "to": "",
+ "type": 1,
+ "value": "null"
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,null,null,null,null"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping null -> N/A",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "description": "should read N/A",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 8,
+ "y": 10
+ },
+ "id": 20,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "0",
+ "id": 1,
+ "operator": "",
+ "text": "OK",
+ "to": "10",
+ "type": 2,
+ "value": "null"
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,null,null,null,null,10"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping range, 0-10 -> OK, value 10",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "description": "should read N/A",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 14,
+ "y": 10
+ },
+ "id": 21,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "current",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "none",
+ "valueMappings": [
+ {
+ "from": "0",
+ "id": 1,
+ "operator": "",
+ "text": "OK",
+ "to": "90",
+ "type": 2,
+ "value": "null"
+ },
+ {
+ "from": "90",
+ "id": 2,
+ "operator": "",
+ "text": "BAD",
+ "to": "100",
+ "type": 2,
+ "value": ""
+ }
+ ]
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,10,null,null,null,null,10,95"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "value mapping range, 90-100 -> BAD, value 90",
+ "type": "gauge"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 18
+ },
+ "id": 9,
+ "panels": [],
+ "title": "Templating & Repeat",
+ "type": "row"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 0,
+ "y": 19
+ },
+ "id": 7,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": "Servers",
+ "repeatDirection": "h",
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server1",
+ "value": "server1"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 6,
+ "y": 19
+ },
+ "id": 22,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": null,
+ "repeatDirection": "h",
+ "repeatIteration": 1547810606599,
+ "repeatPanelId": 7,
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server2",
+ "value": "server2"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 12,
+ "y": 19
+ },
+ "id": 23,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": null,
+ "repeatDirection": "h",
+ "repeatIteration": 1547810606599,
+ "repeatPanelId": 7,
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server3",
+ "value": "server3"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ },
+ {
+ "datasource": "gdev-testdata",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 18,
+ "y": 19
+ },
+ "id": 24,
+ "links": [],
+ "nullPointMode": "null",
+ "options-gauge": {
+ "baseColor": "#299c46",
+ "decimals": "2",
+ "maxValue": 100,
+ "minValue": 0,
+ "options": {
+ "baseColor": "#299c46",
+ "decimals": 0,
+ "maxValue": 100,
+ "minValue": 0,
+ "prefix": "",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [],
+ "unit": "none",
+ "valueMappings": []
+ },
+ "prefix": "$Servers",
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "stat": "avg",
+ "suffix": "",
+ "thresholds": [
+ {
+ "color": "#e24d42",
+ "index": 2,
+ "value": 90
+ },
+ {
+ "color": "#ef843c",
+ "index": 1,
+ "value": 75
+ },
+ {
+ "color": "#7EB26D",
+ "index": 0,
+ "value": null
+ }
+ ],
+ "unit": "ms",
+ "valueMappings": []
+ },
+ "repeat": null,
+ "repeatDirection": "h",
+ "repeatIteration": 1547810606599,
+ "repeatPanelId": 7,
+ "scopedVars": {
+ "Servers": {
+ "selected": false,
+ "text": "server4",
+ "value": "server4"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "scenarioId": "csv_metric_values",
+ "stringInput": "1,20,90,30,5,0"
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "repeat $Servers",
+ "type": "gauge"
+ }
+ ],
+ "refresh": false,
+ "schemaVersion": 17,
+ "style": "dark",
+ "tags": [
+ "gdev",
+ "panel-tests"
+ ],
+ "templating": {
+ "list": [
+ {
+ "allValue": null,
+ "current": {
+ "selected": true,
+ "tags": [],
+ "text": "All",
+ "value": [
+ "$__all"
+ ]
+ },
+ "hide": 0,
+ "includeAll": true,
+ "label": null,
+ "multi": true,
+ "name": "Servers",
+ "options": [
+ {
+ "selected": true,
+ "text": "All",
+ "value": "$__all"
+ },
+ {
+ "selected": false,
+ "text": "server1",
+ "value": "server1"
+ },
+ {
+ "selected": false,
+ "text": "server2",
+ "value": "server2"
+ },
+ {
+ "selected": false,
+ "text": "server3",
+ "value": "server3"
+ },
+ {
+ "selected": false,
+ "text": "server4",
+ "value": "server4"
+ }
+ ],
+ "query": "server1,server2,server3,server4",
+ "skipUrlSync": false,
+ "type": "custom"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": [
+ "5s",
+ "10s",
+ "30s",
+ "1m",
+ "5m",
+ "15m",
+ "30m",
+ "1h",
+ "2h",
+ "1d"
+ ],
+ "time_options": [
+ "5m",
+ "15m",
+ "1h",
+ "6h",
+ "12h",
+ "24h",
+ "2d",
+ "7d",
+ "30d"
+ ]
+ },
+ "timezone": "",
+ "title": "Panel Tests - Gauge",
+ "uid": "_5rDmaQiz",
+ "version": 5
+}
diff --git a/devenv/docker/blocks/influxdb/influxdb.conf b/devenv/docker/blocks/influxdb/influxdb.conf
index c0331ce7449..120739dd896 100644
--- a/devenv/docker/blocks/influxdb/influxdb.conf
+++ b/devenv/docker/blocks/influxdb/influxdb.conf
@@ -69,6 +69,7 @@ reporting-disabled = false
unix-socket-enabled = false # enable http service over unix domain socket
# bind-socket = "/var/run/influxdb.sock"
+ flux-enabled = true
[subscriber]
enabled = true
diff --git a/devenv/docker/blocks/loki/docker-compose.yaml b/devenv/docker/blocks/loki/docker-compose.yaml
new file mode 100644
index 00000000000..d6cf21f7856
--- /dev/null
+++ b/devenv/docker/blocks/loki/docker-compose.yaml
@@ -0,0 +1,22 @@
+version: "3"
+
+networks:
+ loki:
+
+services:
+ loki:
+ image: grafana/loki:master
+ ports:
+ - "3100:3100"
+ command: -config.file=/etc/loki/local-config.yaml
+ networks:
+ - loki
+
+ promtail:
+ image: grafana/promtail:master
+ volumes:
+ - /var/log:/var/log
+ command:
+ -config.file=/etc/promtail/docker-config.yaml
+ networks:
+ - loki
diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml
index 1195e2a977c..504ee86404d 100644
--- a/devenv/docker/ha_test/docker-compose.yaml
+++ b/devenv/docker/ha_test/docker-compose.yaml
@@ -54,7 +54,8 @@ services:
# - GF_DATABASE_SSL_MODE=disable
# - GF_SESSION_PROVIDER=postgres
# - GF_SESSION_PROVIDER_CONFIG=user=grafana password=password host=db port=5432 dbname=grafana sslmode=disable
- - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug
+ - GF_LOG_FILTERS=alerting.notifier:debug,alerting.notifier.slack:debug,auth:debug
+ - GF_LOGIN_ROTATE_TOKEN_MINUTES=2
ports:
- 3000
depends_on:
diff --git a/devenv/docker/loadtest/README.md b/devenv/docker/loadtest/README.md
new file mode 100644
index 00000000000..8e724637acb
--- /dev/null
+++ b/devenv/docker/loadtest/README.md
@@ -0,0 +1,69 @@
+# Grafana load test
+
+Runs load tests and checks using [k6](https://k6.io/).
+
+## Prerequisites
+
+Docker
+
+## Run
+
+Run load test for 15 minutes:
+
+```bash
+$ ./run.sh
+```
+
+Run load test for custom duration:
+
+```bash
+$ ./run.sh -d 10s
+```
+
+Example output:
+
+```bash
+
+ /\ |‾‾| /‾‾/ /‾/
+ /\ / \ | |_/ / / /
+ / \/ \ | | / ‾‾\
+ / \ | |‾\ \ | (_) |
+ / __________ \ |__| \__\ \___/ .io
+
+ execution: local
+ output: -
+ script: src/auth_token_test.js
+
+ duration: 15m0s, iterations: -
+ vus: 2, max: 2
+
+ done [==========================================================] 15m0s / 15m0s
+
+ █ user auth token test
+
+ █ user authenticates thru ui with username and password
+
+ ✓ response status is 200
+ ✓ response has cookie 'grafana_session' with 32 characters
+
+ █ batch tsdb requests
+
+ ✓ response status is 200
+
+ checks.....................: 100.00% ✓ 32844 ✗ 0
+ data_received..............: 411 MB 457 kB/s
+ data_sent..................: 12 MB 14 kB/s
+ group_duration.............: avg=95.64ms min=16.42ms med=94.35ms max=307.52ms p(90)=137.78ms p(95)=146.75ms
+ http_req_blocked...........: avg=1.27ms min=942ns med=610.08µs max=48.32ms p(90)=2.92ms p(95)=4.25ms
+ http_req_connecting........: avg=1.06ms min=0s med=456.79µs max=47.19ms p(90)=2.55ms p(95)=3.78ms
+ http_req_duration..........: avg=58.16ms min=1ms med=52.59ms max=293.35ms p(90)=109.53ms p(95)=120.19ms
+ http_req_receiving.........: avg=38.98µs min=6.43µs med=32.55µs max=16.2ms p(90)=64.63µs p(95)=78.8µs
+ http_req_sending...........: avg=328.66µs min=8.09µs med=110.77µs max=44.13ms p(90)=552.65µs p(95)=1.09ms
+ http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
+ http_req_waiting...........: avg=57.79ms min=935.02µs med=52.15ms max=293.06ms p(90)=109.04ms p(95)=119.71ms
+ http_reqs..................: 34486 38.317775/s
+ iteration_duration.........: avg=1.09s min=1.81µs med=1.09s max=1.3s p(90)=1.13s p(95)=1.14s
+ iterations.................: 1642 1.824444/s
+ vus........................: 2 min=2 max=2
+ vus_max....................: 2 min=2 max=2
+```
diff --git a/devenv/docker/loadtest/auth_token_test.js b/devenv/docker/loadtest/auth_token_test.js
new file mode 100644
index 00000000000..e1356fb6f9a
--- /dev/null
+++ b/devenv/docker/loadtest/auth_token_test.js
@@ -0,0 +1,71 @@
+import { sleep, check, group } from 'k6';
+import { createClient, createBasicAuthClient } from './modules/client.js';
+import { createTestOrgIfNotExists, createTestdataDatasourceIfNotExists } from './modules/util.js';
+
+export let options = {
+ noCookiesReset: true
+};
+
+let endpoint = __ENV.URL || 'http://localhost:3000';
+const client = createClient(endpoint);
+
+export const setup = () => {
+ const basicAuthClient = createBasicAuthClient(endpoint, 'admin', 'admin');
+ const orgId = createTestOrgIfNotExists(basicAuthClient);
+ const datasourceId = createTestdataDatasourceIfNotExists(basicAuthClient);
+ client.withOrgId(orgId);
+ return {
+ orgId: orgId,
+ datasourceId: datasourceId,
+ };
+}
+
+export default (data) => {
+ group("user auth token test", () => {
+ if (__ITER === 0) {
+ group("user authenticates thru ui with username and password", () => {
+ let res = client.ui.login('admin', 'admin');
+
+ check(res, {
+ 'response status is 200': (r) => r.status === 200,
+ 'response has cookie \'grafana_session\' with 32 characters': (r) => r.cookies.grafana_session[0].value.length === 32,
+ });
+ });
+ }
+
+ if (__ITER !== 0) {
+ group("batch tsdb requests", () => {
+ const batchCount = 20;
+ const requests = [];
+ const payload = {
+ from: '1547765247624',
+ to: '1547768847624',
+ queries: [{
+ refId: 'A',
+ scenarioId: 'random_walk',
+ intervalMs: 10000,
+ maxDataPoints: 433,
+ datasourceId: data.datasourceId,
+ }]
+ };
+
+ requests.push({ method: 'GET', url: '/api/annotations?dashboardId=2074&from=1548078832772&to=1548082432772' });
+
+ for (let n = 0; n < batchCount; n++) {
+ requests.push({ method: 'POST', url: '/api/tsdb/query', body: payload });
+ }
+
+ let responses = client.batch(requests);
+ for (let n = 0; n < batchCount; n++) {
+ check(responses[n], {
+ 'response status is 200': (r) => r.status === 200,
+ });
+ }
+ });
+ }
+ });
+
+ sleep(1)
+}
+
+export const teardown = (data) => {}
diff --git a/devenv/docker/loadtest/modules/client.js b/devenv/docker/loadtest/modules/client.js
new file mode 100644
index 00000000000..bda0da64564
--- /dev/null
+++ b/devenv/docker/loadtest/modules/client.js
@@ -0,0 +1,187 @@
+import http from "k6/http";
+import encoding from 'k6/encoding';
+
+export const UIEndpoint = class UIEndpoint {
+ constructor(httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ login(username, pwd) {
+ const payload = { user: username, password: pwd };
+ return this.httpClient.formPost('/login', payload);
+ }
+}
+
+export const DatasourcesEndpoint = class DatasourcesEndpoint {
+ constructor(httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ getById(id) {
+ return this.httpClient.get(`/datasources/${id}`);
+ }
+
+ getByName(name) {
+ return this.httpClient.get(`/datasources/name/${name}`);
+ }
+
+ create(payload) {
+ return this.httpClient.post(`/datasources`, JSON.stringify(payload));
+ }
+
+ delete(id) {
+ return this.httpClient.delete(`/datasources/${id}`);
+ }
+}
+
+export const OrganizationsEndpoint = class OrganizationsEndpoint {
+ constructor(httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ getById(id) {
+ return this.httpClient.get(`/orgs/${id}`);
+ }
+
+ getByName(name) {
+ return this.httpClient.get(`/orgs/name/${name}`);
+ }
+
+ create(name) {
+ let payload = {
+ name: name,
+ };
+ return this.httpClient.post(`/orgs`, JSON.stringify(payload));
+ }
+
+ delete(id) {
+ return this.httpClient.delete(`/orgs/${id}`);
+ }
+}
+
+export const GrafanaClient = class GrafanaClient {
+ constructor(httpClient) {
+ httpClient.onBeforeRequest = this.onBeforeRequest;
+ this.raw = httpClient;
+ this.ui = new UIEndpoint(httpClient);
+ this.orgs = new OrganizationsEndpoint(httpClient.withUrl('/api'));
+ this.datasources = new DatasourcesEndpoint(httpClient.withUrl('/api'));
+ }
+
+ batch(requests) {
+ return this.raw.batch(requests);
+ }
+
+ withOrgId(orgId) {
+ this.orgId = orgId;
+ }
+
+ onBeforeRequest(params) {
+ if (this.orgId && this.orgId > 0) {
+ params = params.headers || {};
+ params.headers["X-Grafana-Org-Id"] = this.orgId;
+ }
+ }
+}
+
+export const BaseClient = class BaseClient {
+ constructor(url, subUrl) {
+ if (url.endsWith('/')) {
+ url = url.substring(0, url.length - 1);
+ }
+
+ if (subUrl.endsWith('/')) {
+ subUrl = subUrl.substring(0, subUrl.length - 1);
+ }
+
+ this.url = url + subUrl;
+ this.onBeforeRequest = () => {};
+ }
+
+ withUrl(subUrl) {
+ let c = new BaseClient(this.url, subUrl);
+ c.onBeforeRequest = this.onBeforeRequest;
+ return c;
+ }
+
+ beforeRequest(params) {
+
+ }
+
+ get(url, params) {
+ params = params || {};
+ this.beforeRequest(params);
+ this.onBeforeRequest(params);
+ return http.get(this.url + url, params);
+ }
+
+ formPost(url, body, params) {
+ params = params || {};
+ this.beforeRequest(params);
+ this.onBeforeRequest(params);
+ return http.post(this.url + url, body, params);
+ }
+
+ post(url, body, params) {
+ params = params || {};
+ params.headers = params.headers || {};
+ params.headers['Content-Type'] = 'application/json';
+
+ this.beforeRequest(params);
+ this.onBeforeRequest(params);
+ return http.post(this.url + url, body, params);
+ }
+
+ delete(url, params) {
+ params = params || {};
+ this.beforeRequest(params);
+ this.onBeforeRequest(params);
+ return http.del(this.url + url, null, params);
+ }
+
+ batch(requests) {
+ for (let n = 0; n < requests.length; n++) {
+ let params = requests[n].params || {};
+ params.headers = params.headers || {};
+ params.headers['Content-Type'] = 'application/json';
+ this.beforeRequest(params);
+ this.onBeforeRequest(params);
+ requests[n].params = params;
+ requests[n].url = this.url + requests[n].url;
+ if (requests[n].body) {
+ requests[n].body = JSON.stringify(requests[n].body);
+ }
+ }
+
+ return http.batch(requests);
+ }
+}
+
+export class BasicAuthClient extends BaseClient {
+ constructor(url, subUrl, username, password) {
+ super(url, subUrl);
+ this.username = username;
+ this.password = password;
+ }
+
+ withUrl(subUrl) {
+ let c = new BasicAuthClient(this.url, subUrl, this.username, this.password);
+ c.onBeforeRequest = this.onBeforeRequest;
+ return c;
+ }
+
+ beforeRequest(params) {
+ params = params || {};
+ params.headers = params.headers || {};
+ let token = `${this.username}:${this.password}`;
+ params.headers['Authorization'] = `Basic ${encoding.b64encode(token)}`;
+ }
+}
+
+export const createClient = (url) => {
+ return new GrafanaClient(new BaseClient(url, ''));
+}
+
+export const createBasicAuthClient = (url, username, password) => {
+ return new GrafanaClient(new BasicAuthClient(url, '', username, password));
+}
diff --git a/devenv/docker/loadtest/modules/util.js b/devenv/docker/loadtest/modules/util.js
new file mode 100644
index 00000000000..af6d4cdac09
--- /dev/null
+++ b/devenv/docker/loadtest/modules/util.js
@@ -0,0 +1,35 @@
+export const createTestOrgIfNotExists = (client) => {
+ let orgId = 0;
+ let res = client.orgs.getByName('k6');
+ if (res.status === 404) {
+ res = client.orgs.create('k6');
+ if (res.status !== 200) {
+ throw new Error('Expected 200 response status when creating org');
+ }
+ orgId = res.json().orgId;
+ } else {
+ orgId = res.json().id;
+ }
+
+ client.withOrgId(orgId);
+ return orgId;
+}
+
+export const createTestdataDatasourceIfNotExists = (client) => {
+ const payload = {
+ access: 'proxy',
+ isDefault: false,
+ name: 'k6-testdata',
+ type: 'testdata',
+ };
+
+ let res = client.datasources.getByName(payload.name);
+ if (res.status === 404) {
+ res = client.datasources.create(payload);
+ if (res.status !== 200) {
+ throw new Error('Expected 200 response status when creating datasource');
+ }
+ }
+
+ return res.json().id;
+}
diff --git a/devenv/docker/loadtest/run.sh b/devenv/docker/loadtest/run.sh
new file mode 100755
index 00000000000..474d75383b6
--- /dev/null
+++ b/devenv/docker/loadtest/run.sh
@@ -0,0 +1,24 @@
+#/bin/bash
+
+PWD=$(pwd)
+
+run() {
+ duration='15m'
+ url='http://localhost:3000'
+
+ while getopts ":d:u:" o; do
+ case "${o}" in
+ d)
+ duration=${OPTARG}
+ ;;
+ u)
+ url=${OPTARG}
+ ;;
+ esac
+ done
+ shift $((OPTIND-1))
+
+ docker run -t --network=host -v $PWD:/src -e URL=$url --rm -i loadimpact/k6:master run --vus 2 --duration $duration src/auth_token_test.js
+}
+
+run "$@"
diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md
index b2a1b1f42e7..cce25e4cf2b 100644
--- a/docs/sources/administration/provisioning.md
+++ b/docs/sources/administration/provisioning.md
@@ -231,3 +231,187 @@ By default Grafana will delete dashboards in the database if the file is removed
> which leads to problems if you re-use settings that are supposed to be unique.
> Be careful not to re-use the same `title` multiple times within a folder
> or `uid` within the same installation as this will cause weird behaviors.
+
+## Alert Notification Channels
+
+Alert Notification Channels can be provisioned by adding one or more yaml config files in the [`provisioning/notifiers`](/installation/configuration/#provisioning) directory.
+
+Each config file can contain the following top-level fields:
+- `notifiers`, a list of alert notifications that will be added or updated during start up. If the notification channel already exists, Grafana will update it to match the configuration file.
+- `delete_notifiers`, a list of alert notifications to be deleted before before inserting/updating those in the `notifiers` list.
+
+Provisioning looks up alert notifications by uid, and will update any existing notification with the provided uid.
+
+By default, exporting a dashboard as JSON will use a sequential identifier to refer to alert notifications. The field `uid` can be optionally specified to specify a string identifier for the alert name.
+
+```json
+{
+ ...
+ "alert": {
+ ...,
+ "conditions": [...],
+ "frequency": "24h",
+ "noDataState": "ok",
+ "notifications": [
+ {"uid": "notifier1"},
+ {"uid": "notifier2"},
+ ]
+ }
+ ...
+}
+```
+
+### Example Alert Notification Channels Config File
+
+```yaml
+notifiers:
+ - name: notification-channel-1
+ type: slack
+ uid: notifier1
+ # either
+ org_id: 2
+ # or
+ org_name: Main Org.
+ is_default: true
+ # See `Supported Settings` section for settings supporter for each
+ # alert notification type.
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
+ url: https://slack.com
+
+delete_notifiers:
+ - name: notification-channel-1
+ uid: notifier1
+ # either
+ org_id: 2
+ # or
+ org_name: Main Org.
+ - name: notification-channel-2
+ # default org_id: 1
+```
+
+### Supported Settings
+
+The following sections detail the supported settings for each alert notification type.
+
+#### Alert notification `pushover`
+
+| Name |
+| ---- |
+| apiToken |
+| userKey |
+| device |
+| retry |
+| expire |
+
+#### Alert notification `slack`
+
+| Name |
+| ---- |
+| url |
+| recipient |
+| username |
+| iconEmoji |
+| iconUrl |
+| uploadImage |
+| mention |
+| token |
+
+#### Alert notification `victorops`
+
+| Name |
+| ---- |
+| url |
+
+#### Alert notification `kafka`
+
+| Name |
+| ---- |
+| kafkaRestProxy |
+| kafkaTopic |
+
+#### Alert notification `LINE`
+
+| Name |
+| ---- |
+| token |
+
+#### Alert notification `pagerduty`
+
+| Name |
+| ---- |
+| integrationKey |
+
+#### Alert notification `sensu`
+
+| Name |
+| ---- |
+| url |
+| source |
+| handler |
+| username |
+| password |
+
+#### Alert notification `prometheus-alertmanager`
+
+| Name |
+| ---- |
+| url |
+
+#### Alert notification `teams`
+
+| Name |
+| ---- |
+| url |
+
+#### Alert notification `dingding`
+
+| Name |
+| ---- |
+| url |
+
+#### Alert notification `email`
+
+| Name |
+| ---- |
+| addresses |
+
+#### Alert notification `hipchat`
+
+| Name |
+| ---- |
+| url |
+| apikey |
+| roomid |
+
+#### Alert notification `opsgenie`
+
+| Name |
+| ---- |
+| apiKey |
+| apiUrl |
+
+#### Alert notification `telegram`
+
+| Name |
+| ---- |
+| bottoken |
+| chatid |
+
+#### Alert notification `threema`
+
+| Name |
+| ---- |
+| gateway_id |
+| recipient_id |
+| api_secret |
+
+#### Alert notification `webhook`
+
+| Name |
+| ---- |
+| url |
+| username |
+| password |
\ No newline at end of file
diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md
index 541aed3fd1f..b6028b0a2a7 100644
--- a/docs/sources/auth/gitlab.md
+++ b/docs/sources/auth/gitlab.md
@@ -47,7 +47,7 @@ authentication:
```bash
[auth.gitlab]
-enabled = false
+enabled = true
allow_sign_up = false
client_id = GITLAB_APPLICATION_ID
client_secret = GITLAB_SECRET
diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md
index e2bcb50bb1d..22f9f38c854 100644
--- a/docs/sources/features/datasources/cloudwatch.md
+++ b/docs/sources/features/datasources/cloudwatch.md
@@ -38,7 +38,7 @@ Name | Description
### IAM Roles
-Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If you grafana
+Currently all access to CloudWatch is done server side by the Grafana backend using the official AWS SDK. If your Grafana
server is running on AWS you can use IAM Roles and authentication will be handled automatically.
Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)
diff --git a/docs/sources/features/datasources/mssql.md b/docs/sources/features/datasources/mssql.md
index cd191f14273..5bab14c05fa 100644
--- a/docs/sources/features/datasources/mssql.md
+++ b/docs/sources/features/datasources/mssql.md
@@ -110,6 +110,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
+*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamp. For example, *dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872*
+*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
+*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as nanosecond timestamp. For example, *1494497183142514872*
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+).
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+).
diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md
index 371c92cde70..bafe79bd09a 100644
--- a/docs/sources/features/datasources/mysql.md
+++ b/docs/sources/features/datasources/mysql.md
@@ -144,6 +144,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183*
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
+*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamp. For example, *dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872*
+*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
+*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as nanosecond timestamp. For example, *1494497183142514872*
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+).
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+).
diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md
index 52f8804f27f..df6bf555077 100644
--- a/docs/sources/features/datasources/postgres.md
+++ b/docs/sources/features/datasources/postgres.md
@@ -154,6 +154,9 @@ Macro example | Description
*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamps. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183*
*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783*
*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183*
+*$__unixEpochNanoFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamps. For example, *dateColumn >= 1494410783152415214 AND dateColumn <= 1494497183142514872*
+*$__unixEpochNanoFrom()* | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, *1494410783152415214*
+*$__unixEpochNanoTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183142514872*
*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup, but for times stored as unix timestamp (only available in Grafana 5.3+).
*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above, but also adds a column alias (only available in Grafana 5.3+).
diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md
index 2c14d897d8e..d1cc1088276 100644
--- a/docs/sources/features/datasources/stackdriver.md
+++ b/docs/sources/features/datasources/stackdriver.md
@@ -12,8 +12,8 @@ weight = 11
# Using Google Stackdriver in Grafana
-> Only available in Grafana v5.3+.
-> The datasource is currently a beta feature and is subject to change.
+> Available as a beta feature in Grafana v5.3.x and v5.4.x.
+> Officially released in Grafana v6.0.0
Grafana ships with built-in support for Google Stackdriver. Just add it as a datasource and you are ready to build dashboards for your Stackdriver metrics.
diff --git a/docs/sources/features/explore/index.md b/docs/sources/features/explore/index.md
index 438943b3243..25af18c2a3d 100644
--- a/docs/sources/features/explore/index.md
+++ b/docs/sources/features/explore/index.md
@@ -1,5 +1,6 @@
+++
title = "Explore"
+keywords = ["explore", "loki", "logs"]
type = "docs"
[menu.docs]
name = "Explore"
@@ -8,7 +9,11 @@ parent = "features"
weight = 5
+++
-# Introduction
+# Explore
+
+> Explore is only available in Grafana 6.0 and above.
+
+## Introduction
One of the major new features of Grafana 6.0 is the new query-focused Explore workflow for troubleshooting and/or for data exploration.
@@ -22,23 +27,6 @@ For infrastructure monitoring and incident response, you no longer need to switc
If you just want to explore your data and do not want to create a dashboard then Explore makes this much easier. Explore will show the results as both a graph and a table enabling you to see trends in the data and more detail at the same time (if the datasource supports both graph and table data).
-## Turning the Explore Feature On
-
-Explore will be officially released in Grafana 6.0. It is however already in the latest nightly builds of Grafana and can be turned using a feature flag in the config file. Restart Grafana after making the config file change.
-
-```ini
-[explore]
-# Enable the Explore section
-enabled = true
-```
-
-Or if using docker:
-
-```bash
-docker pull grafana/grafana:master
-docker run --name grafana -p 3000:3000 -e "GF_EXPLORE_ENABLED=true" grafana/grafana:master
-```
-
## How to Start Exploring
There is a new Explore icon on the menu bar to the left. This opens a new empty Explore tab.
@@ -111,7 +99,14 @@ The Logs Explorer (the `Log labels` button) next to the query field shows a list
Once the result is returned, the log panel shows a list of log rows and a bar chart where the x-axis shows the time and the y-axis shows the frequency/count.
-{{< docs-imagebox img="/img/docs/v60/explore_loki.png" class="docs-image--no-shadow" caption="Explore Loki Log Streams" >}}
+
+
+
+ Your browser does not support the video tag.
+
+
+
+
#### Log Stream Selector
diff --git a/docs/sources/guides/whats-new-in-v5-3.md b/docs/sources/guides/whats-new-in-v5-3.md
index 10592f51648..abd83e7e002 100644
--- a/docs/sources/guides/whats-new-in-v5-3.md
+++ b/docs/sources/guides/whats-new-in-v5-3.md
@@ -26,7 +26,7 @@ Grafana v5.3 brings new features, many enhancements and bug fixes. This article
{{< docs-imagebox img="/img/docs/v53/stackdriver-with-heatmap.png" max-width= "600px" class="docs-image--no-shadow docs-image--right" >}}
-Grafana v5.3 ships with built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) and enables you to visualize your Stackdriver metrics in Grafana.
+Grafana v5.3 ships with built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) and enables you to visualize your Stackdriver metrics in Grafana.
Getting started with the plugin is easy. Simply create a GCE Service account that has access to the Stackdriver API scope, download the Service Account key file from Google and upload it on the Stackdriver datasource config page in Grafana and you should have a secure server-to-server authentication setup. Like other core plugins, Stackdriver has built-in support for alerting. It also comes with support for heatmaps and basic variables.
diff --git a/docs/sources/guides/whats-new-in-v6-0.md b/docs/sources/guides/whats-new-in-v6-0.md
new file mode 100644
index 00000000000..61eec0ac390
--- /dev/null
+++ b/docs/sources/guides/whats-new-in-v6-0.md
@@ -0,0 +1,159 @@
++++
+title = "What's New in Grafana v6.0"
+description = "Feature & improvement highlights for Grafana v6.0"
+keywords = ["grafana", "new", "documentation", "6.0"]
+type = "docs"
+[menu.docs]
+name = "Version 6.0"
+identifier = "v6.0"
+parent = "whatsnew"
+weight = -11
++++
+
+# What's New in Grafana v6.0
+
+This update to Grafana introduces a new way of exploring your data, support for log data and tons of other features.
+
+Grafana v6.0 is out in **Beta**, [Download Now!](https://grafana.com/grafana/download/beta)
+
+The main highlights are:
+
+- [Explore]({{< relref "#explore" >}}) - A new query focused workflow for ad-hoc data exploration and troubleshooting.
+- [Grafana Loki]({{< relref "#explore-and-grafana-loki" >}}) - Integration with the new open source log aggregation system from Grafana Labs.
+- [Gauge Panel]({{< relref "#gauge-panel" >}}) - A new standalone panel for gauges.
+- [New Panel Editor UX]({{< relref "#new-panel-editor" >}}) improves panel editing
+ and enables easy switching between different visualizations.
+- [Google Stackdriver Datasource]({{< relref "#google-stackdriver-datasource" >}}) is out of beta and is officially released.
+- [Azure Monitor]({{< relref "#azure-monitor-datasource" >}}) plugin is ported from being an external plugin to being a core datasource
+- [React Plugin]({{< relref "#react-panels-query-editors" >}}) support enables an easier way to build plugins.
+- [Named Colors]({{< relref "#named-colors" >}}) in our new improved color picker.
+
+## Explore
+
+{{< docs-imagebox img="/img/docs/v60/explore_prometheus.png" max-width="800px" class="docs-image--right" caption="Screenshot of the new Explore option in the panel menu" >}}
+
+Grafana's dashboard UI is all about building dashboards for visualization. **Explore** strips away all the dashboard and panel options so that you can focus on the query & metric exploration. Iterate until you have a working query and then think about building a dashboard. You can also jump from a dashboard panel into **Explore** and from there do some ad-hoc query exporation with the panel queries as a starting point.
+
+For infrastructure monitoring and incident response, you no longer need to switch to other tools to debug what went wrong. **Explore** allows you to dig deeper into your metrics and logs to find the cause. Grafana's new logging datasource, [Loki](https://github.com/grafana/loki) is tightly integrated into Explore and allows you to correlate metrics and logs by viewing them side-by-side.
+
+**Explore** is a new paradigm for Grafana. It creates a new interactive debugging workflow that integrates two pillars
+of observability - metrics and logs. Explore works with every datasource but for Prometheus we have customized the
+query editor and the experience to provide the best possible exploration UX.
+
+### Explore and Prometheus
+
+Explore features a new [Prometheus query editor](/features/explore/#prometheus-specific-features). This new editor has improved autocomplete, metric tree selector,
+integrations with the Explore table view for easy label filtering and useful query hints that can automatically apply
+functions to your query. There is also integration between Prometheus and Grafana Loki (see more about Loki below) that
+enabled jumping between metrics query and logs query with preserved label filters.
+
+### Explore splits
+
+Explore supports splitting the view so you can compare different queries, different datasources and metrics & logs side by side!
+
+{{< docs-imagebox img="/img/docs/v60/explore_split.png" max-width="800px" caption="Screenshot of the new Explore option in the panel menu" >}}
+
+
+
+### Explore and Grafana Loki
+
+The log exploration & visualization features in Explore are available to any data source but are currently only implemented by the new open source log
+aggregation system from Grafana Lab called [Grafana Loki](https://github.com/grafana/loki).
+
+Loki is a horizontally-scalable, highly-available, multi-tenant log aggregation system inspired by Prometheus. It is designed to be very cost effective, as it does not index the contents of the logs, but rather a set of labels for each log stream. The logs from Loki are queried in a similar way to querying with label selectors in Prometheus. It uses labels to group log streams which can be made to match up with your Prometheus labels.
+
+Read more about Grafana Loki [here](https://github.com/grafana/loki) or [Grafana Labs hosted Loki](https://grafana.com/loki).
+
+The Explore feature allows you to query logs and features a new log panel. In the near future, we will be adding support
+for other log sources to Explore and the next planned integration is Elasticsearch.
+
+
+
+
+ Your browser does not support the video tag.
+
+
+
+
+
+## New Panel Editor
+
+Grafana v6.0 has a completely redesigned UX around editing panels. You can now resize the visualization area if you want
+more space for queries & options and vice versa. You can now also change visualization (panel type) from within the new
+panel edit mode. No need to add a new panel to try out different visualizations! Checkout the
+video below to see the new Panel Editor in action.
+
+
+
+
+ Your browser does not support the video tag.
+
+
+
+
+
+### Gauge Panel
+
+We have created a new separate Gauge panel as we felt having this visualization be a hidden option in the Singlestat panel
+was not ideal. When it supports 100% of the Singlestat Gauge features we plan to add a migration so all
+singlestats that use it become Gauge panels instead. This new panel contains a new **Threshold** editor that we will
+continue to refine and start using in other panels.
+
+{{< docs-imagebox img="/img/docs/v60/gauge_panel.png" max-width="600px" caption="Gauge Panel" >}}
+
+
+
+### React Panels & Query Editors
+
+A major part of all the work that has gone into Grafana v6.0 has been on the migration to React. This investment
+is part of the future proofing of Grafana and it's code base and ecosystem. Starting in v6.0 **Panels** and **Data
+source** plugins can be written in React using our published `@grafana/ui` sdk library. More information on this
+will be shared closer to or just after release.
+
+{{< docs-imagebox img="/img/docs/v60/react_panels.png" max-width="600px" caption="React Panel" >}}
+
+
+### Google Stackdriver Datasource
+
+Built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) is officially released in Grafana 6.0. Beta support was added in Grafana 5.3 and we have added lots of improvements since then.
+
+To get started read the guide: [Using Google Stackdriver in Grafana](/features/datasources/stackdriver/).
+
+### Azure Monitor Datasource
+
+One of the goals of the Grafana v6.0 release is to add support for the three major clouds. Amazon Cloudwatch has been a core datasource for years and Google Stackdriver is also now supported. We developed an external plugin for Azure Monitor last year and for this release the [plugin](https://grafana.com/plugins/grafana-azure-monitor-datasource) is being moved into Grafana to be one of the built-in datasources. For users of the external plugin, Grafana will automatically start using the built-in version. As a core datasource, the Azure Monitor datasource will get alerting support for the official 6.0 release.
+
+The Azure Monitor datasource integrates four Azure services with Grafana - Azure Monitor, Azure Log Analytics, Azure Application Insights and Azure Application Insights Analytics.
+
+### Provisioning support for alert notifiers
+
+Grafana now added support for provisioning alert notifiers from configuration files. Allowing operators to provision notifiers without using the UI or the API. A new field called `uid` has been introduced which is a string identifier that the administrator can set themselves. Same kind of identifier used for dashboards since v5.0. This feature makes it possible to use the same notifier configuration in multiple environments and refer to notifiers in dashboard json by a string identifier instead of the numeric id which depends on insert order and how many notifiers that exists in the instance.
+
+### Auth and session token improvements
+
+The previous session storage implementation in Grafana was causing problems in larger HA setups due to too many write requests to the database. The remember me token also have several security issues which is why we decided to rewrite auth middleware in Grafana and remove the session storage since most operations using the session storage could be rewritten to use cookies or data already made available earlier in the request.
+If you are using `Auth proxy` for authentication the session storage will still be used but our goal is to remove this ASAP as well.
+
+This release will force all users to log in again since their previous token is not valid anymore.
+
+### Named Colors
+
+{{< docs-imagebox img="/img/docs/v60/named_colors.png" max-width="400px" class="docs-image--right" caption="Named Colors" >}}
+
+We have updated the color picker to show named colors and primary colors. We hope this will improve accessibility and
+helps making colors more consistent across dashboards. We hope to do more in this color picker in the future, like show
+colors used in the dashboard.
+
+Named colors also enables Grafana to adapt colors to the current theme.
+
+
+
+### Other features
+
+- The ElasticSearch datasource now supports [bucket script pipeline aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-script-aggregation.html). This gives the ability to do per bucket computations like the difference or ratio between two metrics.
+- Support for Google Hangouts Chat alert notifications
+- New built in template variables for the current time range in `$__from` and `$__to`
+
+## Changelog
+
+Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes.
diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md
index 2d4be21bb78..a27fd2aac14 100644
--- a/docs/sources/http_api/admin.md
+++ b/docs/sources/http_api/admin.md
@@ -285,7 +285,7 @@ Content-Type: application/json
HTTP/1.1 200
Content-Type: application/json
-{message: "User permissions updated"}
+{"message": "User permissions updated"}
```
## Delete global User
@@ -308,7 +308,7 @@ Content-Type: application/json
HTTP/1.1 200
Content-Type: application/json
-{message: "User deleted"}
+{"message": "User deleted"}
```
## Pause all alerts
@@ -339,5 +339,5 @@ JSON Body schema:
HTTP/1.1 200
Content-Type: application/json
-{state: "new state", message: "alerts pause/un paused", "alertsAffected": 100}
+{"state": "new state", "message": "alerts pause/un paused", "alertsAffected": 100}
```
diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md
index 9aaf29ec5f4..364b55b0cfc 100644
--- a/docs/sources/http_api/data_source.md
+++ b/docs/sources/http_api/data_source.md
@@ -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"
}
}
```
diff --git a/docs/sources/http_api/folder_permissions.md b/docs/sources/http_api/folder_permissions.md
index 284ab70866f..39cbbc3720a 100644
--- a/docs/sources/http_api/folder_permissions.md
+++ b/docs/sources/http_api/folder_permissions.md
@@ -105,7 +105,7 @@ POST /api/folders/nErXDvCkzz/permissions
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
-
+{
"items": [
{
"role": "Viewer",
diff --git a/docs/sources/http_api/other.md b/docs/sources/http_api/other.md
index 5bf0cde05fe..ea905bf88f0 100644
--- a/docs/sources/http_api/other.md
+++ b/docs/sources/http_api/other.md
@@ -82,4 +82,29 @@ HTTP/1.1 200
Content-Type: application/json
{"message": "Logged in"}
-```
\ No newline at end of file
+```
+
+# Health API
+
+## Returns health information about Grafana
+
+`GET /api/health`
+
+**Example Request**
+
+```http
+GET /api/health
+Accept: application/json
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200 OK
+
+{
+ "commit": "087143285",
+ "database": "ok",
+ "version": "5.1.3"
+}
+```
diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md
index 0e5a55b3c0e..46bab83654e 100644
--- a/docs/sources/installation/configuration.md
+++ b/docs/sources/installation/configuration.md
@@ -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.
+
## [dashboards]
@@ -589,3 +595,14 @@ Default setting for how Grafana handles nodata or null values in alerting. (aler
Alert notifications can include images, but rendering many images at the same time can overload the server.
This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default
value is `5`.
+
+## [panels]
+
+### enable_alpha
+Set to true if you want to test panels that are not yet ready for general usage.
+
+### disable_sanitize_html
+If set to true Grafana will allow script tags in text panels. Not recommended as it enable XSS vulnerabilities. Default
+is false. This settings was introduced in Grafana v6.0.
+
+
diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md
index 7ed44572533..d26af5277a1 100644
--- a/docs/sources/installation/debian.md
+++ b/docs/sources/installation/debian.md
@@ -34,32 +34,29 @@ sudo dpkg -i grafana__amd64.deb
Example:
```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.4_amd64.deb
+wget https://dl.grafana.com/oss/release/grafana_5.4.2_amd64.deb
sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_5.1.4_amd64.deb
+sudo dpkg -i grafana_5.4.2_amd64.deb
```
## APT Repository
-Add the following line to your `/etc/apt/sources.list` file.
+Create a file `/etc/apt/sources.list.d/grafana.list` and add the following to it.
```bash
-deb https://packagecloud.io/grafana/stable/debian/ stretch main
+deb https://packages.grafana.com/oss/deb stable main
```
-Use the above line even if you are on Ubuntu or another Debian version.
-There is also a testing repository if you want beta or release
-candidates.
+There is a separate repository if you want beta releases.
```bash
-deb https://packagecloud.io/grafana/testing/debian/ stretch main
+deb https://packages.grafana.com/oss/deb beta main
```
-Then add the [Package Cloud](https://packagecloud.io/grafana) key. This
-allows you to install signed packages.
+Use the above line even if you are on Ubuntu or another Debian version. Then add our gpg key. This allows you to install signed packages.
```bash
-curl https://packagecloud.io/gpg.key | sudo apt-key add -
+curl https://packages.grafana.com/gpg.key | sudo apt-key add -
```
Update your Apt repositories and install Grafana
diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md
index 5bf3b7ed745..156f703e164 100644
--- a/docs/sources/installation/rpm.md
+++ b/docs/sources/installation/rpm.md
@@ -32,7 +32,7 @@ $ sudo yum install
Example:
```bash
-$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm
+$ sudo yum install https://dl.grafana.com/oss/release/grafana-5.4.2-1.x86_64.rpm
```
Or install manually using `rpm`. First execute
@@ -44,7 +44,7 @@ $ wget
Example:
```bash
-$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm
+$ wget https://dl.grafana.com/oss/release/grafana-5.4.2-1.x86_64.rpm
```
### On CentOS / Fedora / Redhat:
@@ -67,19 +67,27 @@ Add the following to a new file at `/etc/yum.repos.d/grafana.repo`
```bash
[grafana]
name=grafana
-baseurl=https://packagecloud.io/grafana/stable/el/7/$basearch
+baseurl=https://packages.grafana.com/oss/rpm
repo_gpgcheck=1
enabled=1
gpgcheck=1
-gpgkey=https://packagecloud.io/gpg.key https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana
+gpgkey=https://packages.grafana.com/gpg.key
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
```
-There is also a testing repository if you want beta or release candidates.
+There is a separate repository if you want beta releases.
```bash
-baseurl=https://packagecloud.io/grafana/testing/el/7/$basearch
+[grafana]
+name=grafana
+baseurl=https://packages.grafana.com/oss/rpm-beta
+repo_gpgcheck=1
+enabled=1
+gpgcheck=1
+gpgkey=https://packages.grafana.com/gpg.key
+sslverify=1
+sslcacert=/etc/pki/tls/certs/ca-bundle.crt
```
Then install Grafana via the `yum` command.
@@ -91,7 +99,7 @@ $ sudo yum install grafana
### RPM GPG Key
The RPMs are signed, you can verify the signature with this [public GPG
-key](https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana).
+key](https://packages.grafana.com/gpg.key).
## Package details
diff --git a/docs/sources/reference/dashboard.md b/docs/sources/reference/dashboard.md
index 6be12600da5..3d96923bc72 100644
--- a/docs/sources/reference/dashboard.md
+++ b/docs/sources/reference/dashboard.md
@@ -51,7 +51,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
"list": []
},
"refresh": "5s",
- "schemaVersion": 16,
+ "schemaVersion": 17,
"version": 0,
"links": []
}
diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md
index f20cc0ccfc9..7426877654b 100644
--- a/docs/sources/reference/templating.md
+++ b/docs/sources/reference/templating.md
@@ -52,6 +52,7 @@ Filter Option | Example | Raw | Interpolated | Description
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
`distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
`lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
+`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1).
@@ -244,6 +245,11 @@ summarize($myinterval, sum, false)
Grafana has global built-in variables that can be used in expressions in the query editor.
+### Time range variables
+
+Grafana has two built in time range variables in `$__from` and `$__to`. They are currently always interpolated
+as epoch milliseconds. These variables are only available in Grafana v6.0 and above.
+
### The $__interval Variable
This $__interval variable is similar to the `auto` interval variable that is described above. It can be used as a parameter to group by time (for InfluxDB, MySQL, Postgres, MSSQL), Date histogram interval (for Elasticsearch) or as a *summarize* function parameter (for Graphite).
@@ -292,9 +298,11 @@ The `direction` controls how the panels will be arranged.
By choosing `horizontal` the panels will be arranged side-by-side. Grafana will automatically adjust the width
of each repeated panel so that the whole row is filled. Currently, you cannot mix other panels on a row with a repeated
-panel. Each panel will never be smaller that the provided `Min width` if you have many selected values.
+panel.
-By choosing `vertical` the panels will be arranged from top to bottom in a column. The `Min width` doesn't have any effect in this case. The width of the repeated panels will be the same as of the first panel (the original template) being repeated.
+Set `Max per row` to tell grafana how many panels per row you want at most. It defaults to *4* if you don't set anything.
+
+By choosing `vertical` the panels will be arranged from top to bottom in a column. The width of the repeated panels will be the same as of the first panel (the original template) being repeated.
Only make changes to the first panel (the original template). To have the changes take effect on all panels you need to trigger a dynamic dashboard re-build.
You can do this by either changing the variable value (that is the basis for the repeat) or reload the dashboard.
diff --git a/latest.json b/latest.json
index 21a3f38e1af..dc83446f588 100644
--- a/latest.json
+++ b/latest.json
@@ -1,4 +1,4 @@
{
- "stable": "5.4.2",
- "testing": "5.4.2"
+ "stable": "5.4.3",
+ "testing": "5.4.3"
}
diff --git a/package.json b/package.json
index eefe2cbbe53..77fd92baf57 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
- "version": "5.5.0-pre1",
+ "version": "6.0.0-prebeta2",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"
@@ -24,9 +24,10 @@
"@types/jquery": "^1.10.35",
"@types/node": "^8.0.31",
"@types/react": "^16.7.6",
- "@types/react-custom-scrollbars": "^4.0.5",
"@types/react-dom": "^16.0.9",
+ "@types/react-grid-layout": "^0.16.6",
"@types/react-select": "^2.0.4",
+ "@types/react-virtualized": "^9.18.12",
"angular-mocks": "1.6.6",
"autoprefixer": "^6.4.0",
"axios": "^0.17.1",
@@ -65,15 +66,17 @@
"html-webpack-plugin": "^3.2.0",
"husky": "^0.14.3",
"jest": "^23.6.0",
+ "jest-date-mock": "^1.0.6",
"lint-staged": "^6.0.0",
"load-grunt-tasks": "3.5.2",
"mini-css-extract-plugin": "^0.4.0",
"mocha": "^4.0.1",
+ "monaco-editor": "^0.15.6",
"ng-annotate-loader": "^0.6.1",
"ng-annotate-webpack-plugin": "^0.3.0",
"ngtemplate-loader": "^2.0.1",
- "npm": "^5.4.2",
"node-sass": "^4.11.0",
+ "npm": "^5.4.2",
"optimize-css-assets-webpack-plugin": "^4.0.2",
"phantomjs-prebuilt": "^2.1.15",
"postcss-browser-reporter": "^0.5.0",
@@ -82,6 +85,7 @@
"prettier": "1.9.2",
"react-hot-loader": "^4.3.6",
"react-test-renderer": "^16.5.0",
+ "regexp-replace-loader": "^1.0.1",
"sass-lint": "^1.10.2",
"sass-loader": "^7.0.1",
"sinon": "1.17.6",
@@ -114,7 +118,8 @@
"typecheck": "tsc --noEmit",
"jest": "jest --notify --watch",
"api-tests": "jest --notify --watch --config=tests/api/jest.js",
- "precommit": "grunt precommit"
+ "precommit": "grunt precommit",
+ "storybook": "cd packages/grafana-ui && yarn storybook"
},
"husky": {
"hooks": {
@@ -167,7 +172,6 @@
"prop-types": "^15.6.2",
"rc-cascader": "^0.14.0",
"react": "^16.6.3",
- "react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.6.3",
"react-grid-layout": "0.16.6",
"react-highlight-words": "0.11.0",
@@ -189,7 +193,8 @@
"slate-react": "^0.12.4",
"tether": "^1.4.0",
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
- "tinycolor2": "^1.4.1"
+ "tinycolor2": "^1.4.1",
+ "xss": "^1.0.3"
},
"resolutions": {
"caniuse-db": "1.0.30000772",
diff --git a/packages/grafana-ui/.storybook/addons.ts b/packages/grafana-ui/.storybook/addons.ts
new file mode 100644
index 00000000000..2efe80ebffa
--- /dev/null
+++ b/packages/grafana-ui/.storybook/addons.ts
@@ -0,0 +1,2 @@
+import '@storybook/addon-knobs/register';
+import '@storybook/addon-actions/register';
diff --git a/packages/grafana-ui/.storybook/config.ts b/packages/grafana-ui/.storybook/config.ts
new file mode 100644
index 00000000000..9e50c6b501a
--- /dev/null
+++ b/packages/grafana-ui/.storybook/config.ts
@@ -0,0 +1,12 @@
+import { configure } from '@storybook/react';
+
+import '../../../public/sass/grafana.light.scss';
+
+// automatically import all files ending in *.stories.tsx
+const req = require.context('../src/components', true, /.story.tsx$/);
+
+function loadStories() {
+ req.keys().forEach(req);
+}
+
+configure(loadStories, module);
diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js
new file mode 100644
index 00000000000..44de73a1e18
--- /dev/null
+++ b/packages/grafana-ui/.storybook/webpack.config.js
@@ -0,0 +1,56 @@
+const path = require('path');
+
+module.exports = (baseConfig, env, config) => {
+
+ config.module.rules.push({
+ test: /\.(ts|tsx)$/,
+ use: [
+ {
+ loader: require.resolve('awesome-typescript-loader'),
+ },
+ ],
+ });
+
+ config.module.rules.push({
+ test: /\.scss$/,
+ use: [
+ {
+ loader: 'style-loader',
+ },
+ {
+ loader: 'css-loader',
+ options: {
+ importLoaders: 2,
+ url: false,
+ sourceMap: false,
+ minimize: false,
+ },
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ sourceMap: false,
+ config: { path: __dirname + '../../../../scripts/webpack/postcss.config.js' },
+ },
+ },
+ { loader: 'sass-loader', options: { sourceMap: false } },
+ ],
+ });
+
+ config.module.rules.push({
+ test: require.resolve('jquery'),
+ use: [
+ {
+ loader: 'expose-loader',
+ query: 'jQuery',
+ },
+ {
+ loader: 'expose-loader',
+ query: '$',
+ },
+ ],
+ });
+
+ config.resolve.extensions.push('.ts', '.tsx');
+ return config;
+};
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index 2fb210e3b46..0d1b14a7150 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -5,29 +5,57 @@
"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-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",
"react-popper": "^1.3.0",
"react-transition-group": "^2.2.1",
- "react-virtualized": "^9.21.0"
+ "react-virtualized": "^9.21.0",
+ "tether": "^1.4.0",
+ "tether-drop": "https://github.com/torkelo/drop/tarball/master",
+ "tinycolor2": "^1.4.1"
},
"devDependencies": {
- "@types/jest": "^23.3.2",
- "@types/lodash": "^4.14.119",
- "@types/react": "^16.7.6",
+ "@storybook/addon-actions": "^4.1.7",
+ "@storybook/addon-info": "^4.1.6",
+ "@storybook/addon-knobs": "^4.1.7",
+ "@storybook/react": "^4.1.4",
"@types/classnames": "^2.2.6",
+ "@types/jest": "^23.3.2",
"@types/jquery": "^1.10.35",
+ "@types/lodash": "^4.14.119",
+ "@types/node": "^10.12.18",
+ "@types/react": "^16.7.6",
+ "@types/react-custom-scrollbars": "^4.0.5",
+ "@types/react-test-renderer": "^16.0.3",
+ "@types/react-transition-group": "^2.0.15",
+ "@types/storybook__addon-actions": "^3.4.1",
+ "@types/storybook__addon-info": "^3.4.2",
+ "@types/storybook__addon-knobs": "^4.0.0",
+ "@types/storybook__react": "^4.0.0",
+ "@types/tether-drop": "^1.4.8",
+ "@types/tinycolor2": "^1.4.1",
+ "awesome-typescript-loader": "^5.2.1",
+ "react-docgen-typescript-loader": "^3.0.0",
+ "react-docgen-typescript-webpack-plugin": "^1.1.0",
+ "react-test-renderer": "^16.7.0",
"typescript": "^3.2.2"
+ },
+ "resolutions": {
+ "@types/lodash": "4.14.119"
}
}
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx
new file mode 100644
index 00000000000..4c5df5314b8
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { ColorPickerProps } from './ColorPicker';
+import tinycolor from 'tinycolor2';
+import { debounce } from 'lodash';
+
+interface ColorInputState {
+ previousColor: string;
+ value: string;
+}
+
+interface ColorInputProps extends ColorPickerProps {
+ style?: React.CSSProperties;
+}
+
+class ColorInput extends React.PureComponent {
+ constructor(props: ColorInputProps) {
+ super(props);
+ this.state = {
+ previousColor: props.color,
+ value: props.color,
+ };
+
+ this.updateColor = debounce(this.updateColor, 100);
+ }
+
+ static getDerivedStateFromProps(props: ColorPickerProps, state: ColorInputState) {
+ const newColor = tinycolor(props.color);
+ if (newColor.isValid() && props.color !== state.previousColor) {
+ return {
+ ...state,
+ previousColor: props.color,
+ value: newColor.toString(),
+ };
+ }
+
+ return state;
+ }
+ updateColor = (color: string) => {
+ this.props.onChange(color);
+ };
+
+ handleChange = (event: React.SyntheticEvent) => {
+ const newColor = tinycolor(event.currentTarget.value);
+
+ this.setState({
+ value: event.currentTarget.value,
+ });
+
+ if (newColor.isValid()) {
+ this.updateColor(newColor.toString());
+ }
+ };
+
+ handleBlur = () => {
+ const newColor = tinycolor(this.state.value);
+
+ if (!newColor.isValid()) {
+ this.setState({
+ value: this.props.color,
+ });
+ }
+ };
+
+ render() {
+ const { value } = this.state;
+ return (
+
+ );
+ }
+}
+
+export default ColorInput;
diff --git a/public/app/core/specs/ColorPalette.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPalette.test.tsx
similarity index 80%
rename from public/app/core/specs/ColorPalette.test.tsx
rename to packages/grafana-ui/src/components/ColorPicker/ColorPalette.test.tsx
index fb1124aa975..0714180de54 100644
--- a/public/app/core/specs/ColorPalette.test.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPalette.test.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer';
-import { ColorPalette } from '../components/colorpicker/ColorPalette';
+import { ColorPalette } from './ColorPalette';
describe('CollorPalette', () => {
it('renders correctly', () => {
diff --git a/public/app/core/components/colorpicker/ColorPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPalette.tsx
similarity index 90%
rename from public/app/core/components/colorpicker/ColorPalette.tsx
rename to packages/grafana-ui/src/components/ColorPicker/ColorPalette.tsx
index edb2629d16d..03ed9949361 100644
--- a/public/app/core/components/colorpicker/ColorPalette.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPalette.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { sortedColors } from 'app/core/utils/colors';
+import { sortedColors } from '../../utils';
export interface Props {
color: string;
@@ -9,13 +9,13 @@ export interface Props {
export class ColorPalette extends React.Component {
paletteColors: string[];
- constructor(props) {
+ constructor(props: Props) {
super(props);
this.paletteColors = sortedColors;
this.onColorSelect = this.onColorSelect.bind(this);
}
- onColorSelect(color) {
+ onColorSelect(color: string) {
return () => {
this.props.onColorSelect(color);
};
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx
new file mode 100644
index 00000000000..19ae2fda978
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { withKnobs, boolean } from '@storybook/addon-knobs';
+import { SeriesColorPicker, ColorPicker } from './ColorPicker';
+import { action } from '@storybook/addon-actions';
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { UseState } from '../../utils/storybook/UseState';
+import { getThemeKnob } from '../../utils/storybook/themeKnob';
+
+const getColorPickerKnobs = () => {
+ return {
+ selectedTheme: getThemeKnob(),
+ enableNamedColors: boolean('Enable named colors', false),
+ };
+};
+
+const ColorPickerStories = storiesOf('UI/ColorPicker/Pickers', module);
+
+ColorPickerStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
+
+ColorPickerStories.add('default', () => {
+ const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
+ return (
+
+ {(selectedColor, updateSelectedColor) => {
+ return (
+ {
+ action('Color changed')(color);
+ updateSelectedColor(color);
+ }}
+ theme={selectedTheme || undefined}
+ />
+ );
+ }}
+
+ );
+});
+
+ColorPickerStories.add('Series color picker', () => {
+ const { selectedTheme, enableNamedColors } = getColorPickerKnobs();
+
+ return (
+
+ {(selectedColor, updateSelectedColor) => {
+ return (
+ {}}
+ color={selectedColor}
+ onChange={color => updateSelectedColor(color)}
+ theme={selectedTheme || undefined}
+ >
+ Open color picker
+
+ );
+ }}
+
+ );
+});
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
new file mode 100644
index 00000000000..b6cf176a24b
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
@@ -0,0 +1,114 @@
+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';
+
+type ColorPickerChangeHandler = (color: string) => void;
+
+export interface ColorPickerProps extends Themeable {
+ color: string;
+ onChange: ColorPickerChangeHandler;
+
+ /**
+ * @deprecated Use onChange instead
+ */
+ onColorChange?: ColorPickerChangeHandler;
+ enableNamedColors?: boolean;
+ withArrow?: boolean;
+ children?: JSX.Element;
+}
+
+export const warnAboutColorPickerPropsDeprecation = (componentName: string, props: ColorPickerProps) => {
+ const { onColorChange } = props;
+ if (onColorChange) {
+ propDeprecationWarning(componentName, 'onColorChange', 'onChange');
+ }
+};
+
+export const colorPickerFactory = (
+ popover: React.ComponentType,
+ displayName = 'ColorPicker',
+ renderPopoverArrowFunction?: RenderPopperArrowFn
+) => {
+ return class ColorPicker extends Component {
+ static displayName = displayName;
+ pickerTriggerRef = createRef();
+
+ handleColorChange = (color: string) => {
+ const { onColorChange, onChange } = this.props;
+ const changeHandler = (onColorChange || onChange) as ColorPickerChangeHandler;
+
+ return changeHandler(color);
+ };
+
+ render() {
+ const popoverElement = React.createElement(popover, {
+ ...this.props,
+ onChange: this.handleColorChange,
+ });
+ const { theme, withArrow, children } = this.props;
+
+ const renderArrow: RenderPopperArrowFn = ({ arrowProps, placement }) => {
+ return (
+
+ );
+ };
+
+ return (
+
+ {(showPopper, hidePopper, popperProps) => {
+ return (
+ <>
+ {this.pickerTriggerRef.current && (
+
+ )}
+
+ {children ? (
+ React.cloneElement(children as JSX.Element, {
+ ref: this.pickerTriggerRef,
+ onClick: showPopper,
+ onMouseLeave: hidePopper,
+ })
+ ) : (
+
+ )}
+ >
+ );
+ }}
+
+ );
+ }
+ };
+};
+
+export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker');
+export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker');
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx
new file mode 100644
index 00000000000..dc51819a413
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.story.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { ColorPickerPopover } from './ColorPickerPopover';
+import { withKnobs } from '@storybook/addon-knobs';
+
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { getThemeKnob } from '../../utils/storybook/themeKnob';
+import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
+
+const ColorPickerPopoverStories = storiesOf('UI/ColorPicker/Popovers', module);
+
+ColorPickerPopoverStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
+
+ColorPickerPopoverStories.add('default', () => {
+ const selectedTheme = getThemeKnob();
+
+ return (
+ {
+ console.log(color);
+ }}
+ theme={selectedTheme || undefined}
+ />
+ );
+});
+
+ColorPickerPopoverStories.add('SeriesColorPickerPopover', () => {
+ const selectedTheme = getThemeKnob();
+
+ return (
+ {
+ console.log(color);
+ }}
+ theme={selectedTheme || undefined}
+ />
+ );
+});
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx
new file mode 100644
index 00000000000..28d66e7af86
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.test.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { ColorPickerPopover } from './ColorPickerPopover';
+import { getColorDefinitionByName, getNamedColorPalette } from '../../utils/namedColorsPalette';
+import { ColorSwatch } from './NamedColorsGroup';
+import { flatten } from 'lodash';
+import { GrafanaTheme } from '../../types';
+
+const allColors = flatten(Array.from(getNamedColorPalette().values()));
+
+describe('ColorPickerPopover', () => {
+ const BasicGreen = getColorDefinitionByName('green');
+ const BasicBlue = getColorDefinitionByName('blue');
+
+ describe('rendering', () => {
+ it('should render provided color as selected if color provided by name', () => {
+ const wrapper = mount( {}} />);
+ const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
+ const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
+
+ expect(selectedSwatch.length).toBe(1);
+ expect(notSelectedSwatches.length).toBe(allColors.length - 1);
+ expect(selectedSwatch.prop('isSelected')).toBe(true);
+ });
+
+ it('should render provided color as selected if color provided by hex', () => {
+ const wrapper = mount( {}} />);
+ const selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
+ const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere(node => node.prop('isSelected') === false);
+
+ expect(selectedSwatch.length).toBe(1);
+ expect(notSelectedSwatches.length).toBe(allColors.length - 1);
+ expect(selectedSwatch.prop('isSelected')).toBe(true);
+ });
+ });
+
+ describe('named colors support', () => {
+ const onChangeSpy = jest.fn();
+ let wrapper: ReactWrapper;
+
+ afterEach(() => {
+ wrapper.unmount();
+ onChangeSpy.mockClear();
+ });
+
+ it('should pass hex color value to onChange prop by default', () => {
+ wrapper = mount(
+
+ );
+ const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
+
+ basicBlueSwatch.simulate('click');
+
+ expect(onChangeSpy).toBeCalledTimes(1);
+ expect(onChangeSpy).toBeCalledWith(BasicBlue.variants.light);
+ });
+
+ it('should pass color name to onChange prop when named colors enabled', () => {
+ wrapper = mount(
+
+ );
+ const basicBlueSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicBlue.name);
+
+ basicBlueSwatch.simulate('click');
+
+ expect(onChangeSpy).toBeCalledTimes(1);
+ expect(onChangeSpy).toBeCalledWith(BasicBlue.name);
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
new file mode 100644
index 00000000000..d2937a1caba
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
@@ -0,0 +1,130 @@
+import React from 'react';
+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';
+
+export interface Props extends ColorPickerProps, PopperContentProps {
+ customPickers?: T;
+}
+
+type PickerType = 'palette' | 'spectrum';
+
+interface CustomPickersDescriptor {
+ [key: string]: {
+ tabComponent: React.ComponentType;
+ name: string;
+ };
+}
+interface State {
+ activePicker: PickerType | keyof T;
+}
+
+export class ColorPickerPopover extends React.Component, State> {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ activePicker: 'palette',
+ };
+ warnAboutColorPickerPropsDeprecation('ColorPickerPopover', props);
+ }
+
+ getTabClassName = (tabName: PickerType | keyof T) => {
+ const { activePicker } = this.state;
+ return `ColorPickerPopover__tab ${activePicker === tabName && 'ColorPickerPopover__tab--active'}`;
+ };
+
+ handleChange = (color: any) => {
+ const { onColorChange, onChange, enableNamedColors, theme } = this.props;
+ const changeHandler = onColorChange || onChange;
+
+ if (enableNamedColors) {
+ return changeHandler(color);
+ }
+ changeHandler(getColorFromHexRgbOrName(color, theme));
+ };
+
+ handleTabChange = (tab: PickerType | keyof T) => {
+ return () => this.setState({ activePicker: tab });
+ };
+
+ renderPicker = () => {
+ const { activePicker } = this.state;
+ const { color, theme } = this.props;
+
+ switch (activePicker) {
+ case 'spectrum':
+ return ;
+ case 'palette':
+ return ;
+ default:
+ return this.renderCustomPicker(activePicker);
+ }
+ };
+
+ renderCustomPicker = (tabKey: keyof T) => {
+ const { customPickers, color, theme } = this.props;
+ if (!customPickers) {
+ return null;
+ }
+
+ return React.createElement(customPickers[tabKey].tabComponent, {
+ color,
+ theme,
+ onChange: this.handleChange,
+ });
+ };
+
+ renderCustomPickerTabs = () => {
+ const { customPickers } = this.props;
+
+ if (!customPickers) {
+ return null;
+ }
+
+ return (
+ <>
+ {Object.keys(customPickers).map(key => {
+ return (
+
+ {customPickers[key].name}
+
+ );
+ })}
+ >
+ );
+ };
+
+ render() {
+ const { theme } = this.props;
+ const colorPickerTheme = theme || GrafanaTheme.Dark;
+
+ return (
+
+
+
+ Colors
+
+
+ Custom
+
+ {this.renderCustomPickerTabs()}
+
+
+
{this.renderPicker()}
+
+ );
+ }
+}
diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx
new file mode 100644
index 00000000000..91c4f21642a
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsGroup.tsx
@@ -0,0 +1,110 @@
+import React, { FunctionComponent } from 'react';
+import { Themeable, GrafanaTheme } from '../../types';
+import { ColorDefinition, getColorForTheme } from '../../utils/namedColorsPalette';
+import { Color } from 'csstype';
+import { find, upperFirst } from 'lodash';
+
+type ColorChangeHandler = (color: ColorDefinition) => void;
+
+export enum ColorSwatchVariant {
+ Small = 'small',
+ Large = 'large',
+}
+
+interface ColorSwatchProps extends Themeable, React.DOMAttributes {
+ color: string;
+ label?: string;
+ variant?: ColorSwatchVariant;
+ isSelected?: boolean;
+}
+
+export const ColorSwatch: FunctionComponent = ({
+ color,
+ label,
+ variant = ColorSwatchVariant.Small,
+ isSelected,
+ theme,
+ ...otherProps
+}) => {
+ const isSmall = variant === ColorSwatchVariant.Small;
+ const swatchSize = isSmall ? '16px' : '32px';
+ const selectedSwatchBorder = theme === GrafanaTheme.Light ? '#ffffff' : '#1A1B1F';
+ const swatchStyles = {
+ width: swatchSize,
+ height: swatchSize,
+ borderRadius: '50%',
+ background: `${color}`,
+ marginRight: isSmall ? '0px' : '8px',
+ boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${selectedSwatchBorder}` : 'none',
+ };
+
+ return (
+
+
+ {variant === ColorSwatchVariant.Large &&
{label} }
+
+ );
+};
+
+interface NamedColorsGroupProps extends Themeable {
+ colors: ColorDefinition[];
+ selectedColor?: Color;
+ onColorSelect: ColorChangeHandler;
+ key?: string;
+}
+
+const NamedColorsGroup: FunctionComponent = ({
+ colors,
+ selectedColor,
+ onColorSelect,
+ theme,
+ ...otherProps
+}) => {
+ const primaryColor = find(colors, color => !!color.isPrimary);
+
+ return (
+
+ {primaryColor && (
+
onColorSelect(primaryColor)}
+ theme={theme}
+ />
+ )}
+
+ {colors.map(
+ color =>
+ !color.isPrimary && (
+
+ onColorSelect(color)}
+ theme={theme}
+ />
+
+ )
+ )}
+
+
+ );
+};
+
+export default NamedColorsGroup;
diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx
new file mode 100644
index 00000000000..af5de3b2a2d
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.story.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { NamedColorsPalette } from './NamedColorsPalette';
+import { getColorName, getColorDefinitionByName } from '../../utils/namedColorsPalette';
+import { withKnobs, select } from '@storybook/addon-knobs';
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { UseState } from '../../utils/storybook/UseState';
+
+const BasicGreen = getColorDefinitionByName('green');
+const BasicBlue = getColorDefinitionByName('blue');
+const LightBlue = getColorDefinitionByName('light-blue');
+
+const NamedColorsPaletteStories = storiesOf('UI/ColorPicker/Palettes/NamedColorsPalette', module);
+
+NamedColorsPaletteStories.addDecorator(withKnobs).addDecorator(withCenteredStory);
+
+NamedColorsPaletteStories.add('Named colors swatch - support for named colors', () => {
+ const selectedColor = select(
+ 'Selected color',
+ {
+ Green: 'green',
+ Red: 'red',
+ 'Light blue': 'light-blue',
+ },
+ 'red'
+ );
+
+ return (
+
+ {(selectedColor, updateSelectedColor) => {
+ return ;
+ }}
+
+ );
+}).add('Named colors swatch - support for hex values', () => {
+ const selectedColor = select(
+ 'Selected color',
+ {
+ Green: BasicGreen.variants.dark,
+ Red: BasicBlue.variants.dark,
+ 'Light blue': LightBlue.variants.dark,
+ },
+ 'red'
+ );
+ return (
+
+ {(selectedColor, updateSelectedColor) => {
+ return ;
+ }}
+
+ );
+});
diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx
new file mode 100644
index 00000000000..171d26f5c56
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { NamedColorsPalette } from './NamedColorsPalette';
+import { ColorSwatch } from './NamedColorsGroup';
+import { getColorDefinitionByName } from '../../utils';
+import { GrafanaTheme } from '../../types';
+
+describe('NamedColorsPalette', () => {
+
+ const BasicGreen = getColorDefinitionByName('green');
+
+ describe('theme support for named colors', () => {
+ let wrapper: ReactWrapper, selectedSwatch;
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ it('should render provided color variant specific for theme', () => {
+ wrapper = mount( {}} />);
+ selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
+ expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
+
+ wrapper.unmount();
+ wrapper = mount( {}} />);
+ selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
+ expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light);
+ });
+
+ it('should render dar variant of provided color when theme not provided', () => {
+ wrapper = mount( {}} />);
+ selectedSwatch = wrapper.find(ColorSwatch).findWhere(node => node.key() === BasicGreen.name);
+ expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx
new file mode 100644
index 00000000000..7bdd3c690b9
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Color, getNamedColorPalette } from '../../utils/namedColorsPalette';
+import { Themeable } from '../../types/index';
+import NamedColorsGroup from './NamedColorsGroup';
+
+interface NamedColorsPaletteProps extends Themeable {
+ color?: Color;
+ onChange: (colorName: string) => void;
+}
+
+export const NamedColorsPalette = ({ color, onChange, theme }: NamedColorsPaletteProps) => {
+ const swatches: JSX.Element[] = [];
+ getNamedColorPalette().forEach((colors, hue) => {
+ swatches.push(
+ {
+ onChange(color.name);
+ }}
+ />
+ );
+ });
+
+ return (
+
+ {swatches}
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
new file mode 100644
index 00000000000..3fa7a1f4a45
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
@@ -0,0 +1,87 @@
+import React, { FunctionComponent } from 'react';
+
+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;
+ onToggleAxis?: () => void;
+}
+
+export const SeriesColorPickerPopover: FunctionComponent = props => {
+ const { yaxis, onToggleAxis, color, ...colorPickerProps } = props;
+
+ return (
+ (
+ {
+ if (onToggleAxis) {
+ onToggleAxis();
+ }
+ }}
+ />
+ ),
+ },
+ }}
+ />
+ );
+};
+
+interface AxisSelectorProps {
+ yaxis: number;
+ onToggleAxis?: () => void;
+}
+
+interface AxisSelectorState {
+ yaxis: number;
+}
+
+export class AxisSelector extends React.PureComponent {
+ constructor(props: AxisSelectorProps) {
+ super(props);
+ this.state = {
+ yaxis: this.props.yaxis,
+ };
+ this.onToggleAxis = this.onToggleAxis.bind(this);
+ }
+
+ onToggleAxis() {
+ this.setState({
+ yaxis: this.state.yaxis === 2 ? 1 : 2,
+ });
+
+ if (this.props.onToggleAxis) {
+ this.props.onToggleAxis();
+ }
+ }
+
+ render() {
+ const leftButtonClass = this.state.yaxis === 1 ? 'btn-success' : 'btn-inverse';
+ const rightButtonClass = this.state.yaxis === 2 ? 'btn-success' : 'btn-inverse';
+
+ return (
+
+ Y Axis:
+
+ Left
+
+
+ Right
+
+
+ );
+ }
+}
diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx
new file mode 100644
index 00000000000..b4fdaf69ed9
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.story.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { withKnobs } from '@storybook/addon-knobs';
+import SpectrumPalette from './SpectrumPalette';
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { UseState } from '../../utils/storybook/UseState';
+import { getThemeKnob } from '../../utils/storybook/themeKnob';
+
+const SpectrumPaletteStories = storiesOf('UI/ColorPicker/Palettes/SpectrumPalette', module);
+
+SpectrumPaletteStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
+
+SpectrumPaletteStories.add('default', () => {
+ const selectedTheme = getThemeKnob();
+
+ return (
+
+ {(selectedColor, updateSelectedColor) => {
+ return ;
+ }}
+
+ );
+});
diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx
new file mode 100644
index 00000000000..cf001cf5629
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalette.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { CustomPicker, ColorResult } from 'react-color';
+
+import { Saturation, Hue, Alpha } from 'react-color/lib/components/common';
+import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette';
+import tinycolor from 'tinycolor2';
+import ColorInput from './ColorInput';
+import { Themeable, GrafanaTheme } from '../../types';
+import SpectrumPalettePointer, { SpectrumPalettePointerProps } from './SpectrumPalettePointer';
+
+export interface SpectrumPaletteProps extends Themeable {
+ color: string;
+ onChange: (color: string) => void;
+}
+
+const renderPointer = (theme?: GrafanaTheme) => (props: SpectrumPalettePointerProps) => (
+
+);
+
+// @ts-ignore
+const SpectrumPicker = CustomPicker(({ rgb, hsl, onChange, theme }) => {
+ return (
+
+
+
+
+ {/*
+ // @ts-ignore */}
+
+
+
+ {/*
+ // @ts-ignore */}
+
+
+
+
+
+ {/*
+ // @ts-ignore */}
+
+
+
+
+ );
+});
+
+const SpectrumPalette: React.FunctionComponent = ({ color, onChange, theme }) => {
+ return (
+
+ {
+ onChange(tinycolor(a.rgb).toString());
+ }}
+ theme={theme}
+ />
+
+
+ );
+};
+
+export default SpectrumPalette;
diff --git a/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx
new file mode 100644
index 00000000000..d0b2cbc4bff
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/SpectrumPalettePointer.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { GrafanaTheme, Themeable } from '../../types';
+
+export interface SpectrumPalettePointerProps extends Themeable {
+ direction?: string;
+}
+
+const SpectrumPalettePointer: React.FunctionComponent = ({
+ theme,
+ direction,
+}) => {
+ const styles = {
+ picker: {
+ width: '16px',
+ height: '16px',
+ transform: direction === 'vertical' ? 'translate(0, -8px)' : 'translate(-8px, 0)',
+ },
+ };
+
+ const pointerColor = theme === GrafanaTheme.Light ? '#3F444D' : '#8E8E8E';
+
+ let pointerStyles: React.CSSProperties = {
+ position: 'absolute',
+ left: '6px',
+ width: '0',
+ height: '0',
+ borderStyle: 'solid',
+ background: 'none',
+ };
+
+ let topArrowStyles: React.CSSProperties = {
+ top: '-7px',
+ borderWidth: '6px 3px 0px 3px',
+ borderColor: `${pointerColor} transparent transparent transparent`,
+ };
+
+ let bottomArrowStyles: React.CSSProperties = {
+ bottom: '-7px',
+ borderWidth: '0px 3px 6px 3px',
+ borderColor: ` transparent transparent ${pointerColor} transparent`,
+ };
+
+ if (direction === 'vertical') {
+ pointerStyles = {
+ ...pointerStyles,
+ left: 'auto',
+ };
+ topArrowStyles = {
+ borderWidth: '3px 0px 3px 6px',
+ borderColor: `transparent transparent transparent ${pointerColor}`,
+ left: '-7px',
+ top: '7px',
+ };
+ bottomArrowStyles = {
+ borderWidth: '3px 6px 3px 0px',
+ borderColor: `transparent ${pointerColor} transparent transparent`,
+ right: '-7px',
+ top: '7px',
+ };
+ }
+
+ return (
+
+ );
+};
+
+export default SpectrumPalettePointer;
diff --git a/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
new file mode 100644
index 00000000000..46eed5f7ff1
--- /dev/null
+++ b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
@@ -0,0 +1,240 @@
+$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,
+.sp-replacer.sp-active {
+ border-color: inherit;
+ color: inherit;
+}
+
+.sp-container {
+ border-radius: 0;
+ background-color: $dropdownBackground;
+ border: none;
+ padding: 0;
+}
+
+.sp-palette-container,
+.sp-picker-container {
+ border: none;
+}
+
+.sp-dd {
+ display: none;
+}
+
+.sp-preview {
+ position: relative;
+ width: 15px;
+ height: 15px;
+ border: none;
+ 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: $arrowSize;
+ padding-left: 6px;
+}
+
+.drop-popover.gf-color-picker {
+ .drop-content {
+ width: 210px;
+ }
+}
+
+// TODO: Remove. This is a temporary solution until color picker popovers are used
+// with Drop.js.
+.drop-popover.drop-popover--transparent {
+ .drop-content {
+ border: none;
+ background: none;
+ padding: 0;
+ max-width: none;
+
+ &:before {
+ display: none;
+ }
+ }
+}
diff --git a/public/app/core/specs/__snapshots__/ColorPalette.test.tsx.snap b/packages/grafana-ui/src/components/ColorPicker/__snapshots__/ColorPalette.test.tsx.snap
similarity index 100%
rename from public/app/core/specs/__snapshots__/ColorPalette.test.tsx.snap
rename to packages/grafana-ui/src/components/ColorPicker/__snapshots__/ColorPalette.test.tsx.snap
diff --git a/public/app/core/components/CustomScrollbar/CustomScrollbar.test.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx
similarity index 100%
rename from public/app/core/components/CustomScrollbar/CustomScrollbar.test.tsx
rename to packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx
diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
new file mode 100644
index 00000000000..40f6c6c3c37
--- /dev/null
+++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
@@ -0,0 +1,100 @@
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+import Scrollbars from 'react-custom-scrollbars';
+
+interface Props {
+ customClassName?: string;
+ autoHide?: boolean;
+ autoHideTimeout?: number;
+ autoHideDuration?: number;
+ autoHeightMax?: string;
+ hideTracksWhenNotNeeded?: boolean;
+ renderTrackHorizontal?: React.FunctionComponent;
+ renderTrackVertical?: React.FunctionComponent;
+ scrollTop?: number;
+ setScrollTop: (event: any) => void;
+ autoHeightMin?: number | string;
+}
+
+/**
+ * Wraps component into component from `react-custom-scrollbars`
+ */
+export class CustomScrollbar extends PureComponent {
+ static defaultProps: Partial = {
+ customClassName: 'custom-scrollbars',
+ autoHide: false,
+ autoHideTimeout: 200,
+ autoHideDuration: 200,
+ setScrollTop: () => {},
+ hideTracksWhenNotNeeded: false,
+ autoHeightMin: '0',
+ autoHeightMax: '100%',
+ };
+
+ private ref: React.RefObject;
+
+ constructor(props: Props) {
+ super(props);
+ this.ref = React.createRef();
+ }
+
+ updateScroll() {
+ const ref = this.ref.current;
+
+ if (ref && !_.isNil(this.props.scrollTop)) {
+ if (this.props.scrollTop > 10000) {
+ ref.scrollToBottom();
+ } else {
+ ref.scrollTop(this.props.scrollTop);
+ }
+ }
+ }
+
+ componentDidMount() {
+ this.updateScroll();
+ }
+
+ componentDidUpdate() {
+ this.updateScroll();
+ }
+
+ render() {
+ const {
+ customClassName,
+ children,
+ autoHeightMax,
+ autoHeightMin,
+ setScrollTop,
+ autoHide,
+ autoHideTimeout,
+ hideTracksWhenNotNeeded,
+ renderTrackHorizontal,
+ renderTrackVertical,
+ } = this.props;
+
+ return (
+
)}
+ renderTrackVertical={renderTrackVertical || (props =>
)}
+ renderThumbHorizontal={props =>
}
+ renderThumbVertical={props =>
}
+ renderView={props =>
}
+ >
+ {children}
+
+ );
+ }
+}
+
+export default CustomScrollbar;
diff --git a/packages/grafana-ui/src/components/CustomScrollbar/_CustomScrollbar.scss b/packages/grafana-ui/src/components/CustomScrollbar/_CustomScrollbar.scss
new file mode 100644
index 00000000000..c0a8077fb63
--- /dev/null
+++ b/packages/grafana-ui/src/components/CustomScrollbar/_CustomScrollbar.scss
@@ -0,0 +1,40 @@
+.custom-scrollbars {
+ // Fix for Firefox. For some reason sometimes .view container gets a height of its content, but in order to
+ // make scroll working it should fit outer container size (scroll appears only when inner container size is
+ // greater than outer one).
+ display: flex;
+ flex-grow: 1;
+
+ .view {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ }
+
+ .track-vertical {
+ border-radius: 3px;
+ width: 6px !important;
+ right: 2px;
+ bottom: 2px;
+ top: 2px;
+ }
+
+ .track-horizontal {
+ border-radius: 3px;
+ height: 6px !important;
+
+ right: 2px;
+ bottom: 2px;
+ left: 2px;
+ }
+
+ .thumb-vertical {
+ @include gradient-vertical($scrollbarBackground, $scrollbarBackground2);
+ border-radius: 6px;
+ }
+
+ .thumb-horizontal {
+ @include gradient-horizontal($scrollbarBackground, $scrollbarBackground2);
+ border-radius: 6px;
+ }
+}
\ No newline at end of file
diff --git a/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
similarity index 83%
rename from public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
rename to packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
index 310eb714af9..60b4a2e0aa5 100644
--- a/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
+++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
@@ -6,8 +6,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
style={
Object {
"height": "auto",
- "maxHeight": "inherit",
- "minHeight": "inherit",
+ "maxHeight": "100%",
+ "minHeight": "0",
"overflow": "hidden",
"position": "relative",
"width": "100%",
@@ -23,8 +23,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
"left": undefined,
"marginBottom": 0,
"marginRight": 0,
- "maxHeight": "calc(inherit + 0px)",
- "minHeight": "calc(inherit + 0px)",
+ "maxHeight": "calc(100% + 0px)",
+ "minHeight": "calc(0 + 0px)",
"overflow": "scroll",
"position": "relative",
"right": undefined,
@@ -42,9 +42,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
Object {
"display": "none",
"height": 6,
- "opacity": 0,
"position": "absolute",
- "transition": "opacity 200ms",
}
}
>
@@ -64,9 +62,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
style={
Object {
"display": "none",
- "opacity": 0,
"position": "absolute",
- "transition": "opacity 200ms",
"width": 6,
}
}
diff --git a/packages/grafana-ui/src/components/DeleteButton/DeleteButton.story.tsx b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.story.tsx
new file mode 100644
index 00000000000..e086663f065
--- /dev/null
+++ b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.story.tsx
@@ -0,0 +1,24 @@
+import React, { FunctionComponent } from 'react';
+import { storiesOf } from '@storybook/react';
+import { DeleteButton } from '@grafana/ui';
+
+const CenteredStory: FunctionComponent<{}> = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+storiesOf('UI/DeleteButton', module)
+ .addDecorator(story => {story()} )
+ .add('default', () => {
+ return {}} />;
+ });
diff --git a/packages/grafana-ui/src/components/FormField/FormField.test.tsx b/packages/grafana-ui/src/components/FormField/FormField.test.tsx
new file mode 100644
index 00000000000..3c89a347e86
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/FormField.test.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { FormField, Props } from './FormField';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ label: 'Test',
+ labelWidth: 11,
+ value: 10,
+ onChange: jest.fn(),
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/packages/grafana-ui/src/components/FormField/FormField.tsx b/packages/grafana-ui/src/components/FormField/FormField.tsx
new file mode 100644
index 00000000000..593678c7383
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/FormField.tsx
@@ -0,0 +1,25 @@
+import React, { InputHTMLAttributes, FunctionComponent } from 'react';
+import { FormLabel } from '..';
+
+export interface Props extends InputHTMLAttributes {
+ label: string;
+ labelWidth?: number;
+ inputWidth?: number;
+}
+
+const defaultProps = {
+ labelWidth: 6,
+ inputWidth: 12,
+};
+
+const FormField: FunctionComponent = ({ label, labelWidth, inputWidth, ...inputProps }) => {
+ return (
+
+ {label}
+
+
+ );
+};
+
+FormField.defaultProps = defaultProps;
+export { FormField };
diff --git a/packages/grafana-ui/src/components/FormField/_FormField.scss b/packages/grafana-ui/src/components/FormField/_FormField.scss
new file mode 100644
index 00000000000..36955e2fca6
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/_FormField.scss
@@ -0,0 +1,12 @@
+.form-field {
+ margin-bottom: $gf-form-margin;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ text-align: left;
+ position: relative;
+
+ &--grow {
+ flex-grow: 1;
+ }
+}
diff --git a/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap
new file mode 100644
index 00000000000..99eb0803149
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+ Test
+
+
+
+`;
diff --git a/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
new file mode 100644
index 00000000000..0cf7857b0d9
--- /dev/null
+++ b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
@@ -0,0 +1,42 @@
+import React, { FunctionComponent, ReactNode } from 'react';
+import classNames from 'classnames';
+import { Tooltip } from '../Tooltip/Tooltip';
+
+interface Props {
+ children: ReactNode;
+ className?: string;
+ htmlFor?: string;
+ isFocused?: boolean;
+ isInvalid?: boolean;
+ tooltip?: string;
+ width?: number;
+}
+
+export const FormLabel: FunctionComponent = ({
+ children,
+ isFocused,
+ isInvalid,
+ className,
+ htmlFor,
+ tooltip,
+ width,
+ ...rest
+}) => {
+ const classes = classNames(`gf-form-label width-${width ? width : '10'}`, className, {
+ 'gf-form-label--is-focused': isFocused,
+ 'gf-form-label--is-invalid': isInvalid,
+ });
+
+ return (
+
+ {children}
+ {tooltip && (
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
new file mode 100644
index 00000000000..1d2151a0627
--- /dev/null
+++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { Gauge, Props } from './Gauge';
+import { TimeSeriesVMs } from '../../types/data';
+import { ValueMapping, MappingType } from '../../types';
+
+jest.mock('jquery', () => ({
+ plot: jest.fn(),
+}));
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ maxValue: 100,
+ valueMappings: [],
+ minValue: 0,
+ prefix: '',
+ showThresholdMarkers: true,
+ showThresholdLabels: false,
+ suffix: '',
+ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }],
+ unit: 'none',
+ stat: 'avg',
+ height: 300,
+ width: 300,
+ timeSeries: {} as TimeSeriesVMs,
+ decimals: 0,
+ };
+
+ Object.assign(props, propOverrides);
+
+ const wrapper = shallow( );
+ const instance = wrapper.instance() as Gauge;
+
+ return {
+ instance,
+ wrapper,
+ };
+};
+
+describe('Get font color', () => {
+ it('should get first threshold color when only one threshold', () => {
+ const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
+
+ expect(instance.getFontColor(49)).toEqual('#7EB26D');
+ });
+
+ it('should get the threshold color if value is same as a threshold', () => {
+ const { instance } = setup({
+ thresholds: [
+ { index: 2, value: 75, color: '#6ED0E0' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ],
+ });
+
+ expect(instance.getFontColor(50)).toEqual('#EAB839');
+ });
+
+ it('should get the nearest threshold color between thresholds', () => {
+ const { instance } = setup({
+ thresholds: [
+ { index: 2, value: 75, color: '#6ED0E0' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ],
+ });
+
+ expect(instance.getFontColor(55)).toEqual('#EAB839');
+ });
+});
+
+describe('Get thresholds formatted', () => {
+ it('should return first thresholds color for min and max', () => {
+ const { instance } = setup({ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }] });
+
+ expect(instance.getFormattedThresholds()).toEqual([
+ { value: 0, color: '#7EB26D' },
+ { value: 100, color: '#7EB26D' },
+ ]);
+ });
+
+ it('should get the correct formatted values when thresholds are added', () => {
+ const { instance } = setup({
+ thresholds: [
+ { index: 2, value: 75, color: '#6ED0E0' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ],
+ });
+
+ expect(instance.getFormattedThresholds()).toEqual([
+ { value: 0, color: '#7EB26D' },
+ { value: 50, color: '#7EB26D' },
+ { value: 75, color: '#EAB839' },
+ { value: 100, color: '#6ED0E0' },
+ ]);
+ });
+});
+
+describe('Format value', () => {
+ it('should return if value isNaN', () => {
+ const valueMappings: ValueMapping[] = [];
+ const value = 'N/A';
+ const { instance } = setup({ valueMappings });
+
+ const result = instance.formatValue(value);
+
+ expect(result).toEqual('N/A');
+ });
+
+ it('should return formatted value if there are no value mappings', () => {
+ const valueMappings: ValueMapping[] = [];
+ const value = '6';
+ const { instance } = setup({ valueMappings, decimals: 1 });
+
+ const result = instance.formatValue(value);
+
+ expect(result).toEqual('6.0');
+ });
+
+ it('should return formatted value if there are no matching value mappings', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+ { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
+ ];
+ const value = '10';
+ const { instance } = setup({ valueMappings, decimals: 1 });
+
+ const result = instance.formatValue(value);
+
+ expect(result).toEqual('10.0');
+ });
+
+ it('should return mapped value if there are matching value mappings', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+ { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+ ];
+ const value = '11';
+ const { instance } = setup({ valueMappings, decimals: 1 });
+
+ const result = instance.formatValue(value);
+
+ expect(result).toEqual('1-20');
+ });
+});
diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
new file mode 100644
index 00000000000..d4d8442593e
--- /dev/null
+++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
@@ -0,0 +1,220 @@
+import React, { PureComponent } from 'react';
+import $ from 'jquery';
+
+import { ValueMapping, Threshold, BasicGaugeColor, TimeSeriesVMs, GrafanaTheme } from '../../types';
+import { getMappedValue } from '../../utils/valueMappings';
+import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
+
+type TimeSeriesValue = string | number | null;
+
+export interface Props {
+ decimals: number;
+ height: number;
+ valueMappings: ValueMapping[];
+ maxValue: number;
+ minValue: number;
+ prefix: string;
+ timeSeries: TimeSeriesVMs;
+ thresholds: Threshold[];
+ showThresholdMarkers: boolean;
+ showThresholdLabels: boolean;
+ stat: string;
+ suffix: string;
+ unit: string;
+ width: number;
+ theme?: GrafanaTheme;
+}
+
+const FONT_SCALE = 1;
+
+export class Gauge extends PureComponent {
+ canvasElement: any;
+
+ static defaultProps = {
+ maxValue: 100,
+ valueMappings: [],
+ minValue: 0,
+ prefix: '',
+ showThresholdMarkers: true,
+ showThresholdLabels: false,
+ suffix: '',
+ thresholds: [],
+ unit: 'none',
+ stat: 'avg',
+ theme: GrafanaTheme.Dark,
+ };
+
+ componentDidMount() {
+ this.draw();
+ }
+
+ componentDidUpdate() {
+ this.draw();
+ }
+
+ formatValue(value: TimeSeriesValue) {
+ const { decimals, valueMappings, prefix, suffix, unit } = this.props;
+
+ if (isNaN(value as number)) {
+ return value;
+ }
+
+ if (valueMappings.length > 0) {
+ const valueMappedValue = getMappedValue(valueMappings, value);
+ if (valueMappedValue) {
+ return `${prefix && prefix + ' '}${valueMappedValue.text}${suffix && ' ' + suffix}`;
+ }
+ }
+
+ const formatFunc = getValueFormat(unit);
+ const formattedValue = formatFunc(value as number, decimals);
+ const handleNoValueValue = formattedValue || 'no value';
+
+ return `${prefix && prefix + ' '}${handleNoValueValue}${suffix && ' ' + suffix}`;
+ }
+
+ getFontColor(value: TimeSeriesValue) {
+ const { thresholds, theme } = this.props;
+
+ if (thresholds.length === 1) {
+ return getColorFromHexRgbOrName(thresholds[0].color, theme);
+ }
+
+ const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
+ if (atThreshold) {
+ 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 getColorFromHexRgbOrName(nearestThreshold.color, theme);
+ }
+
+ return BasicGaugeColor.Red;
+ }
+
+ getFormattedThresholds() {
+ const { maxValue, minValue, thresholds, theme } = this.props;
+
+ const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
+ const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
+
+ return [
+ ...thresholdsSortedByIndex.map(threshold => {
+ if (threshold.index === 0) {
+ return { value: minValue, color: getColorFromHexRgbOrName(threshold.color, theme) };
+ }
+
+ const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
+ return { value: threshold.value, color: getColorFromHexRgbOrName(previousThreshold.color, theme) };
+ }),
+ { value: maxValue, color: getColorFromHexRgbOrName(lastThreshold.color, theme) },
+ ];
+ }
+
+ getFontScale(length: number): number {
+ if (length > 12) {
+ return FONT_SCALE - length * 5 / 120;
+ }
+ return FONT_SCALE - length * 5 / 105;
+ }
+
+ draw() {
+ const {
+ maxValue,
+ minValue,
+ timeSeries,
+ showThresholdLabels,
+ showThresholdMarkers,
+ width,
+ height,
+ stat,
+ theme,
+ } = this.props;
+
+ let value: TimeSeriesValue = '';
+
+ if (timeSeries[0]) {
+ value = timeSeries[0].stats[stat];
+ } else {
+ value = null;
+ }
+
+ const formattedValue = this.formatValue(value) as string;
+ const dimension = Math.min(width, height * 1.3);
+ const backgroundColor = theme === GrafanaTheme.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
+ const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
+ const gaugeWidth = Math.min(dimension / 6, 60) / gaugeWidthReduceRatio;
+ const thresholdMarkersWidth = gaugeWidth / 5;
+ const fontSize =
+ Math.min(dimension / 5, 100) * (formattedValue !== null ? this.getFontScale(formattedValue.length) : 1);
+ const thresholdLabelFontSize = fontSize / 2.5;
+
+ const options = {
+ series: {
+ gauges: {
+ gauge: {
+ min: minValue,
+ max: maxValue,
+ background: { color: backgroundColor },
+ border: { color: null },
+ shadow: { show: false },
+ width: gaugeWidth,
+ },
+ frame: { show: false },
+ label: { show: false },
+ layout: { margin: 0, thresholdWidth: 0 },
+ cell: { border: { width: 0 } },
+ threshold: {
+ values: this.getFormattedThresholds(),
+ label: {
+ show: showThresholdLabels,
+ margin: thresholdMarkersWidth + 1,
+ font: { size: thresholdLabelFontSize },
+ },
+ show: showThresholdMarkers,
+ width: thresholdMarkersWidth,
+ },
+ value: {
+ color: this.getFontColor(value),
+ formatter: () => {
+ return formattedValue;
+ },
+ font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
+ },
+ show: true,
+ },
+ },
+ };
+
+ const plotSeries = { data: [[0, value]] };
+
+ try {
+ $.plot(this.canvasElement, [plotSeries], options);
+ } catch (err) {
+ console.log('Gauge rendering error', err, options, timeSeries);
+ }
+ }
+
+ render() {
+ const { height, width } = this.props;
+
+ return (
+
+
(this.canvasElement = element)}
+ />
+
+ );
+ }
+}
+
+export default Gauge;
diff --git a/packages/grafana-ui/src/visualizations/Graph/Graph.tsx b/packages/grafana-ui/src/components/Graph/Graph.tsx
similarity index 98%
rename from packages/grafana-ui/src/visualizations/Graph/Graph.tsx
rename to packages/grafana-ui/src/components/Graph/Graph.tsx
index 51afb33802d..ad038cebcda 100644
--- a/packages/grafana-ui/src/visualizations/Graph/Graph.tsx
+++ b/packages/grafana-ui/src/components/Graph/Graph.tsx
@@ -98,6 +98,7 @@ export class Graph extends PureComponent
{
$.plot(this.element, timeSeries, flotOptions);
} catch (err) {
console.log('Graph rendering error', err, flotOptions, timeSeries);
+ throw new Error('Error rendering panel');
}
}
diff --git a/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx
new file mode 100644
index 00000000000..01048014f8a
--- /dev/null
+++ b/packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx
@@ -0,0 +1,11 @@
+import React, { SFC } from 'react';
+
+interface LoadingPlaceholderProps {
+ text: string;
+}
+
+export const LoadingPlaceholder: SFC = ({ text }) => (
+
+ {text}
+
+);
diff --git a/packages/grafana-ui/src/components/PanelOptionsGrid/PanelOptionsGrid.tsx b/packages/grafana-ui/src/components/PanelOptionsGrid/PanelOptionsGrid.tsx
new file mode 100644
index 00000000000..0636ec4a9da
--- /dev/null
+++ b/packages/grafana-ui/src/components/PanelOptionsGrid/PanelOptionsGrid.tsx
@@ -0,0 +1,15 @@
+import React, { SFC } from 'react';
+
+interface Props {
+ cols?: number;
+ children: JSX.Element[] | JSX.Element;
+}
+
+export const PanelOptionsGrid: SFC = ({ children }) => {
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/PanelOptionsGrid/_PanelOptionsGrid.scss b/packages/grafana-ui/src/components/PanelOptionsGrid/_PanelOptionsGrid.scss
new file mode 100644
index 00000000000..1cd26867a97
--- /dev/null
+++ b/packages/grafana-ui/src/components/PanelOptionsGrid/_PanelOptionsGrid.scss
@@ -0,0 +1,10 @@
+.panel-options-grid {
+ display: grid;
+ grid-template-columns: repeat(1, 1fr);
+ grid-row-gap: 10px;
+ grid-column-gap: 10px;
+
+ @include media-breakpoint-up(lg) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
diff --git a/public/app/features/dashboard/dashgrid/PanelOptionSection.tsx b/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx
similarity index 65%
rename from public/app/features/dashboard/dashgrid/PanelOptionSection.tsx
rename to packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx
index f38d99d2237..7ce4b8335ff 100644
--- a/public/app/features/dashboard/dashgrid/PanelOptionSection.tsx
+++ b/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx
@@ -7,11 +7,11 @@ interface Props {
children: JSX.Element | JSX.Element[];
}
-export const PanelOptionSection: SFC = props => {
+export const PanelOptionsGroup: SFC = props => {
return (
-
+
{props.title && (
-
+
{props.title}
{props.onClose && (
@@ -20,7 +20,7 @@ export const PanelOptionSection: SFC = props => {
)}
)}
-
{props.children}
+
{props.children}
);
};
diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
new file mode 100644
index 00000000000..cfc832afa98
--- /dev/null
+++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
@@ -0,0 +1,28 @@
+.panel-options-group {
+ margin-bottom: 10px;
+ border: $panel-options-group-border;
+ border-radius: $border-radius;
+ background: $page-bg;
+}
+
+.panel-options-group__header {
+ padding: 4px 8px;
+ font-size: 1.1rem;
+ background: $panel-options-group-header-bg;
+ position: relative;
+ border-radius: $border-radius $border-radius 0 0;
+
+ .btn {
+ position: absolute;
+ right: 0;
+ top: 0px;
+ }
+}
+
+.panel-options-group__body {
+ padding: 20px;
+
+ &--queries {
+ min-height: 200px;
+ }
+}
diff --git a/public/app/core/components/Portal/Portal.tsx b/packages/grafana-ui/src/components/Portal/Portal.tsx
similarity index 77%
rename from public/app/core/components/Portal/Portal.tsx
rename to packages/grafana-ui/src/components/Portal/Portal.tsx
index 25d54a64209..bc6fa37f2d0 100644
--- a/public/app/core/components/Portal/Portal.tsx
+++ b/packages/grafana-ui/src/components/Portal/Portal.tsx
@@ -6,16 +6,13 @@ interface Props {
root?: HTMLElement;
}
-export default class BodyPortal extends PureComponent
{
+export class Portal extends PureComponent {
node: HTMLElement = document.createElement('div');
portalRoot: HTMLElement;
- constructor(props) {
+ constructor(props: Props) {
super(props);
- const {
- className,
- root = document.body
- } = this.props;
+ const { className, root = document.body } = this.props;
if (className) {
this.node.classList.add(className);
diff --git a/public/app/core/components/Select/IndicatorsContainer.tsx b/packages/grafana-ui/src/components/Select/IndicatorsContainer.tsx
similarity index 73%
rename from public/app/core/components/Select/IndicatorsContainer.tsx
rename to packages/grafana-ui/src/components/Select/IndicatorsContainer.tsx
index d4de51a2cef..260fe6ebbdf 100644
--- a/public/app/core/components/Select/IndicatorsContainer.tsx
+++ b/packages/grafana-ui/src/components/Select/IndicatorsContainer.tsx
@@ -1,7 +1,10 @@
import React from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
import { components } from '@torkelo/react-select';
-export const IndicatorsContainer = props => {
+export const IndicatorsContainer = (props: any) => {
const isOpen = props.selectProps.menuIsOpen;
return (
diff --git a/public/app/core/components/Select/NoOptionsMessage.tsx b/packages/grafana-ui/src/components/Select/NoOptionsMessage.tsx
similarity index 83%
rename from public/app/core/components/Select/NoOptionsMessage.tsx
rename to packages/grafana-ui/src/components/Select/NoOptionsMessage.tsx
index 5fe229340a4..1cec06a5301 100644
--- a/public/app/core/components/Select/NoOptionsMessage.tsx
+++ b/packages/grafana-ui/src/components/Select/NoOptionsMessage.tsx
@@ -1,5 +1,9 @@
import React from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
import { components } from '@torkelo/react-select';
+// @ts-ignore
import { OptionProps } from '@torkelo/react-select/lib/components/Option';
export interface Props {
diff --git a/public/app/core/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx
similarity index 89%
rename from public/app/core/components/Select/Select.tsx
rename to packages/grafana-ui/src/components/Select/Select.tsx
index 893eb1a6655..6d83968d546 100644
--- a/public/app/core/components/Select/Select.tsx
+++ b/packages/grafana-ui/src/components/Select/Select.tsx
@@ -1,17 +1,22 @@
// Libraries
import classNames from 'classnames';
import React, { PureComponent } from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
import { default as ReactSelect } from '@torkelo/react-select';
+// @ts-ignore
import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async';
+// @ts-ignore
import { components } from '@torkelo/react-select';
// Components
-import { Option, SingleValue } from './PickerOption';
-import OptionGroup from './OptionGroup';
+import { SelectOption, SingleValue } from './SelectOption';
+import SelectOptionGroup from './SelectOptionGroup';
import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage';
-import ResetStyles from './ResetStyles';
-import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
+import resetSelectStyles from './resetSelectStyles';
+import { CustomScrollbar } from '..';
export interface SelectOptionItem {
label?: string;
@@ -53,10 +58,10 @@ interface AsyncProps {
loadingMessage?: () => string;
}
-export const MenuList = props => {
+export const MenuList = (props: any) => {
return (
- {props.children}
+ {props.children}
);
};
@@ -112,11 +117,11 @@ export class Select extends PureComponent {
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
- Option,
+ Option: SelectOption,
SingleValue,
IndicatorsContainer,
MenuList,
- Group: OptionGroup,
+ Group: SelectOptionGroup,
}}
defaultValue={defaultValue}
value={value}
@@ -127,7 +132,7 @@ export class Select extends PureComponent {
onChange={onChange}
options={options}
placeholder={placeholder || 'Choose'}
- styles={ResetStyles}
+ styles={resetSelectStyles()}
isDisabled={isDisabled}
isLoading={isLoading}
isClearable={isClearable}
@@ -197,7 +202,7 @@ export class AsyncSelect extends PureComponent {
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
- Option,
+ Option: SelectOption,
SingleValue,
IndicatorsContainer,
NoOptionsMessage,
@@ -212,7 +217,7 @@ export class AsyncSelect extends PureComponent {
isLoading={isLoading}
defaultOptions={defaultOptions}
placeholder={placeholder || 'Choose'}
- styles={ResetStyles}
+ styles={resetSelectStyles()}
loadingMessage={loadingMessage}
noOptionsMessage={noOptionsMessage}
isDisabled={isDisabled}
diff --git a/public/app/core/components/Select/PickerOption.test.tsx b/packages/grafana-ui/src/components/Select/SelectOption.test.tsx
similarity index 50%
rename from public/app/core/components/Select/PickerOption.test.tsx
rename to packages/grafana-ui/src/components/Select/SelectOption.test.tsx
index 6b4aedcfcc0..0a5a8864d64 100644
--- a/public/app/core/components/Select/PickerOption.test.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectOption.test.tsx
@@ -1,11 +1,13 @@
import React from 'react';
import renderer from 'react-test-renderer';
-import PickerOption from './PickerOption';
+import SelectOption from './SelectOption';
+import { OptionProps } from 'react-select/lib/components/Option';
-const model = {
+// @ts-ignore
+const model: OptionProps = {
+ data: jest.fn(),
cx: jest.fn(),
clearValue: jest.fn(),
- onSelect: jest.fn(),
getStyles: jest.fn(),
getValue: jest.fn(),
hasValue: true,
@@ -18,21 +20,31 @@ const model = {
isFocused: false,
isSelected: false,
innerRef: null,
- innerProps: null,
- label: 'Option label',
- type: null,
- children: 'Model title',
- data: {
- title: 'Model title',
- imgUrl: 'url/to/avatar',
- label: 'User picker label',
+ innerProps: {
+ id: '',
+ key: '',
+ onClick: jest.fn(),
+ onMouseOver: jest.fn(),
+ tabIndex: 1,
},
+ label: 'Option label',
+ type: 'option',
+ children: 'Model title',
className: 'class-for-user-picker',
};
-describe('PickerOption', () => {
+describe('SelectOption', () => {
it('renders correctly', () => {
- const tree = renderer.create( ).toJSON();
+ const tree = renderer
+ .create(
+
+ )
+ .toJSON();
expect(tree).toMatchSnapshot();
});
});
diff --git a/public/app/core/components/Select/PickerOption.tsx b/packages/grafana-ui/src/components/Select/SelectOption.tsx
similarity index 84%
rename from public/app/core/components/Select/PickerOption.tsx
rename to packages/grafana-ui/src/components/Select/SelectOption.tsx
index d263f6f832b..5f94c2182f9 100644
--- a/public/app/core/components/Select/PickerOption.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectOption.tsx
@@ -1,4 +1,7 @@
import React from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
import { components } from '@torkelo/react-select';
import { OptionProps } from 'react-select/lib/components/Option';
@@ -10,7 +13,7 @@ interface ExtendedOptionProps extends OptionProps {
};
}
-export const Option = (props: ExtendedOptionProps) => {
+export const SelectOption = (props: ExtendedOptionProps) => {
const { children, isSelected, data } = props;
return (
@@ -28,7 +31,7 @@ export const Option = (props: ExtendedOptionProps) => {
};
// was not able to type this without typescript error
-export const SingleValue = props => {
+export const SingleValue = (props: any) => {
const { children, data } = props;
return (
@@ -41,4 +44,4 @@ export const SingleValue = props => {
);
};
-export default Option;
+export default SelectOption;
diff --git a/public/app/core/components/Select/OptionGroup.tsx b/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx
similarity index 72%
rename from public/app/core/components/Select/OptionGroup.tsx
rename to packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx
index a001f58c681..efc5e4516fc 100644
--- a/public/app/core/components/Select/OptionGroup.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx
@@ -2,21 +2,27 @@ import React, { PureComponent } from 'react';
import { GroupProps } from 'react-select/lib/components/Group';
interface ExtendedGroupProps extends GroupProps {
- data: any;
+ data: {
+ label: string;
+ expanded: boolean;
+ options: any[];
+ };
}
interface State {
expanded: boolean;
}
-export default class OptionGroup extends PureComponent {
+export default class SelectOptionGroup extends PureComponent {
state = {
expanded: false,
};
componentDidMount() {
- if (this.props.selectProps) {
- const value = this.props.selectProps.value[this.props.selectProps.value.length - 1];
+ if (this.props.data.expanded) {
+ this.setState({ expanded: true });
+ } else if (this.props.selectProps && this.props.selectProps.value) {
+ const { value } = this.props.selectProps.value;
if (value && this.props.options.some(option => option.value === value)) {
this.setState({ expanded: true });
@@ -24,7 +30,7 @@ export default class OptionGroup extends PureComponent div {
display: inline-block;
}
diff --git a/public/app/core/components/Select/__snapshots__/PickerOption.test.tsx.snap b/packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap
similarity index 71%
rename from public/app/core/components/Select/__snapshots__/PickerOption.test.tsx.snap
rename to packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap
index 2136c22b1c6..c52be902edd 100644
--- a/public/app/core/components/Select/__snapshots__/PickerOption.test.tsx.snap
+++ b/packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap
@@ -1,7 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`PickerOption renders correctly 1`] = `
-
+exports[`SelectOption renders correctly 1`] = `
+
diff --git a/packages/grafana-ui/src/components/Select/resetSelectStyles.ts b/packages/grafana-ui/src/components/Select/resetSelectStyles.ts
new file mode 100644
index 00000000000..a980741c17c
--- /dev/null
+++ b/packages/grafana-ui/src/components/Select/resetSelectStyles.ts
@@ -0,0 +1,27 @@
+export default function resetSelectStyles() {
+ return {
+ clearIndicator: () => ({}),
+ container: () => ({}),
+ control: () => ({}),
+ dropdownIndicator: () => ({}),
+ group: () => ({}),
+ groupHeading: () => ({}),
+ indicatorsContainer: () => ({}),
+ indicatorSeparator: () => ({}),
+ input: () => ({}),
+ loadingIndicator: () => ({}),
+ loadingMessage: () => ({}),
+ menu: () => ({}),
+ menuList: ({ maxHeight }: { maxHeight: number }) => ({
+ maxHeight,
+ }),
+ multiValue: () => ({}),
+ multiValueLabel: () => ({}),
+ multiValueRemove: () => ({}),
+ noOptionsMessage: () => ({}),
+ option: () => ({}),
+ placeholder: () => ({}),
+ singleValue: () => ({}),
+ valueContainer: () => ({}),
+ };
+}
diff --git a/public/app/core/components/Switch/Switch.tsx b/packages/grafana-ui/src/components/Switch/Switch.tsx
similarity index 78%
rename from public/app/core/components/Switch/Switch.tsx
rename to packages/grafana-ui/src/components/Switch/Switch.tsx
index d53a3d68878..feee58386b8 100644
--- a/public/app/core/components/Switch/Switch.tsx
+++ b/packages/grafana-ui/src/components/Switch/Switch.tsx
@@ -4,10 +4,11 @@ import _ from 'lodash';
export interface Props {
label: string;
checked: boolean;
+ className?: string;
labelClass?: string;
switchClass?: string;
transparent?: boolean;
- onChange: (event) => any;
+ onChange: (event?: React.SyntheticEvent
) => void;
}
export interface State {
@@ -19,20 +20,21 @@ export class Switch extends PureComponent {
id: _.uniqueId(),
};
- internalOnChange = event => {
+ internalOnChange = (event: React.FormEvent) => {
event.stopPropagation();
+
this.props.onChange(event);
};
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 && {label}
}
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
new file mode 100644
index 00000000000..845ff5f6bf4
--- /dev/null
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
@@ -0,0 +1,173 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { ThresholdsEditor, Props } from './ThresholdsEditor';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ onChange: jest.fn(),
+ thresholds: [],
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow(
).instance() as ThresholdsEditor;
+};
+
+describe('Initialization', () => {
+ it('should add a base threshold if missing', () => {
+ const instance = setup();
+
+ expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+ });
+});
+
+describe('Add threshold', () => {
+ it('should not add threshold at index 0', () => {
+ const instance = setup();
+
+ instance.onAddThreshold(0);
+
+ expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]);
+ });
+
+ it('should add threshold', () => {
+ const instance = setup();
+
+ instance.onAddThreshold(1);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ]);
+ });
+
+ it('should add another threshold above a first', () => {
+ const instance = setup({
+ thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }],
+ });
+
+ instance.onAddThreshold(2);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 2, value: 75, color: '#6ED0E0' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ]);
+ });
+
+ it('should add another threshold between first and second index', () => {
+ const instance = setup({
+ thresholds: [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ],
+ });
+
+ instance.onAddThreshold(2);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 3, value: 75, color: '#6ED0E0' },
+ { index: 2, value: 62.5, color: '#EF843C' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ]);
+ });
+});
+
+describe('Remove threshold', () => {
+ it('should not remove threshold at index 0', () => {
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+ const instance = setup({ thresholds });
+
+ instance.onRemoveThreshold(thresholds[0]);
+
+ expect(instance.state.thresholds).toEqual(thresholds);
+ });
+
+ it('should remove threshold', () => {
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+ const instance = setup({
+ thresholds,
+ });
+
+ instance.onRemoveThreshold(thresholds[1]);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 75, color: '#6ED0E0' },
+ ]);
+ });
+});
+
+describe('change threshold value', () => {
+ it('should not change threshold at index 0', () => {
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+ const instance = setup({ thresholds });
+
+ const mockEvent = { target: { value: 12 } };
+
+ instance.onChangeThresholdValue(mockEvent, thresholds[0]);
+
+ expect(instance.state.thresholds).toEqual(thresholds);
+ });
+
+ it('should update value', () => {
+ const instance = setup();
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 50, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+
+ instance.state = {
+ thresholds,
+ };
+
+ const mockEvent = { target: { value: 78 } };
+
+ instance.onChangeThresholdValue(mockEvent, thresholds[1]);
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 78, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ]);
+ });
+});
+
+describe('on blur threshold value', () => {
+ it('should resort rows and update indexes', () => {
+ const instance = setup();
+ const thresholds = [
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ { index: 1, value: 78, color: '#EAB839' },
+ { index: 2, value: 75, color: '#6ED0E0' },
+ ];
+
+ instance.state = {
+ thresholds,
+ };
+
+ instance.onBlur();
+
+ expect(instance.state.thresholds).toEqual([
+ { index: 2, value: 78, color: '#EAB839' },
+ { index: 1, value: 75, color: '#6ED0E0' },
+ { index: 0, value: -Infinity, color: '#7EB26D' },
+ ]);
+ });
+});
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
new file mode 100644
index 00000000000..c15f66cca54
--- /dev/null
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
@@ -0,0 +1,214 @@
+import React, { PureComponent } from 'react';
+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 extends Themeable {
+ thresholds: Threshold[];
+ onChange: (thresholds: Threshold[]) => void;
+}
+
+interface State {
+ thresholds: Threshold[];
+}
+
+export class ThresholdsEditor extends PureComponent
{
+ constructor(props: Props) {
+ super(props);
+
+ const addDefaultThreshold = this.props.thresholds.length === 0;
+ const thresholds: Threshold[] = addDefaultThreshold
+ ? [{ index: 0, value: -Infinity, color: colors[0] }]
+ : props.thresholds;
+ this.state = { thresholds };
+
+ if (addDefaultThreshold) {
+ this.onChange();
+ }
+ }
+
+ onAddThreshold = (index: number) => {
+ const { thresholds } = this.state;
+ const maxValue = 100;
+ const minValue = 0;
+
+ if (index === 0) {
+ return;
+ }
+
+ const newThresholds = thresholds.map(threshold => {
+ if (threshold.index >= index) {
+ const index = threshold.index + 1;
+ threshold = { ...threshold, index };
+ }
+ return threshold;
+ });
+
+ // Setting value to a value between the previous thresholds
+ const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0];
+ const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0];
+ const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue;
+ const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue;
+ const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2;
+
+ // Set a color
+ const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0];
+
+ this.setState(
+ {
+ thresholds: this.sortThresholds([
+ ...newThresholds,
+ {
+ index,
+ value: value as number,
+ color,
+ },
+ ]),
+ },
+ () => this.onChange()
+ );
+ };
+
+ onRemoveThreshold = (threshold: Threshold) => {
+ if (threshold.index === 0) {
+ return;
+ }
+
+ this.setState(
+ prevState => {
+ const newThresholds = prevState.thresholds.map(t => {
+ if (t.index > threshold.index) {
+ const index = t.index - 1;
+ t = { ...t, index };
+ }
+ return t;
+ });
+
+ return {
+ thresholds: newThresholds.filter(t => t !== threshold),
+ };
+ },
+ () => this.onChange()
+ );
+ };
+
+ onChangeThresholdValue = (event: any, threshold: Threshold) => {
+ if (threshold.index === 0) {
+ return;
+ }
+
+ const { thresholds } = this.state;
+ const parsedValue = parseInt(event.target.value, 10);
+ const value = isNaN(parsedValue) ? null : parsedValue;
+
+ const newThresholds = thresholds.map(t => {
+ if (t === threshold && t.index !== 0) {
+ t = { ...t, value: value as number };
+ }
+
+ return t;
+ });
+
+ this.setState({ thresholds: newThresholds });
+ };
+
+ onChangeThresholdColor = (threshold: Threshold, color: string) => {
+ const { thresholds } = this.state;
+
+ const newThresholds = thresholds.map(t => {
+ if (t === threshold) {
+ t = { ...t, color: color };
+ }
+
+ return t;
+ });
+
+ this.setState(
+ {
+ thresholds: newThresholds,
+ },
+ () => this.onChange()
+ );
+ };
+
+ onBlur = () => {
+ this.setState(prevState => {
+ const sortThresholds = this.sortThresholds([...prevState.thresholds]);
+ let index = sortThresholds.length - 1;
+ sortThresholds.forEach(t => {
+ t.index = index--;
+ });
+ return { thresholds: sortThresholds };
+ });
+
+ this.onChange();
+ };
+
+ onChange = () => {
+ this.props.onChange(this.state.thresholds);
+ };
+
+ sortThresholds = (thresholds: Threshold[]) => {
+ return thresholds.sort((t1, t2) => {
+ return t2.value - t1.value;
+ });
+ };
+
+ renderInput = (threshold: Threshold) => {
+ const value = threshold.index === 0 ? 'Base' : threshold.value;
+ return (
+
+
+
+ {threshold.color && (
+
+ this.onChangeThresholdColor(threshold, color)} />
+
+ )}
+
+
+ this.onChangeThresholdValue(event, threshold)}
+ value={value}
+ onBlur={this.onBlur}
+ readOnly={threshold.index === 0}
+ />
+
+ {threshold.index > 0 && (
+
this.onRemoveThreshold(threshold)}>
+
+
+ )}
+
+ );
+ };
+
+ render() {
+ const { thresholds } = this.state;
+ const { theme } = this.props;
+
+ return (
+
+
+ {thresholds.map((threshold, index) => {
+ return (
+
+
this.onAddThreshold(threshold.index + 1)}>
+
+
+
+
{this.renderInput(threshold)}
+
+ );
+ })}
+
+
+ );
+ }
+}
diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
new file mode 100644
index 00000000000..61278321572
--- /dev/null
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
@@ -0,0 +1,105 @@
+.thresholds {
+ margin-bottom: 10px;
+}
+
+.thresholds-row {
+ display: flex;
+ flex-direction: row;
+ height: 70px;
+}
+
+.thresholds-row:first-child > .thresholds-row-color-indicator {
+ border-top-left-radius: $border-radius;
+ border-top-right-radius: $border-radius;
+ overflow: hidden;
+}
+
+.thresholds-row:last-child > .thresholds-row-color-indicator {
+ border-bottom-left-radius: $border-radius;
+ border-bottom-right-radius: $border-radius;
+ overflow: hidden;
+}
+
+.thresholds-row-add-button {
+ align-self: center;
+ margin-right: 5px;
+ color: $green;
+ height: 24px;
+ width: 24px;
+ background-color: $green;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.thresholds-row-add-button > i {
+ color: $white;
+}
+
+.thresholds-row-color-indicator {
+ width: 10px;
+}
+
+.thresholds-row-input {
+ margin-top: 49px;
+ margin-left: 2px;
+}
+
+.thresholds-row-input-inner {
+ display: flex;
+ justify-content: center;
+ flex-direction: row;
+}
+
+.thresholds-row-input-inner > *:last-child {
+ border-top-right-radius: $border-radius;
+ border-bottom-right-radius: $border-radius;
+}
+
+.thresholds-row-input-inner-arrow {
+ align-self: center;
+ width: 0;
+ height: 0;
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-right: 6px solid $input-label-border-color;
+}
+
+.thresholds-row-input-inner-value > input {
+ height: $gf-form-input-height;
+ padding: $input-padding-y $input-padding-x;
+ width: 150px;
+ border-top: 1px solid $input-label-border-color;
+ border-bottom: 1px solid $input-label-border-color;
+}
+
+.thresholds-row-input-inner-color {
+ width: 42px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: $input-bg;
+ border: 1px solid $input-label-border-color;
+}
+
+.thresholds-row-input-inner-color-colorpicker {
+ border-radius: 10px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
+}
+
+.thresholds-row-input-inner-remove {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: $gf-form-input-height;
+ padding: $input-padding-y $input-padding-x;
+ width: 42px;
+ background-color: $input-label-bg;
+ border: 1px solid $input-label-border-color;
+ cursor: pointer;
+}
diff --git a/packages/grafana-ui/src/components/Tooltip/Popper.tsx b/packages/grafana-ui/src/components/Tooltip/Popper.tsx
new file mode 100644
index 00000000000..93a896d97ed
--- /dev/null
+++ b/packages/grafana-ui/src/components/Tooltip/Popper.tsx
@@ -0,0 +1,92 @@
+import React, { PureComponent } from 'react';
+import * as PopperJS from 'popper.js';
+import { Manager, Popper as ReactPopper, PopperArrowProps } from 'react-popper';
+import { Portal } from '@grafana/ui';
+import Transition from 'react-transition-group/Transition';
+import { PopperContent } from './PopperController';
+
+const defaultTransitionStyles = {
+ transition: 'opacity 200ms linear',
+ opacity: 0,
+};
+
+const transitionStyles: { [key: string]: object } = {
+ exited: { opacity: 0 },
+ entering: { opacity: 0 },
+ entered: { opacity: 1, transitionDelay: '0s' },
+ exiting: { opacity: 0, transitionDelay: '500ms' },
+};
+
+export type RenderPopperArrowFn = (
+ props: {
+ arrowProps: PopperArrowProps;
+ placement: string;
+ }
+) => JSX.Element;
+
+interface Props extends React.HTMLAttributes {
+ show: boolean;
+ placement?: PopperJS.Placement;
+ content: PopperContent;
+ referenceElement: PopperJS.ReferenceObject;
+ wrapperClassName?: string;
+ renderArrow?: RenderPopperArrowFn;
+}
+
+class Popper extends PureComponent {
+ render() {
+ const { show, placement, onMouseEnter, onMouseLeave, className, wrapperClassName, renderArrow } = this.props;
+ const { content } = this.props;
+
+ return (
+
+
+ {transitionState => {
+ return (
+
+
+ {({ ref, style, placement, arrowProps, scheduleUpdate }) => {
+ return (
+
+
+ {typeof content === 'string'
+ ? content
+ : React.cloneElement(content, {
+ updatePopperPosition: scheduleUpdate,
+ })}
+ {renderArrow &&
+ renderArrow({
+ arrowProps,
+ placement,
+ })}
+
+
+ );
+ }}
+
+
+ );
+ }}
+
+
+ );
+ }
+}
+
+export default Popper;
diff --git a/packages/grafana-ui/src/components/Tooltip/PopperController.tsx b/packages/grafana-ui/src/components/Tooltip/PopperController.tsx
new file mode 100644
index 00000000000..770d1ce9f37
--- /dev/null
+++ b/packages/grafana-ui/src/components/Tooltip/PopperController.tsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import * as PopperJS from 'popper.js';
+
+// 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 = string | React.ReactElement;
+
+export interface UsingPopperProps {
+ show?: boolean;
+ placement?: PopperJS.Placement;
+ content: PopperContent;
+ children: JSX.Element;
+}
+
+type PopperControllerRenderProp = (
+ showPopper: () => void,
+ hidePopper: () => void,
+ popperProps: {
+ show: boolean;
+ placement: PopperJS.Placement;
+ content: PopperContent;
+ }
+) => JSX.Element;
+
+interface Props {
+ placement?: PopperJS.Placement;
+ content: PopperContent;
+ className?: string;
+ children: PopperControllerRenderProp;
+ hideAfter?: number;
+}
+
+interface State {
+ placement: PopperJS.Placement;
+ show: boolean;
+}
+
+class PopperController extends React.Component {
+ private hideTimeout: any;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ placement: this.props.placement || 'auto',
+ show: false,
+ };
+ }
+
+ componentWillReceiveProps(nextProps: Props) {
+ if (nextProps.placement && nextProps.placement !== this.state.placement) {
+ this.setState((prevState: State) => {
+ return {
+ ...prevState,
+ placement: nextProps.placement || 'auto',
+ };
+ });
+ }
+ }
+
+ showPopper = () => {
+ if (this.hideTimeout) {
+ clearTimeout(this.hideTimeout);
+ }
+
+ this.setState(prevState => ({
+ ...prevState,
+ show: true,
+ }));
+ };
+
+ 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,
+ }));
+ };
+
+ render() {
+ const { children, content } = this.props;
+ const { show, placement } = this.state;
+
+ return children(this.showPopper, this.hidePopper, {
+ show,
+ placement,
+ content,
+ });
+ }
+}
+
+export default PopperController;
diff --git a/public/app/core/components/Tooltip/Tooltip.test.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx
similarity index 56%
rename from public/app/core/components/Tooltip/Tooltip.test.tsx
rename to packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx
index d2c96bb23d2..95d01c7f2fe 100644
--- a/public/app/core/components/Tooltip/Tooltip.test.tsx
+++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx
@@ -1,13 +1,15 @@
import React from 'react';
import renderer from 'react-test-renderer';
-import Tooltip from './Tooltip';
+import { Tooltip } from './Tooltip';
describe('Tooltip', () => {
it('renders correctly', () => {
const tree = renderer
.create(
-
- Link with tooltip
+
+
+ Link with tooltip
+
)
.toJSON();
diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
new file mode 100644
index 00000000000..9acc166ad5d
--- /dev/null
+++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
@@ -0,0 +1,41 @@
+import React, { createRef } from 'react';
+import * as PopperJS from 'popper.js';
+import Popper from './Popper';
+import PopperController, { UsingPopperProps } from './PopperController';
+
+interface TooltipProps extends UsingPopperProps {
+ theme?: 'info' | 'error';
+}
+export const Tooltip = ({ children, theme, ...controllerProps }: TooltipProps) => {
+ const tooltipTriggerRef = createRef();
+ const popperBackgroundClassName = 'popper__background' + (theme ? ' popper__background--' + theme : '');
+
+ return (
+
+ {(showPopper, hidePopper, popperProps) => {
+ return (
+ <>
+ {tooltipTriggerRef.current && (
+ (
+
+ )}
+ />
+ )}
+ {React.cloneElement(children, {
+ ref: tooltipTriggerRef,
+ onMouseEnter: showPopper,
+ onMouseLeave: hidePopper,
+ })}
+ >
+ );
+ }}
+
+ );
+};
diff --git a/public/sass/components/_popper.scss b/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
similarity index 80%
rename from public/sass/components/_popper.scss
rename to packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
index d869d52b92f..503b977bbf0 100644
--- a/public/sass/components/_popper.scss
+++ b/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
@@ -1,5 +1,14 @@
$popper-margin-from-ref: 5px;
+@mixin popper-theme($backgroundColor, $textColor) {
+ background: $backgroundColor;
+ color: $textColor;
+
+ .popper__arrow {
+ border-color: $backgroundColor;
+ }
+}
+
.popper {
position: absolute;
z-index: $zindex-tooltip;
@@ -8,7 +17,29 @@ $popper-margin-from-ref: 5px;
text-align: center;
}
-.popper .popper__arrow {
+.popper__background {
+ background: $tooltipBackground;
+ border-radius: $border-radius-sm;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
+ padding: 6px 10px;
+ color: $tooltipColor;
+ font-weight: 500;
+
+ .popper__arrow {
+ border-color: $tooltipBackground;
+ }
+
+ // Themes
+ &.popper__background--error {
+ @include popper-theme($tooltipBackgroundError, $tooltipBackgroundError);
+ }
+
+ &.popper__background--info {
+ @include popper-theme($popover-help-bg, $popover-help-color);
+ }
+}
+
+.popper__arrow {
width: 0;
height: 0;
border-style: solid;
@@ -16,17 +47,6 @@ $popper-margin-from-ref: 5px;
margin: 0px;
}
-.popper .popper__arrow {
- border-color: $tooltipBackground;
-}
-
-.popper__background {
- background: $tooltipBackground;
- border-radius: $border-radius;
- box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
- padding: 10px;
-}
-
// Top
.popper[data-placement^='top'] {
padding-bottom: $popper-margin-from-ref;
diff --git a/packages/grafana-ui/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap b/packages/grafana-ui/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
new file mode 100644
index 00000000000..761221906d4
--- /dev/null
+++ b/packages/grafana-ui/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
@@ -0,0 +1,12 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tooltip renders correctly 1`] = `
+
+ Link with tooltip
+
+`;
diff --git a/public/app/plugins/panel/gauge/MappingRow.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
similarity index 51%
rename from public/app/plugins/panel/gauge/MappingRow.tsx
rename to packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
index 35d0b2e638c..c5704e8bc88 100644
--- a/public/app/plugins/panel/gauge/MappingRow.tsx
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
@@ -1,22 +1,22 @@
-import React, { PureComponent } from 'react';
-import { Label } from 'app/core/components/Label/Label';
-import { Select } from 'app/core/components/Select/Select';
-import { MappingType, RangeMap, ValueMap } from 'app/types';
+import React, { ChangeEvent, PureComponent } from 'react';
-interface Props {
- mapping: ValueMap | RangeMap;
- updateMapping: (mapping) => void;
- removeMapping: () => void;
+import { MappingType, ValueMapping } from '../../types';
+import { FormField, FormLabel, Select } from '..';
+
+export interface Props {
+ valueMapping: ValueMapping;
+ updateValueMapping: (valueMapping: ValueMapping) => void;
+ removeValueMapping: () => void;
}
interface State {
- from: string;
+ from?: string;
id: number;
operator: string;
text: string;
- to: string;
+ to?: string;
type: MappingType;
- value: string;
+ value?: string;
}
const mappingOptions = [
@@ -25,36 +25,34 @@ const mappingOptions = [
];
export default class MappingRow extends PureComponent {
- constructor(props) {
+ constructor(props: Props) {
super(props);
- this.state = {
- ...props.mapping,
- };
+ this.state = { ...props.valueMapping };
}
- onMappingValueChange = event => {
+ onMappingValueChange = (event: ChangeEvent) => {
this.setState({ value: event.target.value });
};
- onMappingFromChange = event => {
+ onMappingFromChange = (event: ChangeEvent) => {
this.setState({ from: event.target.value });
};
- onMappingToChange = event => {
+ onMappingToChange = (event: ChangeEvent) => {
this.setState({ to: event.target.value });
};
- onMappingTextChange = event => {
+ onMappingTextChange = (event: ChangeEvent) => {
this.setState({ text: event.target.value });
};
- onMappingTypeChange = mappingType => {
+ onMappingTypeChange = (mappingType: MappingType) => {
this.setState({ type: mappingType });
};
updateMapping = () => {
- this.props.updateMapping({ ...this.state });
+ this.props.updateValueMapping({ ...this.state } as ValueMapping);
};
renderRow() {
@@ -63,30 +61,28 @@ export default class MappingRow extends PureComponent {
if (type === MappingType.RangeToText) {
return (
<>
-
-
From
+
+
+
+ Text
-
-
- To
-
-
-
- Text
-
@@ -96,17 +92,16 @@ export default class MappingRow extends PureComponent
{
return (
<>
-
- Value
-
-
+
-
Text
+
Text
{
return (
- Type
+ Type
{
{this.renderRow()}
-
+
diff --git a/public/app/plugins/panel/gauge/ValueMappings.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
similarity index 61%
rename from public/app/plugins/panel/gauge/ValueMappings.test.tsx
rename to packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
index fd9f56343b1..bbad3e5a7ca 100644
--- a/public/app/plugins/panel/gauge/ValueMappings.test.tsx
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx
@@ -1,26 +1,23 @@
import React from 'react';
import { shallow } from 'enzyme';
-import ValueMappings from './ValueMappings';
-import { defaultProps, OptionModuleProps } from './module';
-import { MappingType } from 'app/types';
+
+import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
+import { MappingType } from '../../types/panel';
const setup = (propOverrides?: object) => {
- const props: OptionModuleProps = {
+ const props: Props = {
onChange: jest.fn(),
- options: {
- ...defaultProps.options,
- mappings: [
- { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
- { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
- ],
- },
+ valueMappings: [
+ { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
+ { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
+ ],
};
Object.assign(props, propOverrides);
- const wrapper = shallow(
);
+ const wrapper = shallow(
);
- const instance = wrapper.instance() as ValueMappings;
+ const instance = wrapper.instance() as ValueMappingsEditor;
return {
instance,
@@ -39,18 +36,20 @@ describe('Render', () => {
describe('On remove mapping', () => {
it('Should remove mapping with id 0', () => {
const { instance } = setup();
+
instance.onRemoveMapping(1);
- expect(instance.state.mappings).toEqual([
+ expect(instance.state.valueMappings).toEqual([
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
]);
});
it('should remove mapping with id 1', () => {
const { instance } = setup();
+
instance.onRemoveMapping(2);
- expect(instance.state.mappings).toEqual([
+ expect(instance.state.valueMappings).toEqual([
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
]);
});
@@ -66,7 +65,7 @@ describe('Next id to add', () => {
});
it('should default to 1', () => {
- const { instance } = setup({ options: { ...defaultProps.options } });
+ const { instance } = setup({ valueMappings: [] });
expect(instance.state.nextIdToAdd).toEqual(1);
});
diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
new file mode 100644
index 00000000000..ca0a6e71f4a
--- /dev/null
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx
@@ -0,0 +1,105 @@
+import React, { PureComponent } from 'react';
+
+import MappingRow from './MappingRow';
+import { MappingType, ValueMapping } from '../../types/panel';
+import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+
+export interface Props {
+ valueMappings: ValueMapping[];
+ onChange: (valueMappings: ValueMapping[]) => void;
+}
+
+interface State {
+ valueMappings: ValueMapping[];
+ nextIdToAdd: number;
+}
+
+export class ValueMappingsEditor extends PureComponent
{
+ constructor(props: Props) {
+ super(props);
+
+ const mappings = props.valueMappings;
+
+ this.state = {
+ valueMappings: mappings,
+ nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromValueMappings(mappings) : 1,
+ };
+ }
+
+ getMaxIdFromValueMappings(mappings: ValueMapping[]) {
+ return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
+ }
+
+ addMapping = () =>
+ this.setState(prevState => ({
+ valueMappings: [
+ ...prevState.valueMappings,
+ {
+ id: prevState.nextIdToAdd,
+ operator: '',
+ value: '',
+ text: '',
+ type: MappingType.ValueToText,
+ from: '',
+ to: '',
+ },
+ ],
+ nextIdToAdd: prevState.nextIdToAdd + 1,
+ }));
+
+ onRemoveMapping = (id: number) => {
+ this.setState(
+ prevState => ({
+ valueMappings: prevState.valueMappings.filter(m => {
+ return m.id !== id;
+ }),
+ }),
+ () => {
+ this.props.onChange(this.state.valueMappings);
+ }
+ );
+ };
+
+ updateGauge = (mapping: ValueMapping) => {
+ this.setState(
+ prevState => ({
+ valueMappings: prevState.valueMappings.map(m => {
+ if (m.id === mapping.id) {
+ return { ...mapping };
+ }
+
+ return m;
+ }),
+ }),
+ () => {
+ this.props.onChange(this.state.valueMappings);
+ }
+ );
+ };
+
+ render() {
+ const { valueMappings } = this.state;
+
+ return (
+
+
+ {valueMappings.length > 0 &&
+ valueMappings.map((valueMapping, index) => (
+ this.onRemoveMapping(valueMapping.id)}
+ />
+ ))}
+
+
+
+ );
+ }
+}
diff --git a/public/sass/components/_value-mappings.scss b/packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss
similarity index 100%
rename from public/sass/components/_value-mappings.scss
rename to packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss
diff --git a/public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
similarity index 73%
rename from public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap
rename to packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
index 8a05cb7e91b..8a465ff88df 100644
--- a/public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap
+++ b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap
@@ -1,18 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
-
+
`;
diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss
index d52508c946c..4f5cbf9a51b 100644
--- a/packages/grafana-ui/src/components/index.scss
+++ b/packages/grafana-ui/src/components/index.scss
@@ -1 +1,10 @@
+@import 'CustomScrollbar/CustomScrollbar';
@import 'DeleteButton/DeleteButton';
+@import 'ThresholdsEditor/ThresholdsEditor';
+@import 'Tooltip/Tooltip';
+@import 'Select/Select';
+@import 'PanelOptionsGroup/PanelOptionsGroup';
+@import 'PanelOptionsGrid/PanelOptionsGrid';
+@import 'ColorPicker/ColorPicker';
+@import 'ValueMappingsEditor/ValueMappingsEditor';
+@import "FormField/FormField";
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index b57b9bcfdb7..5cd677761b0 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -1 +1,25 @@
export { DeleteButton } from './DeleteButton/DeleteButton';
+export { Tooltip } from './Tooltip/Tooltip';
+export { Portal } from './Portal/Portal';
+export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
+
+// Select
+export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
+export { IndicatorsContainer } from './Select/IndicatorsContainer';
+export { NoOptionsMessage } from './Select/NoOptionsMessage';
+export { default as resetSelectStyles } from './Select/resetSelectStyles';
+
+// Forms
+export { FormLabel } from './FormLabel/FormLabel';
+export { FormField } from './FormField/FormField';
+
+export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
+export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
+export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
+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';
diff --git a/packages/grafana-ui/src/forms/GfFormLabel/GfFormLabel.tsx b/packages/grafana-ui/src/forms/GfFormLabel/GfFormLabel.tsx
deleted file mode 100644
index 8b80de64696..00000000000
--- a/packages/grafana-ui/src/forms/GfFormLabel/GfFormLabel.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React, { SFC, ReactNode } from 'react';
-import classNames from 'classnames';
-
-interface Props {
- children: ReactNode;
- htmlFor?: string;
- className?: string;
- isFocused?: boolean;
- isInvalid?: boolean;
-}
-
-export const GfFormLabel: SFC = ({ children, isFocused, isInvalid, className, htmlFor, ...rest }) => {
- const classes = classNames('gf-form-label', className, {
- 'gf-form-label--is-focused': isFocused,
- 'gf-form-label--is-invalid': isInvalid,
- });
-
- return (
-
- {children}
-
- );
-};
diff --git a/packages/grafana-ui/src/forms/index.ts b/packages/grafana-ui/src/forms/index.ts
deleted file mode 100644
index bb6998b0025..00000000000
--- a/packages/grafana-ui/src/forms/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { GfFormLabel } from './GfFormLabel/GfFormLabel';
diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts
index b22152497b9..974d976bbef 100644
--- a/packages/grafana-ui/src/index.ts
+++ b/packages/grafana-ui/src/index.ts
@@ -1,5 +1,3 @@
export * from './components';
-export * from './visualizations';
export * from './types';
export * from './utils';
-export * from './forms';
diff --git a/packages/grafana-ui/src/types/series.ts b/packages/grafana-ui/src/types/data.ts
similarity index 78%
rename from packages/grafana-ui/src/types/series.ts
rename to packages/grafana-ui/src/types/data.ts
index 49662e9872d..1e4ccba3948 100644
--- a/packages/grafana-ui/src/types/series.ts
+++ b/packages/grafana-ui/src/types/data.ts
@@ -21,9 +21,12 @@ export interface TimeSeriesVM {
color: string;
data: TimeSeriesValue[][];
stats: TimeSeriesStats;
+ allIsNull: boolean;
+ allIsZero: boolean;
}
export interface TimeSeriesStats {
+ [key: string]: number | null;
total: number | null;
max: number | null;
min: number | null;
@@ -36,8 +39,6 @@ export interface TimeSeriesStats {
range: number | null;
timeStep: number;
count: number;
- allIsNull: boolean;
- allIsZero: boolean;
}
export enum NullValueMode {
@@ -51,3 +52,20 @@ export interface TimeSeriesVMs {
[index: number]: TimeSeriesVM;
length: number;
}
+
+interface Column {
+ text: string;
+ title?: string;
+ type?: string;
+ sort?: boolean;
+ desc?: boolean;
+ filterable?: boolean;
+ unit?: string;
+}
+
+export interface TableData {
+ columns: Column[];
+ rows: any[];
+ type: string;
+ columnMap: any;
+}
diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts
new file mode 100644
index 00000000000..44d38ff20e2
--- /dev/null
+++ b/packages/grafana-ui/src/types/datasource.ts
@@ -0,0 +1,89 @@
+import { TimeRange, RawTimeRange } from './time';
+import { PluginMeta } from './plugin';
+import { TableData, TimeSeries } from './data';
+
+export interface DataQueryResponse {
+ data: TimeSeries[] | [TableData];
+}
+
+export interface DataQuery {
+ /**
+ * A - Z
+ */
+ refId: string;
+
+ /**
+ * true if query is disabled (ie not executed / sent to TSDB)
+ */
+ hide?: boolean;
+
+ /**
+ * Unique, guid like, string used in explore mode
+ */
+ key?: string;
+
+ /**
+ * For mixed data sources the selected datasource is on the query level.
+ * For non mixed scenarios this is undefined.
+ */
+ datasource?: string | null;
+}
+
+export interface DataQueryOptions {
+ timezone: string;
+ range: TimeRange;
+ rangeRaw: RawTimeRange;
+ targets: TQuery[];
+ panelId: number;
+ dashboardId: number;
+ cacheTimeout?: string;
+ interval: string;
+ intervalMs: number;
+ maxDataPoints: number;
+ scopedVars: object;
+}
+
+export interface QueryFix {
+ type: string;
+ label: string;
+ action?: QueryFixAction;
+}
+
+export interface QueryFixAction {
+ type: string;
+ query?: string;
+ preventSubmit?: boolean;
+}
+
+export interface QueryHint {
+ type: string;
+ label: string;
+ fix?: QueryFix;
+}
+
+export interface DataSourceSettings {
+ id: number;
+ orgId: number;
+ name: string;
+ typeLogoUrl: string;
+ type: string;
+ access: string;
+ url: string;
+ password: string;
+ user: string;
+ database: string;
+ basicAuth: boolean;
+ basicAuthPassword: string;
+ basicAuthUser: string;
+ isDefault: boolean;
+ jsonData: { authType: string; defaultRegion: string };
+ readOnly: boolean;
+ withCredentials: boolean;
+}
+
+export interface DataSourceSelectItem {
+ name: string;
+ value: string | null;
+ meta: PluginMeta;
+ sort: string;
+}
diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts
index f618ce6db34..e23b5e63af8 100644
--- a/packages/grafana-ui/src/types/index.ts
+++ b/packages/grafana-ui/src/types/index.ts
@@ -1,3 +1,14 @@
-export * from './series';
+export * from './data';
export * from './time';
export * from './panel';
+export * from './plugin';
+export * from './datasource';
+
+export enum GrafanaTheme {
+ Light = 'light',
+ Dark = 'dark',
+}
+
+export interface Themeable {
+ theme?: GrafanaTheme;
+}
diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts
index 44336555a81..ad09b3aba9f 100644
--- a/packages/grafana-ui/src/types/panel.ts
+++ b/packages/grafana-ui/src/types/panel.ts
@@ -1,6 +1,8 @@
-import { TimeSeries, LoadingState } from './series';
+import { TimeSeries, LoadingState, TableData } from './data';
import { TimeRange } from './time';
+export type InterpolateFunction = (value: string, format?: string | Function) => string;
+
export interface PanelProps {
timeSeries: TimeSeries[];
timeRange: TimeRange;
@@ -9,6 +11,12 @@ export interface PanelProps {
renderCounter: number;
width: number;
height: number;
+ onInterpolate: InterpolateFunction;
+}
+
+export interface PanelData {
+ timeSeries?: TimeSeries[];
+ tableData?: TableData;
}
export interface PanelOptionsProps {
@@ -29,3 +37,37 @@ export interface PanelMenuItem {
shortcut?: string;
subMenu?: PanelMenuItem[];
}
+
+export interface Threshold {
+ index: number;
+ value: number;
+ color: string;
+}
+
+export enum BasicGaugeColor {
+ Green = '#299c46',
+ Red = '#d44a3a',
+}
+
+export enum MappingType {
+ ValueToText = 1,
+ RangeToText = 2,
+}
+
+interface BaseMap {
+ id: number;
+ operator: string;
+ text: string;
+ type: MappingType;
+}
+
+export type ValueMapping = ValueMap | RangeMap;
+
+export interface ValueMap extends BaseMap {
+ value: string;
+}
+
+export interface RangeMap extends BaseMap {
+ from: string;
+ to: string;
+}
diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts
new file mode 100644
index 00000000000..420a54e5840
--- /dev/null
+++ b/packages/grafana-ui/src/types/plugin.ts
@@ -0,0 +1,118 @@
+import { ComponentClass } from 'react';
+import { PanelProps, PanelOptionsProps } from './panel';
+import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint } from './datasource';
+
+export interface DataSourceApi {
+ /**
+ * min interval range
+ */
+ interval?: string;
+
+ /**
+ * Imports queries from a different datasource
+ */
+ importQueries?(queries: TQuery[], originMeta: PluginMeta): Promise;
+
+ /**
+ * Initializes a datasource after instantiation
+ */
+ init?: () => void;
+
+ /**
+ * Main metrics / data query action
+ */
+ query(options: DataQueryOptions): Promise;
+
+ /**
+ * Test & verify datasource settings & connection details
+ */
+ testDatasource(): Promise;
+
+ /**
+ * Get hints for query improvements
+ */
+ getQueryHints?(query: TQuery, results: any[], ...rest: any): QueryHint[];
+
+ /**
+ * Set after constructor is called by Grafana
+ */
+ name?: string;
+ meta?: PluginMeta;
+ pluginExports?: PluginExports;
+}
+
+export interface QueryEditorProps {
+ datasource: DSType;
+ query: TQuery;
+ onExecuteQuery?: () => void;
+ onQueryChange?: (value: TQuery) => void;
+}
+
+export interface PluginExports {
+ Datasource?: DataSourceApi;
+ QueryCtrl?: any;
+ QueryEditor?: ComponentClass>;
+ ConfigCtrl?: any;
+ AnnotationsQueryCtrl?: any;
+ VariableQueryEditor?: any;
+ ExploreQueryField?: any;
+ ExploreStartPage?: any;
+
+ // Panel plugin
+ PanelCtrl?: any;
+ Panel?: ComponentClass;
+ PanelOptions?: ComponentClass;
+ PanelDefaults?: any;
+}
+
+export interface PluginMeta {
+ id: string;
+ name: string;
+ info: PluginMetaInfo;
+ includes: PluginInclude[];
+
+ // Datasource-specific
+ metrics?: boolean;
+ tables?: boolean;
+ logs?: boolean;
+ explore?: boolean;
+ annotations?: boolean;
+ mixed?: boolean;
+ hasQueryHelp?: boolean;
+ queryOptions?: PluginMetaQueryOptions;
+}
+
+interface PluginMetaQueryOptions {
+ cacheTimeout?: boolean;
+ maxDataPoints?: boolean;
+ minInterval?: boolean;
+}
+
+export interface PluginInclude {
+ type: string;
+ name: string;
+ path: string;
+}
+
+interface PluginMetaInfoLink {
+ name: string;
+ url: string;
+}
+
+export interface PluginMetaInfo {
+ author: {
+ name: string;
+ url?: string;
+ };
+ description: string;
+ links: PluginMetaInfoLink[];
+ logos: {
+ large: string;
+ small: string;
+ };
+ screenshots: any[];
+ updated: string;
+ version: string;
+}
+
+
diff --git a/packages/grafana-ui/src/utils/colors.ts b/packages/grafana-ui/src/utils/colors.ts
new file mode 100644
index 00000000000..65ee605c33b
--- /dev/null
+++ b/packages/grafana-ui/src/utils/colors.ts
@@ -0,0 +1,92 @@
+import _ from 'lodash';
+import tinycolor from 'tinycolor2';
+
+export const PALETTE_ROWS = 4;
+export const PALETTE_COLUMNS = 14;
+export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)';
+export const OK_COLOR = 'rgba(11, 237, 50, 1)';
+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
+ '#6ED0E0', // 2: light blue
+ '#EF843C', // 3: orange
+ '#E24D42', // 4: red
+ '#1F78C1', // 5: ocean
+ '#BA43A9', // 6: purple
+ '#705DA0', // 7: violet
+ '#508642', // 8: dark green
+ '#CCA300', // 9: dark sand
+ '#447EBC',
+ '#C15C17',
+ '#890F02',
+ '#0A437C',
+ '#6D1F62',
+ '#584477',
+ '#B7DBAB',
+ '#F4D598',
+ '#70DBED',
+ '#F9BA8F',
+ '#F29191',
+ '#82B5D8',
+ '#E5A8E2',
+ '#AEA2E0',
+ '#629E51',
+ '#E5AC0E',
+ '#64B0C8',
+ '#E0752D',
+ '#BF1B00',
+ '#0A50A1',
+ '#962D82',
+ '#614D93',
+ '#9AC48A',
+ '#F2C96D',
+ '#65C5DB',
+ '#F9934E',
+ '#EA6460',
+ '#5195CE',
+ '#D683CE',
+ '#806EB7',
+ '#3F6833',
+ '#967302',
+ '#2F575E',
+ '#99440A',
+ '#58140C',
+ '#052B51',
+ '#511749',
+ '#3F2B5B',
+ '#E0F9D7',
+ '#FCEACA',
+ '#CFFAFF',
+ '#F9E2D2',
+ '#FCE2DE',
+ '#BADFF4',
+ '#F9D9F9',
+ '#DEDAF7',
+];
+
+function sortColorsByHue(hexColors: string[]) {
+ const hslColors = _.map(hexColors, hexToHsl);
+
+ const sortedHSLColors = _.sortBy(hslColors, ['h']);
+ const chunkedHSLColors = _.chunk(sortedHSLColors, PALETTE_ROWS);
+ const sortedChunkedHSLColors = _.map(chunkedHSLColors, chunk => {
+ return _.sortBy(chunk, 'l');
+ });
+ const flattenedZippedSortedChunkedHSLColors = _.flattenDeep(_.zip(...sortedChunkedHSLColors));
+
+ return _.map(flattenedZippedSortedChunkedHSLColors, hslToHex);
+}
+
+function hexToHsl(color: string) {
+ return tinycolor(color).toHsl();
+}
+
+function hslToHex(color: any) {
+ return tinycolor(color).toHexString();
+}
+
+export let sortedColors = sortColorsByHue(colors);
diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts
index 4d9b9a4b948..52a8535f9e1 100644
--- a/packages/grafana-ui/src/utils/index.ts
+++ b/packages/grafana-ui/src/utils/index.ts
@@ -1 +1,4 @@
export * from './processTimeSeries';
+export * from './valueFormats/valueFormats';
+export * from './colors';
+export * from './namedColorsPalette';
diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.test.ts b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
new file mode 100644
index 00000000000..c6a1aaf0dd0
--- /dev/null
+++ b/packages/grafana-ui/src/utils/namedColorsPalette.test.ts
@@ -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)');
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/utils/namedColorsPalette.ts b/packages/grafana-ui/src/utils/namedColorsPalette.ts
new file mode 100644
index 00000000000..5312b27ad26
--- /dev/null
+++ b/packages/grafana-ui/src/utils/namedColorsPalette.ts
@@ -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;
+
+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();
+
+ 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;
+};
diff --git a/packages/grafana-ui/src/utils/processTimeSeries.ts b/packages/grafana-ui/src/utils/processTimeSeries.ts
index e92aaf0c1a6..f5389f1b2bd 100644
--- a/packages/grafana-ui/src/utils/processTimeSeries.ts
+++ b/packages/grafana-ui/src/utils/processTimeSeries.ts
@@ -1,18 +1,19 @@
// Libraries
import _ from 'lodash';
+import { colors } from './colors';
+
// Types
import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
interface Options {
timeSeries: TimeSeries[];
nullValueMode: NullValueMode;
- colorPalette: string[];
}
-export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: Options): TimeSeriesVMs {
+export function processTimeSeries({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
const vmSeries = timeSeries.map((item, index) => {
- const colorIndex = index % colorPalette.length;
+ const colorIndex = index % colors.length;
const label = item.target;
const result = [];
@@ -49,8 +50,8 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
continue;
}
- if (typeof currentValue !== 'number') {
- continue;
+ if (currentValue !== null && typeof currentValue !== 'number') {
+ throw {message: 'Time series contains non number values'};
}
// Due to missing values we could have different timeStep all along the series
@@ -150,7 +151,9 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
return {
data: result,
label: label,
- color: colorPalette[colorIndex],
+ color: colors[colorIndex],
+ allIsZero,
+ allIsNull,
stats: {
total,
min,
@@ -164,8 +167,6 @@ export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: O
range,
count,
first,
- allIsZero,
- allIsNull,
},
};
});
diff --git a/packages/grafana-ui/src/utils/propDeprecationWarning.ts b/packages/grafana-ui/src/utils/propDeprecationWarning.ts
new file mode 100644
index 00000000000..a277395c090
--- /dev/null
+++ b/packages/grafana-ui/src/utils/propDeprecationWarning.ts
@@ -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;
diff --git a/packages/grafana-ui/src/utils/storybook/UseState.tsx b/packages/grafana-ui/src/utils/storybook/UseState.tsx
new file mode 100644
index 00000000000..cc263b9a456
--- /dev/null
+++ b/packages/grafana-ui/src/utils/storybook/UseState.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+
+interface StateHolderProps {
+ initialState: T;
+ children: (currentState: T, updateState: (nextState: T) => void) => JSX.Element;
+}
+
+export class UseState extends React.Component, { value: T; initialState: T }> {
+ constructor(props: StateHolderProps) {
+ 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);
+ }
+}
diff --git a/packages/grafana-ui/src/utils/storybook/themeKnob.ts b/packages/grafana-ui/src/utils/storybook/themeKnob.ts
new file mode 100644
index 00000000000..a3733462bea
--- /dev/null
+++ b/packages/grafana-ui/src/utils/storybook/themeKnob.ts
@@ -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
+ );
+};
diff --git a/packages/grafana-ui/src/utils/storybook/withCenteredStory.tsx b/packages/grafana-ui/src/utils/storybook/withCenteredStory.tsx
new file mode 100644
index 00000000000..af564616f5a
--- /dev/null
+++ b/packages/grafana-ui/src/utils/storybook/withCenteredStory.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { RenderFunction } from '@storybook/react';
+
+const CenteredStory: React.FunctionComponent<{}> = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const withCenteredStory = (story: RenderFunction) => {story()} ;
diff --git a/packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.test.ts b/packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.test.ts
new file mode 100644
index 00000000000..44332a51307
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.test.ts
@@ -0,0 +1,40 @@
+import { toHex, toHex0x } from './arithmeticFormatters';
+
+describe('hex', () => {
+ it('positive integer', () => {
+ const str = toHex(100, 0);
+ expect(str).toBe('64');
+ });
+ it('negative integer', () => {
+ const str = toHex(-100, 0);
+ expect(str).toBe('-64');
+ });
+ it('positive float', () => {
+ const str = toHex(50.52, 1);
+ expect(str).toBe('32.8');
+ });
+ it('negative float', () => {
+ const str = toHex(-50.333, 2);
+ expect(str).toBe('-32.547AE147AE14');
+ });
+});
+
+describe('hex 0x', () => {
+ it('positive integeter', () => {
+ const str = toHex0x(7999, 0);
+ expect(str).toBe('0x1F3F');
+ });
+ it('negative integer', () => {
+ const str = toHex0x(-584, 0);
+ expect(str).toBe('-0x248');
+ });
+
+ it('positive float', () => {
+ const str = toHex0x(74.443, 3);
+ expect(str).toBe('0x4A.716872B020C4');
+ });
+ it('negative float', () => {
+ const str = toHex0x(-65.458, 1);
+ expect(str).toBe('-0x41.8');
+ });
+});
diff --git a/packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.ts b/packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.ts
new file mode 100644
index 00000000000..fa9daf0fb97
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.ts
@@ -0,0 +1,42 @@
+import { toFixed } from './valueFormats';
+
+export function toPercent(size: number, decimals: number) {
+ if (size === null) {
+ return '';
+ }
+ return toFixed(size, decimals) + '%';
+}
+
+export function toPercentUnit(size: number, decimals: number) {
+ if (size === null) {
+ return '';
+ }
+ return toFixed(100 * size, decimals) + '%';
+}
+
+export function toHex0x(value: number, decimals: number) {
+ if (value == null) {
+ return '';
+ }
+ const hexString = toHex(value, decimals);
+ if (hexString.substring(0, 1) === '-') {
+ return '-0x' + hexString.substring(1);
+ }
+ return '0x' + hexString;
+}
+
+export function toHex(value: number, decimals: number) {
+ if (value == null) {
+ return '';
+ }
+ return parseFloat(toFixed(value, decimals))
+ .toString(16)
+ .toUpperCase();
+}
+
+export function sci(value: number, decimals: number) {
+ if (value == null) {
+ return '';
+ }
+ return value.toExponential(decimals);
+}
diff --git a/packages/grafana-ui/src/utils/valueFormats/categories.ts b/packages/grafana-ui/src/utils/valueFormats/categories.ts
new file mode 100644
index 00000000000..d7410c22276
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/categories.ts
@@ -0,0 +1,322 @@
+import { locale, scaledUnits, simpleCountUnit, toFixed, toFixedUnit, ValueFormatCategory } from './valueFormats';
+import {
+ dateTimeAsIso,
+ dateTimeAsUS,
+ dateTimeFromNow,
+ toClockMilliseconds,
+ toClockSeconds,
+ toDays,
+ toDurationInHoursMinutesSeconds,
+ toDurationInMilliseconds,
+ toDurationInSeconds,
+ toHours,
+ toMicroSeconds,
+ toMilliSeconds,
+ toMinutes,
+ toNanoSeconds,
+ toSeconds,
+ toTimeTicks,
+} from './dateTimeFormatters';
+import { toHex, sci, toHex0x, toPercent, toPercentUnit } from './arithmeticFormatters';
+import { binarySIPrefix, currency, decimalSIPrefix } from './symbolFormatters';
+
+export const getCategories = (): ValueFormatCategory[] => [
+ {
+ name: 'Misc',
+ formats: [
+ { name: 'none', id: 'none', fn: toFixed },
+ {
+ name: 'short',
+ id: 'short',
+ fn: scaledUnits(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']),
+ },
+ { name: 'percent (0-100)', id: 'percent', fn: toPercent },
+ { name: 'percent (0.0-1.0)', id: 'percentunit', fn: toPercentUnit },
+ { name: 'Humidity (%H)', id: 'humidity', fn: toFixedUnit('%H') },
+ { name: 'decibel', id: 'dB', fn: toFixedUnit('dB') },
+ { name: 'hexadecimal (0x)', id: 'hex0x', fn: toHex0x },
+ { name: 'hexadecimal', id: 'hex', fn: toHex },
+ { name: 'scientific notation', id: 'sci', fn: sci },
+ { name: 'locale format', id: 'locale', fn: locale },
+ ],
+ },
+ {
+ name: 'Acceleration',
+ formats: [
+ { name: 'Meters/sec²', id: 'accMS2', fn: toFixedUnit('m/sec²') },
+ { name: 'Feet/sec²', id: 'accFS2', fn: toFixedUnit('f/sec²') },
+ { name: 'G unit', id: 'accG', fn: toFixedUnit('g') },
+ ],
+ },
+ {
+ name: 'Angle',
+ formats: [
+ { name: 'Degrees (°)', id: 'degree', fn: toFixedUnit('°') },
+ { name: 'Radians', id: 'radian', fn: toFixedUnit('rad') },
+ { name: 'Gradian', id: 'grad', fn: toFixedUnit('grad') },
+ ],
+ },
+ {
+ name: 'Area',
+ formats: [
+ { name: 'Square Meters (m²)', id: 'areaM2', fn: toFixedUnit('m²') },
+ { name: 'Square Feet (ft²)', id: 'areaF2', fn: toFixedUnit('ft²') },
+ { name: 'Square Miles (mi²)', id: 'areaMI2', fn: toFixedUnit('mi²') },
+ ],
+ },
+ {
+ name: 'Computation',
+ formats: [
+ { name: 'FLOP/s', id: 'flops', fn: decimalSIPrefix('FLOP/s') },
+ { name: 'MFLOP/s', id: 'mflops', fn: decimalSIPrefix('FLOP/s', 2) },
+ { name: 'GFLOP/s', id: 'gflops', fn: decimalSIPrefix('FLOP/s', 3) },
+ { name: 'TFLOP/s', id: 'tflops', fn: decimalSIPrefix('FLOP/s', 4) },
+ { name: 'PFLOP/s', id: 'pflops', fn: decimalSIPrefix('FLOP/s', 5) },
+ { name: 'EFLOP/s', id: 'eflops', fn: decimalSIPrefix('FLOP/s', 6) },
+ ],
+ },
+ {
+ name: 'Concentration',
+ formats: [
+ { name: 'parts-per-million (ppm)', id: 'ppm', fn: toFixedUnit('ppm') },
+ { name: 'parts-per-billion (ppb)', id: 'conppb', fn: toFixedUnit('ppb') },
+ { name: 'nanogram per cubic meter (ng/m³)', id: 'conngm3', fn: toFixedUnit('ng/m³') },
+ { name: 'nanogram per normal cubic meter (ng/Nm³)', id: 'conngNm3', fn: toFixedUnit('ng/Nm³') },
+ { name: 'microgram per cubic meter (μg/m³)', id: 'conμgm3', fn: toFixedUnit('μg/m³') },
+ { name: 'microgram per normal cubic meter (μg/Nm³)', id: 'conμgNm3', fn: toFixedUnit('μg/Nm³') },
+ { name: 'milligram per cubic meter (mg/m³)', id: 'conmgm3', fn: toFixedUnit('mg/m³') },
+ { name: 'milligram per normal cubic meter (mg/Nm³)', id: 'conmgNm3', fn: toFixedUnit('mg/Nm³') },
+ { name: 'gram per cubic meter (g/m³)', id: 'congm3', fn: toFixedUnit('g/m³') },
+ { name: 'gram per normal cubic meter (g/Nm³)', id: 'congNm3', fn: toFixedUnit('g/Nm³') },
+ { name: 'milligrams per decilitre (mg/dL)', id: 'conmgdL', fn: toFixedUnit('mg/dL') },
+ { name: 'millimoles per litre (mmol/L)', id: 'conmmolL', fn: toFixedUnit('mmol/L') },
+ ],
+ },
+ {
+ name: 'Currency',
+ formats: [
+ { name: 'Dollars ($)', id: 'currencyUSD', fn: currency('$') },
+ { name: 'Pounds (£)', id: 'currencyGBP', fn: currency('£') },
+ { name: 'Euro (€)', id: 'currencyEUR', fn: currency('€') },
+ { name: 'Yen (¥)', id: 'currencyJPY', fn: currency('¥') },
+ { name: 'Rubles (₽)', id: 'currencyRUB', fn: currency('₽') },
+ { name: 'Hryvnias (₴)', id: 'currencyUAH', fn: currency('₴') },
+ { name: 'Real (R$)', id: 'currencyBRL', fn: currency('R$') },
+ { name: 'Danish Krone (kr)', id: 'currencyDKK', fn: currency('kr') },
+ { name: 'Icelandic Króna (kr)', id: 'currencyISK', fn: currency('kr') },
+ { name: 'Norwegian Krone (kr)', id: 'currencyNOK', fn: currency('kr') },
+ { name: 'Swedish Krona (kr)', id: 'currencySEK', fn: currency('kr') },
+ { name: 'Czech koruna (czk)', id: 'currencyCZK', fn: currency('czk') },
+ { name: 'Swiss franc (CHF)', id: 'currencyCHF', fn: currency('CHF') },
+ { name: 'Polish Złoty (PLN)', id: 'currencyPLN', fn: currency('PLN') },
+ { name: 'Bitcoin (฿)', id: 'currencyBTC', fn: currency('฿') },
+ ],
+ },
+ {
+ name: 'Data (IEC)',
+ formats: [
+ { name: 'bits', id: 'bits', fn: binarySIPrefix('b') },
+ { name: 'bytes', id: 'bytes', fn: binarySIPrefix('B') },
+ { name: 'kibibytes', id: 'kbytes', fn: binarySIPrefix('B', 1) },
+ { name: 'mebibytes', id: 'mbytes', fn: binarySIPrefix('B', 2) },
+ { name: 'gibibytes', id: 'gbytes', fn: binarySIPrefix('B', 3) },
+ ],
+ },
+ {
+ name: 'Data (Metric)',
+ formats: [
+ { name: 'bits', id: 'decbits', fn: decimalSIPrefix('d') },
+ { name: 'bytes', id: 'decbytes', fn: decimalSIPrefix('B') },
+ { name: 'kilobytes', id: 'deckbytes', fn: decimalSIPrefix('B', 1) },
+ { name: 'megabytes', id: 'decmbytes', fn: decimalSIPrefix('B', 2) },
+ { name: 'gigabytes', id: 'decgbytes', fn: decimalSIPrefix('B', 3) },
+ ],
+ },
+ {
+ name: 'Data Rate',
+ formats: [
+ { name: 'packets/sec', id: 'pps', fn: decimalSIPrefix('pps') },
+ { name: 'bits/sec', id: 'bps', fn: decimalSIPrefix('bps') },
+ { name: 'bytes/sec', id: 'Bps', fn: decimalSIPrefix('B/s') },
+ { name: 'kilobytes/sec', id: 'KBs', fn: decimalSIPrefix('Bs', 1) },
+ { name: 'kilobits/sec', id: 'Kbits', fn: decimalSIPrefix('bps', 1) },
+ { name: 'megabytes/sec', id: 'MBs', fn: decimalSIPrefix('Bs', 2) },
+ { name: 'megabits/sec', id: 'Mbits', fn: decimalSIPrefix('bps', 2) },
+ { name: 'gigabytes/sec', id: 'GBs', fn: decimalSIPrefix('Bs', 3) },
+ { name: 'gigabits/sec', id: 'Gbits', fn: decimalSIPrefix('bps', 3) },
+ ],
+ },
+ {
+ name: 'Date & Time',
+ formats: [
+ { name: 'YYYY-MM-DD HH:mm:ss', id: 'dateTimeAsIso', fn: dateTimeAsIso },
+ { name: 'DD/MM/YYYY h:mm:ss a', id: 'dateTimeAsUS', fn: dateTimeAsUS },
+ { name: 'From Now', id: 'dateTimeFromNow', fn: dateTimeFromNow },
+ ],
+ },
+ {
+ name: 'Energy',
+ formats: [
+ { name: 'Watt (W)', id: 'watt', fn: decimalSIPrefix('W') },
+ { name: 'Kilowatt (kW)', id: 'kwatt', fn: decimalSIPrefix('W', 1) },
+ { name: 'Milliwatt (mW)', id: 'mwatt', fn: decimalSIPrefix('W', -1) },
+ { name: 'Watt per square meter (W/m²)', id: 'Wm2', fn: toFixedUnit('W/m²') },
+ { name: 'Volt-ampere (VA)', id: 'voltamp', fn: decimalSIPrefix('VA') },
+ { name: 'Kilovolt-ampere (kVA)', id: 'kvoltamp', fn: decimalSIPrefix('VA', 1) },
+ { name: 'Volt-ampere reactive (var)', id: 'voltampreact', fn: decimalSIPrefix('var') },
+ { name: 'Kilovolt-ampere reactive (kvar)', id: 'kvoltampreact', fn: decimalSIPrefix('var', 1) },
+ { name: 'Watt-hour (Wh)', id: 'watth', fn: decimalSIPrefix('Wh') },
+ { name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: decimalSIPrefix('Wh', 1) },
+ { name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: decimalSIPrefix('W/Min', 1) },
+ { name: 'Joule (J)', id: 'joule', fn: decimalSIPrefix('J') },
+ { name: 'Electron volt (eV)', id: 'ev', fn: decimalSIPrefix('eV') },
+ { name: 'Ampere (A)', id: 'amp', fn: decimalSIPrefix('A') },
+ { name: 'Kiloampere (kA)', id: 'kamp', fn: decimalSIPrefix('A', 1) },
+ { name: 'Milliampere (mA)', id: 'mamp', fn: decimalSIPrefix('A', -1) },
+ { name: 'Volt (V)', id: 'volt', fn: decimalSIPrefix('V') },
+ { name: 'Kilovolt (kV)', id: 'kvolt', fn: decimalSIPrefix('V', 1) },
+ { name: 'Millivolt (mV)', id: 'mvolt', fn: decimalSIPrefix('V', -1) },
+ { name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: decimalSIPrefix('dBm') },
+ { name: 'Ohm (Ω)', id: 'ohm', fn: decimalSIPrefix('Ω') },
+ { name: 'Lumens (Lm)', id: 'lumens', fn: decimalSIPrefix('Lm') },
+ ],
+ },
+ {
+ name: 'Flow',
+ formats: [
+ { name: 'Gallons/min (gpm)', id: 'flowgpm', fn: toFixedUnit('gpm') },
+ { name: 'Cubic meters/sec (cms)', id: 'flowcms', fn: toFixedUnit('cms') },
+ { name: 'Cubic feet/sec (cfs)', id: 'flowcfs', fn: toFixedUnit('cfs') },
+ { name: 'Cubic feet/min (cfm)', id: 'flowcfm', fn: toFixedUnit('cfm') },
+ { name: 'Litre/hour', id: 'litreh', fn: toFixedUnit('l/h') },
+ { name: 'Litre/min (l/min)', id: 'flowlpm', fn: toFixedUnit('l/min') },
+ { name: 'milliLitre/min (mL/min)', id: 'flowmlpm', fn: toFixedUnit('mL/min') },
+ ],
+ },
+ {
+ name: 'Force',
+ formats: [
+ { name: 'Newton-meters (Nm)', id: 'forceNm', fn: decimalSIPrefix('Nm') },
+ { name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: decimalSIPrefix('Nm', 1) },
+ { name: 'Newtons (N)', id: 'forceN', fn: decimalSIPrefix('N') },
+ { name: 'Kilonewtons (kN)', id: 'forcekN', fn: decimalSIPrefix('N', 1) },
+ ],
+ },
+ {
+ name: 'Hash Rate',
+ formats: [
+ { name: 'hashes/sec', id: 'Hs', fn: decimalSIPrefix('H/s') },
+ { name: 'kilohashes/sec', id: 'KHs', fn: decimalSIPrefix('H/s', 1) },
+ { name: 'megahashes/sec', id: 'MHs', fn: decimalSIPrefix('H/s', 2) },
+ { name: 'gigahashes/sec', id: 'GHs', fn: decimalSIPrefix('H/s', 3) },
+ { name: 'terahashes/sec', id: 'THs', fn: decimalSIPrefix('H/s', 4) },
+ { name: 'petahashes/sec', id: 'PHs', fn: decimalSIPrefix('H/s', 5) },
+ { name: 'exahashes/sec', id: 'EHs', fn: decimalSIPrefix('H/s', 6) },
+ ],
+ },
+ {
+ name: 'Mass',
+ formats: [
+ { name: 'milligram (mg)', id: 'massmg', fn: decimalSIPrefix('g', -1) },
+ { name: 'gram (g)', id: 'massg', fn: decimalSIPrefix('g') },
+ { name: 'kilogram (kg)', id: 'masskg', fn: decimalSIPrefix('g', 1) },
+ { name: 'metric ton (t)', id: 'masst', fn: toFixedUnit('t') },
+ ],
+ },
+ {
+ name: 'length',
+ formats: [
+ { name: 'millimetre (mm)', id: 'lengthmm', fn: decimalSIPrefix('m', -1) },
+ { name: 'feet (ft)', id: 'lengthft', fn: toFixedUnit('ft') },
+ { name: 'meter (m)', id: 'lengthm', fn: decimalSIPrefix('m') },
+ { name: 'kilometer (km)', id: 'lengthkm', fn: decimalSIPrefix('m', 1) },
+ { name: 'mile (mi)', id: 'lengthmi', fn: toFixedUnit('mi') },
+ ],
+ },
+ {
+ name: 'Pressure',
+ formats: [
+ { name: 'Millibars', id: 'pressurembar', fn: decimalSIPrefix('bar', -1) },
+ { name: 'Bars', id: 'pressurebar', fn: decimalSIPrefix('bar') },
+ { name: 'Kilobars', id: 'pressurekbar', fn: decimalSIPrefix('bar', 1) },
+ { name: 'Hectopascals', id: 'pressurehpa', fn: toFixedUnit('hPa') },
+ { name: 'Kilopascals', id: 'pressurekpa', fn: toFixedUnit('kPa') },
+ { name: 'Inches of mercury', id: 'pressurehg', fn: toFixedUnit('"Hg') },
+ { name: 'PSI', id: 'pressurepsi', fn: scaledUnits(1000, ['psi', 'ksi', 'Mpsi']) },
+ ],
+ },
+ {
+ name: 'Radiation',
+ formats: [
+ { name: 'Becquerel (Bq)', id: 'radbq', fn: decimalSIPrefix('Bq') },
+ { name: 'curie (Ci)', id: 'radci', fn: decimalSIPrefix('Ci') },
+ { name: 'Gray (Gy)', id: 'radgy', fn: decimalSIPrefix('Gy') },
+ { name: 'rad', id: 'radrad', fn: decimalSIPrefix('rad') },
+ { name: 'Sievert (Sv)', id: 'radsv', fn: decimalSIPrefix('Sv') },
+ { name: 'rem', id: 'radrem', fn: decimalSIPrefix('rem') },
+ { name: 'Exposure (C/kg)', id: 'radexpckg', fn: decimalSIPrefix('C/kg') },
+ { name: 'roentgen (R)', id: 'radr', fn: decimalSIPrefix('R') },
+ { name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: decimalSIPrefix('Sv/h') },
+ ],
+ },
+ {
+ name: 'Temperature',
+ formats: [
+ { name: 'Celsius (°C)', id: 'celsius', fn: toFixedUnit('°C') },
+ { name: 'Farenheit (°F)', id: 'farenheit', fn: toFixedUnit('°F') },
+ { name: 'Kelvin (K)', id: 'kelvin', fn: toFixedUnit('K') },
+ ],
+ },
+ {
+ name: 'Time',
+ formats: [
+ { name: 'Hertz (1/s)', id: 'hertz', fn: decimalSIPrefix('Hz') },
+ { name: 'nanoseconds (ns)', id: 'ns', fn: toNanoSeconds },
+ { name: 'microseconds (µs)', id: 'µs', fn: toMicroSeconds },
+ { name: 'milliseconds (ms)', id: 'ms', fn: toMilliSeconds },
+ { name: 'seconds (s)', id: 's', fn: toSeconds },
+ { name: 'minutes (m)', id: 'm', fn: toMinutes },
+ { name: 'hours (h)', id: 'h', fn: toHours },
+ { name: 'days (d)', id: 'd', fn: toDays },
+ { name: 'duration (ms)', id: 'dtdurationms', fn: toDurationInMilliseconds },
+ { name: 'duration (s)', id: 'dtdurations', fn: toDurationInSeconds },
+ { name: 'duration (hh:mm:ss)', id: 'dthms', fn: toDurationInHoursMinutesSeconds },
+ { name: 'Timeticks (s/100)', id: 'timeticks', fn: toTimeTicks },
+ { name: 'clock (ms)', id: 'clockms', fn: toClockMilliseconds },
+ { name: 'clock (s)', id: 'clocks', fn: toClockSeconds },
+ ],
+ },
+ {
+ name: 'Throughput',
+ formats: [
+ { name: 'ops/sec (ops)', id: 'ops', fn: simpleCountUnit('ops') },
+ { name: 'requests/sec (rps)', id: 'reqps', fn: simpleCountUnit('reqps') },
+ { name: 'reads/sec (rps)', id: 'rps', fn: simpleCountUnit('rps') },
+ { name: 'writes/sec (wps)', id: 'wps', fn: simpleCountUnit('wps') },
+ { name: 'I/O ops/sec (iops)', id: 'iops', fn: simpleCountUnit('iops') },
+ { name: 'ops/min (opm)', id: 'opm', fn: simpleCountUnit('opm') },
+ { name: 'reads/min (rpm)', id: 'rpm', fn: simpleCountUnit('rpm') },
+ { name: 'writes/min (wpm)', id: 'wpm', fn: simpleCountUnit('wpm') },
+ ],
+ },
+ {
+ name: 'Velocity',
+ formats: [
+ { name: 'metres/second (m/s)', id: 'velocityms', fn: toFixedUnit('m/s') },
+ { name: 'kilometers/hour (km/h)', id: 'velocitykmh', fn: toFixedUnit('km/h') },
+ { name: 'miles/hour (mph)', id: 'velocitymph', fn: toFixedUnit('mph') },
+ { name: 'knot (kn)', id: 'velocityknot', fn: toFixedUnit('kn') },
+ ]
+ },
+ {
+ name: 'Volume',
+ formats: [
+ { name: 'millilitre (mL)', id: 'mlitre', fn: decimalSIPrefix('L', -1) },
+ { name: 'litre (L)', id: 'litre', fn: decimalSIPrefix('L') },
+ { name: 'cubic metre', id: 'm3', fn: toFixedUnit('m³') },
+ { name: 'Normal cubic metre', id: 'Nm3', fn: toFixedUnit('Nm³') },
+ { name: 'cubic decimetre', id: 'dm3', fn: toFixedUnit('dm³') },
+ { name: 'gallons', id: 'gallons', fn: toFixedUnit('gal') },
+ ],
+ }
+];
diff --git a/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts
new file mode 100644
index 00000000000..cf69a1d433a
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts
@@ -0,0 +1,231 @@
+import moment from 'moment';
+import {
+ dateTimeAsIso,
+ dateTimeAsUS,
+ dateTimeFromNow,
+ Interval,
+ toClock,
+ toDuration,
+ toDurationInMilliseconds,
+ toDurationInSeconds,
+} from './dateTimeFormatters';
+
+describe('date time formats', () => {
+ const epoch = 1505634997920;
+ const utcTime = moment.utc(epoch);
+ const browserTime = moment(epoch);
+
+ it('should format as iso date', () => {
+ const expected = browserTime.format('YYYY-MM-DD HH:mm:ss');
+ const actual = dateTimeAsIso(epoch, 0, 0, false);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as iso date (in UTC)', () => {
+ const expected = utcTime.format('YYYY-MM-DD HH:mm:ss');
+ const actual = dateTimeAsIso(epoch, 0, 0, true);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as iso date and skip date when today', () => {
+ const now = moment();
+ const expected = now.format('HH:mm:ss');
+ const actual = dateTimeAsIso(now.valueOf(), 0, 0, false);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as iso date (in UTC) and skip date when today', () => {
+ const now = moment.utc();
+ const expected = now.format('HH:mm:ss');
+ const actual = dateTimeAsIso(now.valueOf(), 0, 0, true);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as US date', () => {
+ const expected = browserTime.format('MM/DD/YYYY h:mm:ss a');
+ const actual = dateTimeAsUS(epoch, 0, 0, false);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as US date (in UTC)', () => {
+ const expected = utcTime.format('MM/DD/YYYY h:mm:ss a');
+ const actual = dateTimeAsUS(epoch, 0, 0, true);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as US date and skip date when today', () => {
+ const now = moment();
+ const expected = now.format('h:mm:ss a');
+ const actual = dateTimeAsUS(now.valueOf(), 0, 0, false);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as US date (in UTC) and skip date when today', () => {
+ const now = moment.utc();
+ const expected = now.format('h:mm:ss a');
+ const actual = dateTimeAsUS(now.valueOf(), 0, 0, true);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as from now with days', () => {
+ const daysAgo = moment().add(-7, 'd');
+ const expected = '7 days ago';
+ const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, false);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as from now with days (in UTC)', () => {
+ const daysAgo = moment.utc().add(-7, 'd');
+ const expected = '7 days ago';
+ const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, true);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as from now with minutes', () => {
+ const daysAgo = moment().add(-2, 'm');
+ const expected = '2 minutes ago';
+ const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, false);
+ expect(actual).toBe(expected);
+ });
+
+ it('should format as from now with minutes (in UTC)', () => {
+ const daysAgo = moment.utc().add(-2, 'm');
+ const expected = '2 minutes ago';
+ const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, true);
+ expect(actual).toBe(expected);
+ });
+});
+
+describe('duration', () => {
+ it('0 milliseconds', () => {
+ const str = toDurationInMilliseconds(0, 0);
+ expect(str).toBe('0 milliseconds');
+ });
+ it('1 millisecond', () => {
+ const str = toDurationInMilliseconds(1, 0);
+ expect(str).toBe('1 millisecond');
+ });
+ it('-1 millisecond', () => {
+ const str = toDurationInMilliseconds(-1, 0);
+ expect(str).toBe('1 millisecond ago');
+ });
+ it('seconds', () => {
+ const str = toDurationInSeconds(1, 0);
+ expect(str).toBe('1 second');
+ });
+ it('minutes', () => {
+ const str = toDuration(1, 0, Interval.Minute);
+ expect(str).toBe('1 minute');
+ });
+ it('hours', () => {
+ const str = toDuration(1, 0, Interval.Hour);
+ expect(str).toBe('1 hour');
+ });
+ it('days', () => {
+ const str = toDuration(1, 0, Interval.Day);
+ expect(str).toBe('1 day');
+ });
+ it('weeks', () => {
+ const str = toDuration(1, 0, Interval.Week);
+ expect(str).toBe('1 week');
+ });
+ it('months', () => {
+ const str = toDuration(1, 0, Interval.Month);
+ expect(str).toBe('1 month');
+ });
+ it('years', () => {
+ const str = toDuration(1, 0, Interval.Year);
+ expect(str).toBe('1 year');
+ });
+ it('decimal days', () => {
+ const str = toDuration(1.5, 2, Interval.Day);
+ expect(str).toBe('1 day, 12 hours, 0 minutes');
+ });
+ it('decimal months', () => {
+ const str = toDuration(1.5, 3, Interval.Month);
+ expect(str).toBe('1 month, 2 weeks, 1 day, 0 hours');
+ });
+ it('no decimals', () => {
+ const str = toDuration(38898367008, 0, Interval.Millisecond);
+ expect(str).toBe('1 year');
+ });
+ it('1 decimal', () => {
+ const str = toDuration(38898367008, 1, Interval.Millisecond);
+ expect(str).toBe('1 year, 2 months');
+ });
+ it('too many decimals', () => {
+ const str = toDuration(38898367008, 20, Interval.Millisecond);
+ expect(str).toBe('1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, 7 seconds, 8 milliseconds');
+ });
+ it('floating point error', () => {
+ const str = toDuration(36993906007, 8, Interval.Millisecond);
+ expect(str).toBe('1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds');
+ });
+});
+
+describe('clock', () => {
+ it('size less than 1 second', () => {
+ const str = toClock(999, 0);
+ expect(str).toBe('999ms');
+ });
+ describe('size less than 1 minute', () => {
+ it('default', () => {
+ const str = toClock(59999);
+ expect(str).toBe('59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = toClock(59999, 0);
+ expect(str).toBe('59s');
+ });
+ });
+ describe('size less than 1 hour', () => {
+ it('default', () => {
+ const str = toClock(3599999);
+ expect(str).toBe('59m:59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = toClock(3599999, 0);
+ expect(str).toBe('59m');
+ });
+ it('decimals equals 1', () => {
+ const str = toClock(3599999, 1);
+ expect(str).toBe('59m:59s');
+ });
+ });
+ describe('size greater than or equal 1 hour', () => {
+ it('default', () => {
+ const str = toClock(7199999);
+ expect(str).toBe('01h:59m:59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = toClock(7199999, 0);
+ expect(str).toBe('01h');
+ });
+ it('decimals equals 1', () => {
+ const str = toClock(7199999, 1);
+ expect(str).toBe('01h:59m');
+ });
+ it('decimals equals 2', () => {
+ const str = toClock(7199999, 2);
+ expect(str).toBe('01h:59m:59s');
+ });
+ });
+ describe('size greater than or equal 1 day', () => {
+ it('default', () => {
+ const str = toClock(89999999);
+ expect(str).toBe('24h:59m:59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = toClock(89999999, 0);
+ expect(str).toBe('24h');
+ });
+ it('decimals equals 1', () => {
+ const str = toClock(89999999, 1);
+ expect(str).toBe('24h:59m');
+ });
+ it('decimals equals 2', () => {
+ const str = toClock(89999999, 2);
+ expect(str).toBe('24h:59m:59s');
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts
new file mode 100644
index 00000000000..1e07857eb66
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts
@@ -0,0 +1,312 @@
+import { toFixed, toFixedScaled } from './valueFormats';
+import moment from 'moment';
+
+interface IntervalsInSeconds {
+ [interval: string]: number;
+}
+
+export enum Interval {
+ Year = 'year',
+ Month = 'month',
+ Week = 'week',
+ Day = 'day',
+ Hour = 'hour',
+ Minute = 'minute',
+ Second = 'second',
+ Millisecond = 'millisecond',
+}
+
+const INTERVALS_IN_SECONDS: IntervalsInSeconds = {
+ [Interval.Year]: 31536000,
+ [Interval.Month]: 2592000,
+ [Interval.Week]: 604800,
+ [Interval.Day]: 86400,
+ [Interval.Hour]: 3600,
+ [Interval.Minute]: 60,
+ [Interval.Second]: 1,
+ [Interval.Millisecond]: 0.001,
+};
+
+export function toNanoSeconds(size: number, decimals: number, scaledDecimals: number) {
+ if (size === null) {
+ return '';
+ }
+
+ if (Math.abs(size) < 1000) {
+ return toFixed(size, decimals) + ' ns';
+ } else if (Math.abs(size) < 1000000) {
+ return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' µs');
+ } else if (Math.abs(size) < 1000000000) {
+ return toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' ms');
+ } else if (Math.abs(size) < 60000000000) {
+ return toFixedScaled(size / 1000000000, decimals, scaledDecimals, 9, ' s');
+ } else {
+ return toFixedScaled(size / 60000000000, decimals, scaledDecimals, 12, ' min');
+ }
+}
+
+export function toMicroSeconds(size: number, decimals: number, scaledDecimals: number) {
+ if (size === null) {
+ return '';
+ }
+
+ if (Math.abs(size) < 1000) {
+ return toFixed(size, decimals) + ' µs';
+ } else if (Math.abs(size) < 1000000) {
+ return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' ms');
+ } else {
+ return toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' s');
+ }
+}
+
+export function toMilliSeconds(size: number, decimals: number, scaledDecimals: number) {
+ if (size === null) {
+ return '';
+ }
+
+ if (Math.abs(size) < 1000) {
+ return toFixed(size, decimals) + ' ms';
+ } else if (Math.abs(size) < 60000) {
+ // Less than 1 min
+ return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' s');
+ } else if (Math.abs(size) < 3600000) {
+ // Less than 1 hour, divide in minutes
+ return toFixedScaled(size / 60000, decimals, scaledDecimals, 5, ' min');
+ } else if (Math.abs(size) < 86400000) {
+ // Less than one day, divide in hours
+ return toFixedScaled(size / 3600000, decimals, scaledDecimals, 7, ' hour');
+ } else if (Math.abs(size) < 31536000000) {
+ // Less than one year, divide in days
+ return toFixedScaled(size / 86400000, decimals, scaledDecimals, 8, ' day');
+ }
+
+ return toFixedScaled(size / 31536000000, decimals, scaledDecimals, 10, ' year');
+}
+
+export function toSeconds(size: number, decimals: number, scaledDecimals: number) {
+ if (size === null) {
+ return '';
+ }
+
+ // Less than 1 µs, divide in ns
+ if (Math.abs(size) < 0.000001) {
+ return toFixedScaled(size * 1e9, decimals, scaledDecimals - decimals, -9, ' ns');
+ }
+ // Less than 1 ms, divide in µs
+ if (Math.abs(size) < 0.001) {
+ return toFixedScaled(size * 1e6, decimals, scaledDecimals - decimals, -6, ' µs');
+ }
+ // Less than 1 second, divide in ms
+ if (Math.abs(size) < 1) {
+ return toFixedScaled(size * 1e3, decimals, scaledDecimals - decimals, -3, ' ms');
+ }
+
+ if (Math.abs(size) < 60) {
+ return toFixed(size, decimals) + ' s';
+ } else if (Math.abs(size) < 3600) {
+ // Less than 1 hour, divide in minutes
+ return toFixedScaled(size / 60, decimals, scaledDecimals, 1, ' min');
+ } else if (Math.abs(size) < 86400) {
+ // Less than one day, divide in hours
+ return toFixedScaled(size / 3600, decimals, scaledDecimals, 4, ' hour');
+ } else if (Math.abs(size) < 604800) {
+ // Less than one week, divide in days
+ return toFixedScaled(size / 86400, decimals, scaledDecimals, 5, ' day');
+ } else if (Math.abs(size) < 31536000) {
+ // Less than one year, divide in week
+ return toFixedScaled(size / 604800, decimals, scaledDecimals, 6, ' week');
+ }
+
+ return toFixedScaled(size / 3.15569e7, decimals, scaledDecimals, 7, ' year');
+}
+
+export function toMinutes(size: number, decimals: number, scaledDecimals: number) {
+ if (size === null) {
+ return '';
+ }
+
+ if (Math.abs(size) < 60) {
+ return toFixed(size, decimals) + ' min';
+ } else if (Math.abs(size) < 1440) {
+ return toFixedScaled(size / 60, decimals, scaledDecimals, 2, ' hour');
+ } else if (Math.abs(size) < 10080) {
+ return toFixedScaled(size / 1440, decimals, scaledDecimals, 3, ' day');
+ } else if (Math.abs(size) < 604800) {
+ return toFixedScaled(size / 10080, decimals, scaledDecimals, 4, ' week');
+ } else {
+ return toFixedScaled(size / 5.25948e5, decimals, scaledDecimals, 5, ' year');
+ }
+}
+
+export function toHours(size: number, decimals: number, scaledDecimals: number) {
+ if (size === null) {
+ return '';
+ }
+
+ if (Math.abs(size) < 24) {
+ return toFixed(size, decimals) + ' hour';
+ } else if (Math.abs(size) < 168) {
+ return toFixedScaled(size / 24, decimals, scaledDecimals, 2, ' day');
+ } else if (Math.abs(size) < 8760) {
+ return toFixedScaled(size / 168, decimals, scaledDecimals, 3, ' week');
+ } else {
+ return toFixedScaled(size / 8760, decimals, scaledDecimals, 4, ' year');
+ }
+}
+
+export function toDays(size: number, decimals: number, scaledDecimals: number) {
+ if (size === null) {
+ return '';
+ }
+
+ if (Math.abs(size) < 7) {
+ return toFixed(size, decimals) + ' day';
+ } else if (Math.abs(size) < 365) {
+ return toFixedScaled(size / 7, decimals, scaledDecimals, 2, ' week');
+ } else {
+ return toFixedScaled(size / 365, decimals, scaledDecimals, 3, ' year');
+ }
+}
+
+export function toDuration(size: number, decimals: number, timeScale: Interval): string {
+ if (size === null) {
+ return '';
+ }
+ if (size === 0) {
+ return '0 ' + timeScale + 's';
+ }
+ if (size < 0) {
+ return toDuration(-size, decimals, timeScale) + ' ago';
+ }
+
+ const units = [
+ { long: Interval.Year },
+ { long: Interval.Month },
+ { long: Interval.Week },
+ { long: Interval.Day },
+ { long: Interval.Hour },
+ { long: Interval.Minute },
+ { long: Interval.Second },
+ { long: Interval.Millisecond },
+ ];
+ // convert $size to milliseconds
+ // intervals_in_seconds uses seconds (duh), convert them to milliseconds here to minimize floating point errors
+ size *= INTERVALS_IN_SECONDS[timeScale] * 1000;
+
+ const strings = [];
+ // after first value >= 1 print only $decimals more
+ let decrementDecimals = false;
+ for (let i = 0; i < units.length && decimals >= 0; i++) {
+ const interval = INTERVALS_IN_SECONDS[units[i].long] * 1000;
+ const value = size / interval;
+ if (value >= 1 || decrementDecimals) {
+ decrementDecimals = true;
+ const floor = Math.floor(value);
+ const unit = units[i].long + (floor !== 1 ? 's' : '');
+ strings.push(floor + ' ' + unit);
+ size = size % interval;
+ decimals--;
+ }
+ }
+
+ return strings.join(', ');
+}
+
+export function toClock(size: number, decimals?: number) {
+ if (size === null) {
+ return '';
+ }
+
+ // < 1 second
+ if (size < 1000) {
+ return moment.utc(size).format('SSS\\m\\s');
+ }
+
+ // < 1 minute
+ if (size < 60000) {
+ let format = 'ss\\s:SSS\\m\\s';
+ if (decimals === 0) {
+ format = 'ss\\s';
+ }
+ return moment.utc(size).format(format);
+ }
+
+ // < 1 hour
+ if (size < 3600000) {
+ let format = 'mm\\m:ss\\s:SSS\\m\\s';
+ if (decimals === 0) {
+ format = 'mm\\m';
+ } else if (decimals === 1) {
+ format = 'mm\\m:ss\\s';
+ }
+ return moment.utc(size).format(format);
+ }
+
+ let format = 'mm\\m:ss\\s:SSS\\m\\s';
+
+ const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
+
+ if (decimals === 0) {
+ format = '';
+ } else if (decimals === 1) {
+ format = 'mm\\m';
+ } else if (decimals === 2) {
+ format = 'mm\\m:ss\\s';
+ }
+
+ return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
+}
+
+export function toDurationInMilliseconds(size: number, decimals: number) {
+ return toDuration(size, decimals, Interval.Millisecond);
+}
+
+export function toDurationInSeconds(size: number, decimals: number) {
+ return toDuration(size, decimals, Interval.Second);
+}
+
+export function toDurationInHoursMinutesSeconds(size: number) {
+ const strings = [];
+ const numHours = Math.floor(size / 3600);
+ const numMinutes = Math.floor((size % 3600) / 60);
+ const numSeconds = Math.floor((size % 3600) % 60);
+ numHours > 9 ? strings.push('' + numHours) : strings.push('0' + numHours);
+ numMinutes > 9 ? strings.push('' + numMinutes) : strings.push('0' + numMinutes);
+ numSeconds > 9 ? strings.push('' + numSeconds) : strings.push('0' + numSeconds);
+ return strings.join(':');
+}
+
+export function toTimeTicks(size: number, decimals: number, scaledDecimals: number) {
+ return toSeconds(size, decimals, scaledDecimals);
+}
+
+export function toClockMilliseconds(size: number, decimals: number) {
+ return toClock(size, decimals);
+}
+
+export function toClockSeconds(size: number, decimals: number) {
+ return toClock(size * 1000, decimals);
+}
+
+export function dateTimeAsIso(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
+ const time = isUtc ? moment.utc(value) : moment(value);
+
+ if (moment().isSame(value, 'day')) {
+ return time.format('HH:mm:ss');
+ }
+ return time.format('YYYY-MM-DD HH:mm:ss');
+}
+
+export function dateTimeAsUS(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
+ const time = isUtc ? moment.utc(value) : moment(value);
+
+ if (moment().isSame(value, 'day')) {
+ return time.format('h:mm:ss a');
+ }
+ return time.format('MM/DD/YYYY h:mm:ss a');
+}
+
+export function dateTimeFromNow(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
+ const time = isUtc ? moment.utc(value) : moment(value);
+ return time.fromNow();
+}
diff --git a/packages/grafana-ui/src/utils/valueFormats/symbolFormatters.test.ts b/packages/grafana-ui/src/utils/valueFormats/symbolFormatters.test.ts
new file mode 100644
index 00000000000..49278711608
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/symbolFormatters.test.ts
@@ -0,0 +1,7 @@
+import { currency } from './symbolFormatters';
+
+describe('Currency', () => {
+ it('should format as usd', () => {
+ expect(currency('$')(1532.82, 1, -1)).toEqual('$1.53K');
+ });
+});
diff --git a/packages/grafana-ui/src/utils/valueFormats/symbolFormatters.ts b/packages/grafana-ui/src/utils/valueFormats/symbolFormatters.ts
new file mode 100644
index 00000000000..66808143daa
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/symbolFormatters.ts
@@ -0,0 +1,30 @@
+import { scaledUnits } from './valueFormats';
+
+export function currency(symbol: string) {
+ const units = ['', 'K', 'M', 'B', 'T'];
+ const scaler = scaledUnits(1000, units);
+ return (size: number, decimals: number, scaledDecimals: number) => {
+ if (size === null) {
+ return '';
+ }
+ const scaled = scaler(size, decimals, scaledDecimals);
+ return symbol + scaled;
+ };
+}
+
+export function binarySIPrefix(unit: string, offset = 0) {
+ const prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'].slice(offset);
+ const units = prefixes.map(p => {
+ return ' ' + p + unit;
+ });
+ return scaledUnits(1024, units);
+}
+
+export function decimalSIPrefix(unit: string, offset = 0) {
+ let prefixes = ['n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
+ prefixes = prefixes.slice(3 + (offset || 0));
+ const units = prefixes.map(p => {
+ return ' ' + p + unit;
+ });
+ return scaledUnits(1000, units);
+}
diff --git a/packages/grafana-ui/src/utils/valueFormats/valueFormats.ts b/packages/grafana-ui/src/utils/valueFormats/valueFormats.ts
new file mode 100644
index 00000000000..0a56ce58e5b
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueFormats/valueFormats.ts
@@ -0,0 +1,166 @@
+import { getCategories } from './categories';
+
+type ValueFormatter = (value: number, decimals?: number, scaledDecimals?: number, isUtc?: boolean) => string;
+
+interface ValueFormat {
+ name: string;
+ id: string;
+ fn: ValueFormatter;
+}
+
+export interface ValueFormatCategory {
+ name: string;
+ formats: ValueFormat[];
+}
+
+interface ValueFormatterIndex {
+ [id: string]: ValueFormatter;
+}
+
+// Globals & formats cache
+let categories: ValueFormatCategory[] = [];
+const index: ValueFormatterIndex = {};
+let hasBuiltIndex = false;
+
+export function toFixed(value: number, decimals?: number): string {
+ if (value === null) {
+ return '';
+ }
+
+ const factor = decimals ? Math.pow(10, Math.max(0, decimals)) : 1;
+ const formatted = String(Math.round(value * factor) / factor);
+
+ // if exponent return directly
+ if (formatted.indexOf('e') !== -1 || value === 0) {
+ return formatted;
+ }
+
+ // If tickDecimals was specified, ensure that we have exactly that
+ // much precision; otherwise default to the value's own precision.
+ if (decimals != null) {
+ const decimalPos = formatted.indexOf('.');
+ const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1;
+ if (precision < decimals) {
+ return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision);
+ }
+ }
+
+ return formatted;
+}
+
+export function toFixedScaled(
+ value: number,
+ decimals: number,
+ scaledDecimals: number,
+ additionalDecimals: number,
+ ext: string
+) {
+ if (scaledDecimals === null) {
+ return toFixed(value, decimals) + ext;
+ } else {
+ return toFixed(value, scaledDecimals + additionalDecimals) + ext;
+ }
+}
+
+export function toFixedUnit(unit: string) {
+ return (size: number, decimals: number) => {
+ if (size === null) {
+ return '';
+ }
+ return toFixed(size, decimals) + ' ' + unit;
+ };
+}
+
+// Formatter which scales the unit string geometrically according to the given
+// numeric factor. Repeatedly scales the value down by the factor until it is
+// less than the factor in magnitude, or the end of the array is reached.
+export function scaledUnits(factor: number, extArray: string[]) {
+ return (size: number, decimals: number, scaledDecimals: number) => {
+ if (size === null) {
+ return '';
+ }
+
+ let steps = 0;
+ const limit = extArray.length;
+
+ while (Math.abs(size) >= factor) {
+ steps++;
+ size /= factor;
+
+ if (steps >= limit) {
+ return 'NA';
+ }
+ }
+
+ if (steps > 0 && scaledDecimals !== null) {
+ decimals = scaledDecimals + 3 * steps;
+ }
+
+ return toFixed(size, decimals) + extArray[steps];
+ };
+}
+
+export function locale(value: number, decimals: number) {
+ if (value == null) {
+ return '';
+ }
+ return value.toLocaleString(undefined, { maximumFractionDigits: decimals });
+}
+
+export function simpleCountUnit(symbol: string) {
+ const units = ['', 'K', 'M', 'B', 'T'];
+ const scaler = scaledUnits(1000, units);
+ return (size: number, decimals: number, scaledDecimals: number) => {
+ if (size === null) {
+ return '';
+ }
+ const scaled = scaler(size, decimals, scaledDecimals);
+ return scaled + ' ' + symbol;
+ };
+}
+
+function buildFormats() {
+ categories = getCategories();
+
+ for (const cat of categories) {
+ for (const format of cat.formats) {
+ index[format.id] = format.fn;
+ }
+ }
+
+ hasBuiltIndex = true;
+}
+
+export function getValueFormat(id: string): ValueFormatter {
+ if (!hasBuiltIndex) {
+ buildFormats();
+ }
+
+ return index[id];
+}
+
+export function getValueFormatterIndex(): ValueFormatterIndex {
+ if (!hasBuiltIndex) {
+ buildFormats();
+ }
+
+ return index;
+}
+
+export function getValueFormats() {
+ if (!hasBuiltIndex) {
+ buildFormats();
+ }
+
+ return categories.map(cat => {
+ return {
+ text: cat.name,
+ submenu: cat.formats.map(format => {
+ return {
+ text: format.name,
+ value: format.id,
+ };
+ }),
+ };
+ });
+}
diff --git a/packages/grafana-ui/src/utils/valueMappings.test.ts b/packages/grafana-ui/src/utils/valueMappings.test.ts
new file mode 100644
index 00000000000..d37e0beedab
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueMappings.test.ts
@@ -0,0 +1,81 @@
+import { getMappedValue } from './valueMappings';
+import { ValueMapping, MappingType } from '../types/panel';
+
+describe('Format value with value mappings', () => {
+ it('should return undefined with no valuemappings', () => {
+ const valueMappings: ValueMapping[] = [];
+ const value = '10';
+
+ expect(getMappedValue(valueMappings, value)).toBeUndefined();
+ });
+
+ it('should return undefined with no matching valuemappings', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+ { id: 1, operator: '', text: '1-9', type: MappingType.RangeToText, from: '1', to: '9' },
+ ];
+ const value = '10';
+
+ expect(getMappedValue(valueMappings, value)).toBeUndefined();
+ });
+
+ it('should return first matching mapping with lowest id', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+ { id: 1, operator: '', text: 'tio', type: MappingType.ValueToText, value: '10' },
+ ];
+ const value = '10';
+
+ expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
+ });
+
+ it('should return if value is null and value to text mapping value is null', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+ { id: 1, operator: '', text: '', type: MappingType.ValueToText, value: 'null' },
+ ];
+ const value = null;
+
+ expect(getMappedValue(valueMappings, value).text).toEqual('');
+ });
+
+ it('should return if value is null and range to text mapping from and to is null', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '', type: MappingType.RangeToText, from: 'null', to: 'null' },
+ { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+ ];
+ const value = null;
+
+ expect(getMappedValue(valueMappings, value).text).toEqual('');
+ });
+
+ it('should return rangeToText mapping where value equals to', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '1-10', type: MappingType.RangeToText, from: '1', to: '10' },
+ { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+ ];
+ const value = '10';
+
+ expect(getMappedValue(valueMappings, value).text).toEqual('1-10');
+ });
+
+ it('should return rangeToText mapping where value equals from', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '10-20', type: MappingType.RangeToText, from: '10', to: '20' },
+ { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+ ];
+ const value = '10';
+
+ expect(getMappedValue(valueMappings, value).text).toEqual('10-20');
+ });
+
+ it('should return rangeToText mapping where value is between from and to', () => {
+ const valueMappings: ValueMapping[] = [
+ { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
+ { id: 1, operator: '', text: 'elva', type: MappingType.ValueToText, value: '11' },
+ ];
+ const value = '10';
+
+ expect(getMappedValue(valueMappings, value).text).toEqual('1-20');
+ });
+});
diff --git a/packages/grafana-ui/src/utils/valueMappings.ts b/packages/grafana-ui/src/utils/valueMappings.ts
new file mode 100644
index 00000000000..c9b926ea0a4
--- /dev/null
+++ b/packages/grafana-ui/src/utils/valueMappings.ts
@@ -0,0 +1,89 @@
+import { ValueMapping, MappingType, ValueMap, RangeMap } from '../types';
+
+export type TimeSeriesValue = string | number | null;
+
+const addValueToTextMappingText = (
+ allValueMappings: ValueMapping[],
+ valueToTextMapping: ValueMap,
+ value: TimeSeriesValue
+) => {
+ if (valueToTextMapping.value === undefined) {
+ return allValueMappings;
+ }
+
+ if (value === null && valueToTextMapping.value && valueToTextMapping.value.toLowerCase() === 'null') {
+ return allValueMappings.concat(valueToTextMapping);
+ }
+
+ const valueAsNumber = parseFloat(value as string);
+ const valueToTextMappingAsNumber = parseFloat(valueToTextMapping.value as string);
+
+ if (isNaN(valueAsNumber) || isNaN(valueToTextMappingAsNumber)) {
+ return allValueMappings;
+ }
+
+ if (valueAsNumber !== valueToTextMappingAsNumber) {
+ return allValueMappings;
+ }
+
+ return allValueMappings.concat(valueToTextMapping);
+};
+
+const addRangeToTextMappingText = (
+ allValueMappings: ValueMapping[],
+ rangeToTextMapping: RangeMap,
+ value: TimeSeriesValue
+) => {
+ if (rangeToTextMapping.from === undefined || rangeToTextMapping.to === undefined || value === undefined) {
+ return allValueMappings;
+ }
+
+ if (
+ value === null &&
+ rangeToTextMapping.from &&
+ rangeToTextMapping.to &&
+ rangeToTextMapping.from.toLowerCase() === 'null' &&
+ rangeToTextMapping.to.toLowerCase() === 'null'
+ ) {
+ return allValueMappings.concat(rangeToTextMapping);
+ }
+
+ const valueAsNumber = parseFloat(value as string);
+ const fromAsNumber = parseFloat(rangeToTextMapping.from as string);
+ const toAsNumber = parseFloat(rangeToTextMapping.to as string);
+
+ if (isNaN(valueAsNumber) || isNaN(fromAsNumber) || isNaN(toAsNumber)) {
+ return allValueMappings;
+ }
+
+ if (valueAsNumber >= fromAsNumber && valueAsNumber <= toAsNumber) {
+ return allValueMappings.concat(rangeToTextMapping);
+ }
+
+ return allValueMappings;
+};
+
+const getAllFormattedValueMappings = (valueMappings: ValueMapping[], value: TimeSeriesValue) => {
+ const allFormattedValueMappings = valueMappings.reduce(
+ (allValueMappings, valueMapping) => {
+ if (valueMapping.type === MappingType.ValueToText) {
+ allValueMappings = addValueToTextMappingText(allValueMappings, valueMapping as ValueMap, value);
+ } else if (valueMapping.type === MappingType.RangeToText) {
+ allValueMappings = addRangeToTextMappingText(allValueMappings, valueMapping as RangeMap, value);
+ }
+
+ return allValueMappings;
+ },
+ [] as ValueMapping[]
+ );
+
+ allFormattedValueMappings.sort((t1, t2) => {
+ return t1.id - t2.id;
+ });
+
+ return allFormattedValueMappings;
+};
+
+export const getMappedValue = (valueMappings: ValueMapping[], value: TimeSeriesValue): ValueMapping => {
+ return getAllFormattedValueMappings(valueMappings, value)[0];
+};
diff --git a/packages/grafana-ui/src/visualizations/index.ts b/packages/grafana-ui/src/visualizations/index.ts
deleted file mode 100644
index 967432d37c9..00000000000
--- a/packages/grafana-ui/src/visualizations/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { Graph } from './Graph/Graph';
diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json
index ed6009f1ebd..22d336b85f5 100644
--- a/packages/grafana-ui/tsconfig.json
+++ b/packages/grafana-ui/tsconfig.json
@@ -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
+ },
}
diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile
index 4d4f6539972..d4f2f2aa7a3 100644
--- a/packaging/docker/Dockerfile
+++ b/packaging/docker/Dockerfile
@@ -1,4 +1,5 @@
-FROM debian:stretch-slim
+ARG BASE_IMAGE=debian:stretch-slim
+FROM ${BASE_IMAGE}
ARG GRAFANA_TGZ="grafana-latest.linux-x64.tar.gz"
@@ -10,7 +11,8 @@ COPY ${GRAFANA_TGZ} /tmp/grafana.tar.gz
RUN mkdir /tmp/grafana && tar xfvz /tmp/grafana.tar.gz --strip-components=1 -C /tmp/grafana
-FROM debian:stretch-slim
+ARG BASE_IMAGE=debian:stretch-slim
+FROM ${BASE_IMAGE}
ARG GF_UID="472"
ARG GF_GID="472"
diff --git a/packaging/docker/build-deploy.sh b/packaging/docker/build-deploy.sh
index ac3226a4a61..22655bead8c 100755
--- a/packaging/docker/build-deploy.sh
+++ b/packaging/docker/build-deploy.sh
@@ -8,6 +8,5 @@ docker login -u "$DOCKER_USER" -p "$DOCKER_PASS"
./push_to_docker_hub.sh "$_grafana_version"
if echo "$_grafana_version" | grep -q "^master-"; then
- apk add --no-cache curl
./deploy_to_k8s.sh "grafana/grafana-dev:$_grafana_version"
fi
diff --git a/packaging/docker/build.sh b/packaging/docker/build.sh
index c303c71cd5f..a522363089b 100755
--- a/packaging/docker/build.sh
+++ b/packaging/docker/build.sh
@@ -1,25 +1,49 @@
#!/bin/sh
-_grafana_tag=$1
+_grafana_tag=${1:-}
+_docker_repo=${2:-grafana/grafana}
# If the tag starts with v, treat this as a official release
if echo "$_grafana_tag" | grep -q "^v"; then
_grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2)
- _docker_repo=${2:-grafana/grafana}
else
_grafana_version=$_grafana_tag
- _docker_repo=${2:-grafana/grafana-dev}
fi
echo "Building ${_docker_repo}:${_grafana_version}"
-docker build \
- --tag "${_docker_repo}:${_grafana_version}" \
- --no-cache=true .
+export DOCKER_CLI_EXPERIMENTAL=enabled
+
+# Build grafana image for a specific arch
+docker_build () {
+ base_image=$1
+ grafana_tgz=$2
+ tag=$3
+
+ docker build \
+ --build-arg BASE_IMAGE=${base_image} \
+ --build-arg GRAFANA_TGZ=${grafana_tgz} \
+ --tag "${tag}" \
+ --no-cache=true .
+}
+
+# Tag docker images of all architectures
+docker_tag_all () {
+ repo=$1
+ tag=$2
+ docker tag "${_docker_repo}:${_grafana_version}" "${repo}:${tag}"
+ docker tag "${_docker_repo}-arm32v7-linux:${_grafana_version}" "${repo}-arm32v7-linux:${tag}"
+ docker tag "${_docker_repo}-arm64v8-linux:${_grafana_version}" "${repo}-arm64v8-linux:${tag}"
+}
+
+docker_build "debian:stretch-slim" "grafana-latest.linux-x64.tar.gz" "${_docker_repo}:${_grafana_version}"
+docker_build "arm32v7/debian:stretch-slim" "grafana-latest.linux-armv7.tar.gz" "${_docker_repo}-arm32v7-linux:${_grafana_version}"
+docker_build "arm64v8/debian:stretch-slim" "grafana-latest.linux-arm64.tar.gz" "${_docker_repo}-arm64v8-linux:${_grafana_version}"
# Tag as 'latest' for official release; otherwise tag as grafana/grafana:master
if echo "$_grafana_tag" | grep -q "^v"; then
- docker tag "${_docker_repo}:${_grafana_version}" "${_docker_repo}:latest"
+ docker_tag_all "${_docker_repo}" "latest"
else
- docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana:master"
+ docker_tag_all "${_docker_repo}" "master"
+ docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana-dev:${_grafana_version}"
fi
diff --git a/packaging/docker/push_to_docker_hub.sh b/packaging/docker/push_to_docker_hub.sh
index 526c216f8fa..37b5ae0095c 100755
--- a/packaging/docker/push_to_docker_hub.sh
+++ b/packaging/docker/push_to_docker_hub.sh
@@ -1,24 +1,46 @@
#!/bin/sh
set -e
-_grafana_tag=$1
+_grafana_tag=${1:-}
+_docker_repo=${2:-grafana/grafana}
# If the tag starts with v, treat this as a official release
if echo "$_grafana_tag" | grep -q "^v"; then
_grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2)
- _docker_repo=${2:-grafana/grafana}
else
_grafana_version=$_grafana_tag
- _docker_repo=${2:-grafana/grafana-dev}
fi
+export DOCKER_CLI_EXPERIMENTAL=enabled
+
echo "pushing ${_docker_repo}:${_grafana_version}"
-docker push "${_docker_repo}:${_grafana_version}"
+
+
+docker_push_all () {
+ repo=$1
+ tag=$2
+
+ # Push each image individually
+ docker push "${repo}:${tag}"
+ docker push "${repo}-arm32v7-linux:${tag}"
+ docker push "${repo}-arm64v8-linux:${tag}"
+
+ # Create and push a multi-arch manifest
+ docker manifest create "${repo}:${tag}" \
+ "${repo}:${tag}" \
+ "${repo}-arm32v7-linux:${tag}" \
+ "${repo}-arm64v8-linux:${tag}"
+
+ docker manifest push "${repo}:${tag}"
+}
if echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -vq "beta"; then
echo "pushing ${_docker_repo}:latest"
- docker push "${_docker_repo}:latest"
+ docker_push_all "${_docker_repo}" "latest"
+ docker_push_all "${_docker_repo}" "${_grafana_version}"
+elif echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -q "beta"; then
+ docker_push_all "${_docker_repo}" "${_grafana_version}"
elif echo "$_grafana_tag" | grep -q "master"; then
- echo "pushing grafana/grafana:master"
- docker push grafana/grafana:master
+ docker_push_all "${_docker_repo}" "master"
+ docker push "grafana/grafana-dev:${_grafana_version}"
fi
diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go
index 66b3b504946..19fb4efd7e8 100644
--- a/pkg/api/alerting.go
+++ b/pkg/api/alerting.go
@@ -212,6 +212,10 @@ func GetAlertNotificationByID(c *m.ReqContext) Response {
return Error(500, "Failed to get alert notifications", err)
}
+ if query.Result == nil {
+ return Error(404, "Alert notification not found", nil)
+ }
+
return JSON(200, dtos.NewAlertNotification(query.Result))
}
diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go
index 331beeef5e4..168193e377f 100644
--- a/pkg/api/alerting_test.go
+++ b/pkg/api/alerting_test.go
@@ -119,6 +119,12 @@ func TestAlertingApiEndpoint(t *testing.T) {
So(getAlertsQuery.Limit, ShouldEqual, 5)
So(getAlertsQuery.Query, ShouldEqual, "alertQuery")
})
+
+ loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/alert-notifications/1", "/alert-notifications/:notificationId", m.ROLE_ADMIN, func(sc *scenarioContext) {
+ sc.handlerFunc = GetAlertNotificationByID
+ sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 404)
+ })
})
}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 0526ee80afe..07cb712f794 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -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) {
diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go
index a2137089fc6..d77d9d87b4a 100644
--- a/pkg/api/app_routes.go
+++ b/pkg/api/app_routes.go
@@ -35,7 +35,7 @@ func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) {
for _, plugin := range plugins.Apps {
for _, route := range plugin.Routes {
- url := util.JoinUrlFragments("/api/plugin-proxy/"+plugin.Id, route.Path)
+ url := util.JoinURLFragments("/api/plugin-proxy/"+plugin.Id, route.Path)
handlers := make([]macaron.Handler, 0)
handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{
ReqSignedIn: true,
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index 8b66a7a468b..eb1f89e3f22 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -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) {}
diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go
index c037831f341..dcdc3976ec5 100644
--- a/pkg/api/dtos/alerting.go
+++ b/pkg/api/dtos/alerting.go
@@ -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"`
diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go
index 8f5457cf271..ed7054050e4 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -165,6 +165,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
"externalUserMngInfo": setting.ExternalUserMngInfo,
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
+ "viewersCanEdit": setting.ViewersCanEdit,
+ "disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
"buildInfo": map[string]interface{}{
"version": setting.BuildVersion,
"commit": setting.BuildCommit,
diff --git a/pkg/api/grafana_com_proxy.go b/pkg/api/grafana_com_proxy.go
index afd3bb9bf8e..541d66b0b35 100644
--- a/pkg/api/grafana_com_proxy.go
+++ b/pkg/api/grafana_com_proxy.go
@@ -30,7 +30,7 @@ func ReverseProxyGnetReq(proxyPath string) *httputil.ReverseProxy {
req.URL.Host = url.Host
req.Host = url.Host
- req.URL.Path = util.JoinUrlFragments(url.Path+"/api", proxyPath)
+ req.URL.Path = util.JoinURLFragments(url.Path+"/api", proxyPath)
// clear cookie headers
req.Header.Del("Cookie")
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index d4d7b41bec5..7b7c1478a4c 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -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
diff --git a/pkg/api/index.go b/pkg/api/index.go
index 2980d8a5c6b..e90db84016d 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -140,7 +140,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
Children: dashboardChildNavs,
})
- if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) {
+ if setting.ExploreEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || setting.ViewersCanEdit) {
data.NavTree = append(data.NavTree, &dtos.NavLink{
Text: "Explore",
Id: "explore",
diff --git a/pkg/api/login.go b/pkg/api/login.go
index 05afc40e59a..50c62e0835a 100644
--- a/pkg/api/login.go
+++ b/pkg/api/login.go
@@ -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
+}
diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go
index fe4fa93b621..4160d48733e 100644
--- a/pkg/api/login_oauth.go
+++ b/pkg/api/login_oauth.go
@@ -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")
}
diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go
index dfb2cf045ed..835b03a2cc9 100644
--- a/pkg/api/org_invite.go
+++ b/pkg/api/org_invite.go
@@ -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()
diff --git a/pkg/api/pluginproxy/ds_auth_provider.go b/pkg/api/pluginproxy/ds_auth_provider.go
index 5c5776eec07..7e0a88bae3d 100644
--- a/pkg/api/pluginproxy/ds_auth_provider.go
+++ b/pkg/api/pluginproxy/ds_auth_provider.go
@@ -39,7 +39,7 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route
req.URL.Scheme = routeURL.Scheme
req.URL.Host = routeURL.Host
req.Host = routeURL.Host
- req.URL.Path = util.JoinUrlFragments(routeURL.Path, proxyPath)
+ req.URL.Path = util.JoinURLFragments(routeURL.Path, proxyPath)
if err := addHeaders(&req.Header, route, data); err != nil {
logger.Error("Failed to render plugin headers", "error", err)
diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go
index 38a2fd187e3..a0ad96a6977 100644
--- a/pkg/api/pluginproxy/ds_proxy.go
+++ b/pkg/api/pluginproxy/ds_proxy.go
@@ -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},
}
}
@@ -139,19 +139,19 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
reqQueryVals := req.URL.Query()
if proxy.ds.Type == m.DS_INFLUXDB_08 {
- req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, "db/"+proxy.ds.Database+"/"+proxy.proxyPath)
+ req.URL.Path = util.JoinURLFragments(proxy.targetUrl.Path, "db/"+proxy.ds.Database+"/"+proxy.proxyPath)
reqQueryVals.Add("u", proxy.ds.User)
reqQueryVals.Add("p", proxy.ds.Password)
req.URL.RawQuery = reqQueryVals.Encode()
} else if proxy.ds.Type == m.DS_INFLUXDB {
- req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath)
+ req.URL.Path = util.JoinURLFragments(proxy.targetUrl.Path, proxy.proxyPath)
req.URL.RawQuery = reqQueryVals.Encode()
if !proxy.ds.BasicAuth {
req.Header.Del("Authorization")
req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.User, proxy.ds.Password))
}
} else {
- req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath)
+ req.URL.Path = util.JoinURLFragments(proxy.targetUrl.Path, proxy.proxyPath)
}
if proxy.ds.BasicAuth {
req.Header.Del("Authorization")
diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go
index 66e4498c283..4cf1dbd7cde 100644
--- a/pkg/api/pluginproxy/pluginproxy.go
+++ b/pkg/api/pluginproxy/pluginproxy.go
@@ -46,7 +46,7 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
req.URL.Host = targetURL.Host
req.Host = targetURL.Host
- req.URL.Path = util.JoinUrlFragments(targetURL.Path, proxyPath)
+ req.URL.Path = util.JoinURLFragments(targetURL.Path, proxyPath)
// clear cookie headers
req.Header.Del("Cookie")
diff --git a/pkg/api/render.go b/pkg/api/render.go
index cf672af9bea..3fcb99d8121 100644
--- a/pkg/api/render.go
+++ b/pkg/api/render.go
@@ -14,7 +14,7 @@ import (
)
func (hs *HTTPServer) RenderToPng(c *m.ReqContext) {
- queryReader, err := util.NewUrlQueryReader(c.Req.URL)
+ queryReader, err := util.NewURLQueryReader(c.Req.URL)
if err != nil {
c.Handle(400, "Render parameters error", err)
return
diff --git a/pkg/api/signup.go b/pkg/api/signup.go
index 200a3ebc9d1..fe577dd9ef9 100644
--- a/pkg/api/signup.go
+++ b/pkg/api/signup.go
@@ -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)
diff --git a/pkg/components/dashdiffs/formatter_json.go b/pkg/components/dashdiffs/formatter_json.go
index 488a345d492..09dac1ae5ac 100644
--- a/pkg/components/dashdiffs/formatter_json.go
+++ b/pkg/components/dashdiffs/formatter_json.go
@@ -206,10 +206,9 @@ func (f *JSONFormatter) processObject(object map[string]interface{}, deltas []di
// Added
for _, delta := range deltas {
- switch delta.(type) {
+ switch delta := delta.(type) {
case *diff.Added:
- d := delta.(*diff.Added)
- f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
+ f.printRecursive(delta.Position.String(), delta.Value, ChangeAdded)
}
}
@@ -222,9 +221,8 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
if len(matchedDeltas) > 0 {
for _, matchedDelta := range matchedDeltas {
- switch matchedDelta.(type) {
+ switch matchedDelta := matchedDelta.(type) {
case *diff.Object:
- d := matchedDelta.(*diff.Object)
switch value.(type) {
case map[string]interface{}:
//ok
@@ -238,7 +236,7 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
f.print("{")
f.closeLine()
f.push(positionStr, len(o), false)
- f.processObject(o, d.Deltas)
+ f.processObject(o, matchedDelta.Deltas)
f.pop()
f.newLine(ChangeNil)
f.print("}")
@@ -246,7 +244,6 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
f.closeLine()
case *diff.Array:
- d := matchedDelta.(*diff.Array)
switch value.(type) {
case []interface{}:
//ok
@@ -260,7 +257,7 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
f.print("[")
f.closeLine()
f.push(positionStr, len(a), true)
- f.processArray(a, d.Deltas)
+ f.processArray(a, matchedDelta.Deltas)
f.pop()
f.newLine(ChangeNil)
f.print("]")
@@ -268,27 +265,23 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
f.closeLine()
case *diff.Added:
- d := matchedDelta.(*diff.Added)
- f.printRecursive(positionStr, d.Value, ChangeAdded)
+ f.printRecursive(positionStr, matchedDelta.Value, ChangeAdded)
f.size[len(f.size)-1]++
case *diff.Modified:
- d := matchedDelta.(*diff.Modified)
savedSize := f.size[len(f.size)-1]
- f.printRecursive(positionStr, d.OldValue, ChangeOld)
+ f.printRecursive(positionStr, matchedDelta.OldValue, ChangeOld)
f.size[len(f.size)-1] = savedSize
- f.printRecursive(positionStr, d.NewValue, ChangeNew)
+ f.printRecursive(positionStr, matchedDelta.NewValue, ChangeNew)
case *diff.TextDiff:
savedSize := f.size[len(f.size)-1]
- d := matchedDelta.(*diff.TextDiff)
- f.printRecursive(positionStr, d.OldValue, ChangeOld)
+ f.printRecursive(positionStr, matchedDelta.OldValue, ChangeOld)
f.size[len(f.size)-1] = savedSize
- f.printRecursive(positionStr, d.NewValue, ChangeNew)
+ f.printRecursive(positionStr, matchedDelta.NewValue, ChangeNew)
case *diff.Deleted:
- d := matchedDelta.(*diff.Deleted)
- f.printRecursive(positionStr, d.Value, ChangeDeleted)
+ f.printRecursive(positionStr, matchedDelta.Value, ChangeDeleted)
default:
return errors.New("Unknown Delta type detected")
@@ -305,13 +298,13 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, position diff.Position) (results []diff.Delta) {
results = make([]diff.Delta, 0)
for _, delta := range deltas {
- switch delta.(type) {
+ switch typedDelta := delta.(type) {
case diff.PostDelta:
- if delta.(diff.PostDelta).PostPosition() == position {
+ if typedDelta.PostPosition() == position {
results = append(results, delta)
}
case diff.PreDelta:
- if delta.(diff.PreDelta).PrePosition() == position {
+ if typedDelta.PrePosition() == position {
results = append(results, delta)
}
default:
@@ -417,20 +410,19 @@ func (f *JSONFormatter) print(a string) {
}
func (f *JSONFormatter) printRecursive(name string, value interface{}, change ChangeType) {
- switch value.(type) {
+ switch value := value.(type) {
case map[string]interface{}:
f.newLine(change)
f.printKey(name)
f.print("{")
f.closeLine()
- m := value.(map[string]interface{})
- size := len(m)
+ size := len(value)
f.push(name, size, false)
- keys := sortKeys(m)
+ keys := sortKeys(value)
for _, key := range keys {
- f.printRecursive(key, m[key], change)
+ f.printRecursive(key, value[key], change)
}
f.pop()
@@ -445,10 +437,9 @@ func (f *JSONFormatter) printRecursive(name string, value interface{}, change Ch
f.print("[")
f.closeLine()
- s := value.([]interface{})
- size := len(s)
+ size := len(value)
f.push("", size, true)
- for _, item := range s {
+ for _, item := range value {
f.printRecursive("", item, change)
}
f.pop()
diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go
index 93f69cadd46..422a03d3501 100644
--- a/pkg/components/imguploader/imguploader.go
+++ b/pkg/components/imguploader/imguploader.go
@@ -6,7 +6,6 @@ import (
"regexp"
"github.com/grafana/grafana/pkg/log"
-
"github.com/grafana/grafana/pkg/setting"
)
@@ -21,6 +20,10 @@ func (NopImageUploader) Upload(ctx context.Context, path string) (string, error)
return "", nil
}
+var (
+ logger = log.New("imguploader")
+)
+
func NewImageUploader() (ImageUploader, error) {
switch setting.ImageUploadProvider {
@@ -94,7 +97,7 @@ func NewImageUploader() (ImageUploader, error) {
}
if setting.ImageUploadProvider != "" {
- log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
+ logger.Error("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
}
return NopImageUploader{}, nil
diff --git a/pkg/log/log.go b/pkg/log/log.go
index 8f0522748ef..2e3b6303a6e 100644
--- a/pkg/log/log.go
+++ b/pkg/log/log.go
@@ -10,13 +10,11 @@ import (
"path/filepath"
"strings"
- "gopkg.in/ini.v1"
-
"github.com/go-stack/stack"
+ "github.com/grafana/grafana/pkg/util"
"github.com/inconshreveable/log15"
isatty "github.com/mattn/go-isatty"
-
- "github.com/grafana/grafana/pkg/util"
+ "gopkg.in/ini.v1"
)
var Root log15.Logger
@@ -58,10 +56,6 @@ func Debug(format string, v ...interface{}) {
Root.Debug(message)
}
-func Debug2(message string, v ...interface{}) {
- Root.Debug(message, v...)
-}
-
func Info(format string, v ...interface{}) {
var message string
if len(v) > 0 {
@@ -73,10 +67,6 @@ func Info(format string, v ...interface{}) {
Root.Info(message)
}
-func Info2(message string, v ...interface{}) {
- Root.Info(message, v...)
-}
-
func Warn(format string, v ...interface{}) {
var message string
if len(v) > 0 {
@@ -88,18 +78,10 @@ func Warn(format string, v ...interface{}) {
Root.Warn(message)
}
-func Warn2(message string, v ...interface{}) {
- Root.Warn(message, v...)
-}
-
func Error(skip int, format string, v ...interface{}) {
Root.Error(fmt.Sprintf(format, v...))
}
-func Error2(message string, v ...interface{}) {
- Root.Error(message, v...)
-}
-
func Critical(skip int, format string, v ...interface{}) {
Root.Crit(fmt.Sprintf(format, v...))
}
diff --git a/pkg/login/ext_user.go b/pkg/login/ext_user.go
index 1262c1cc44f..42fb37ff9d0 100644
--- a/pkg/login/ext_user.go
+++ b/pkg/login/ext_user.go
@@ -11,6 +11,10 @@ func init() {
bus.AddHandler("auth", UpsertUser)
}
+var (
+ logger = log.New("login.ext_user")
+)
+
func UpsertUser(cmd *m.UpsertUserCommand) error {
extUser := cmd.ExternalUser
@@ -135,7 +139,7 @@ func updateUser(user *m.User, extUser *m.ExternalUserInfo) error {
return nil
}
- log.Debug2("Syncing user info", "id", user.Id, "update", updateCmd)
+ logger.Debug("Syncing user info", "id", user.Id, "update", updateCmd)
return bus.Dispatch(updateCmd)
}
diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go
index 5faee1e3fa7..27248342c8d 100644
--- a/pkg/middleware/auth.go
+++ b/pkg/middleware/auth.go
@@ -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)
diff --git a/pkg/middleware/auth_proxy.go b/pkg/middleware/auth_proxy.go
index fc109ac707f..93ee577e3c6 100644
--- a/pkg/middleware/auth_proxy.go
+++ b/pkg/middleware/auth_proxy.go
@@ -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
diff --git a/pkg/middleware/dashboard_redirect_test.go b/pkg/middleware/dashboard_redirect_test.go
index 1e4d8293cae..ebef4c92730 100644
--- a/pkg/middleware/dashboard_redirect_test.go
+++ b/pkg/middleware/dashboard_redirect_test.go
@@ -20,7 +20,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
fakeDash.Id = 1
fakeDash.FolderId = 1
fakeDash.HasAcl = false
- fakeDash.Uid = util.GenerateShortUid()
+ fakeDash.Uid = util.GenerateShortUID()
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
query.Result = fakeDash
diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go
index ace72d998eb..3722ac3058f 100644
--- a/pkg/middleware/middleware.go
+++ b/pkg/middleware/middleware.go
@@ -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 == "" {
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index b9a8afce6c6..11740574d0b 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -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) {}
diff --git a/pkg/middleware/org_redirect.go b/pkg/middleware/org_redirect.go
index db263c2a17a..ca63733946c 100644
--- a/pkg/middleware/org_redirect.go
+++ b/pkg/middleware/org_redirect.go
@@ -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"
)
diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go
index fa08154b250..46b8776fdcc 100644
--- a/pkg/middleware/org_redirect_test.go
+++ b/pkg/middleware/org_redirect_test.go
@@ -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
diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go
index 92c3d62674d..4f2203a5d3d 100644
--- a/pkg/middleware/quota_test.go
+++ b/pkg/middleware/quota_test.go
@@ -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,
diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go
index c92150f3b7d..e041d42e56b 100644
--- a/pkg/middleware/recovery_test.go
+++ b/pkg/middleware/recovery_test.go
@@ -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())
diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go
deleted file mode 100644
index 19cfa368b49..00000000000
--- a/pkg/middleware/session.go
+++ /dev/null
@@ -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())
- }
- }
-}
diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go
index e0fd12937ed..0a26276e787 100644
--- a/pkg/models/alert_notifications.go
+++ b/pkg/models/alert_notifications.go
@@ -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
diff --git a/pkg/models/context.go b/pkg/models/context.go
index 7cb80a957c3..df970451304 100644
--- a/pkg/models/context.go
+++ b/pkg/models/context.go
@@ -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
diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go
index 3a8010e797b..0f3f56175fe 100644
--- a/pkg/models/dashboards.go
+++ b/pkg/models/dashboards.go
@@ -112,7 +112,7 @@ func NewDashboard(title string) *Dashboard {
func NewDashboardFolder(title string) *Dashboard {
folder := NewDashboard(title)
folder.IsFolder = true
- folder.Data.Set("schemaVersion", 16)
+ folder.Data.Set("schemaVersion", 17)
folder.Data.Set("version", 0)
folder.IsFolder = true
return folder
diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go
index 89439420d7a..e1cb185d92a 100644
--- a/pkg/models/datasource.go
+++ b/pkg/models/datasource.go
@@ -23,6 +23,7 @@ const (
DS_ACCESS_DIRECT = "direct"
DS_ACCESS_PROXY = "proxy"
DS_STACKDRIVER = "stackdriver"
+ DS_AZURE_MONITOR = "azure-monitor"
)
var (
@@ -73,6 +74,7 @@ var knownDatasourcePlugins = map[string]bool{
DS_MYSQL: true,
DS_MSSQL: true,
DS_STACKDRIVER: true,
+ DS_AZURE_MONITOR: true,
"opennms": true,
"abhisant-druid-datasource": true,
"dalmatinerdb-datasource": true,
diff --git a/pkg/plugins/frontend_plugin.go b/pkg/plugins/frontend_plugin.go
index 04af4060169..a1b96debae1 100644
--- a/pkg/plugins/frontend_plugin.go
+++ b/pkg/plugins/frontend_plugin.go
@@ -45,9 +45,9 @@ func (fp *FrontendPluginBase) setPathsBasedOnApp(app *AppPlugin) {
fp.BaseUrl = app.BaseUrl
if isExternalPlugin(app.PluginDir) {
- fp.Module = util.JoinUrlFragments("plugins/"+app.Id, appSubPath) + "/module"
+ fp.Module = util.JoinURLFragments("plugins/"+app.Id, appSubPath) + "/module"
} else {
- fp.Module = util.JoinUrlFragments("app/plugins/app/"+app.Id, appSubPath) + "/module"
+ fp.Module = util.JoinURLFragments("app/plugins/app/"+app.Id, appSubPath) + "/module"
}
}
diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go
index 4f15441bb2f..b10e4640f4b 100644
--- a/pkg/plugins/plugins.go
+++ b/pkg/plugins/plugins.go
@@ -169,7 +169,7 @@ func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err erro
}
if f.Name() == "node_modules" {
- return util.WalkSkipDir
+ return util.ErrWalkSkipDir
}
if f.IsDir() {
diff --git a/pkg/services/alerting/engine.go b/pkg/services/alerting/engine.go
index 0f8e24bcef5..22cbe2456b7 100644
--- a/pkg/services/alerting/engine.go
+++ b/pkg/services/alerting/engine.go
@@ -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()
diff --git a/pkg/services/alerting/engine_integration_test.go b/pkg/services/alerting/engine_integration_test.go
new file mode 100644
index 00000000000..aa518baae24
--- /dev/null
+++ b/pkg/services/alerting/engine_integration_test.go
@@ -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)
+}
diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go
index e33e3dc2af3..5b911c5a9ad 100644
--- a/pkg/services/alerting/extractor.go
+++ b/pkg/services/alerting/extractor.go
@@ -112,7 +112,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
frequency, err := getTimeDurationStringToSeconds(jsonAlert.Get("frequency").MustString())
if err != nil {
- return nil, ValidationError{Reason: "Could not parse frequency"}
+ return nil, ValidationError{Reason: err.Error()}
}
rawFor := jsonAlert.Get("for").MustString()
diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go
index 9665a657bb7..9c689fec921 100644
--- a/pkg/services/alerting/extractor_test.go
+++ b/pkg/services/alerting/extractor_test.go
@@ -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")
+ })
})
})
})
diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go
index 040d0991861..bd7ca087769 100644
--- a/pkg/services/alerting/interfaces.go
+++ b/pkg/services/alerting/interfaces.go
@@ -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
diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go
index 75c68615750..e1a550d48f4 100644
--- a/pkg/services/alerting/notifier.go
+++ b/pkg/services/alerting/notifier.go
@@ -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
}
diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go
index d4a9975bcba..9616c2ab7cf 100644
--- a/pkg/services/alerting/notifiers/base.go
+++ b/pkg/services/alerting/notifiers/base.go
@@ -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 {
diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go
index 3fd4447eefe..bf09ecab0cd 100644
--- a/pkg/services/alerting/notifiers/base_test.go
+++ b/pkg/services/alerting/notifiers/base_test.go
@@ -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,
diff --git a/pkg/services/alerting/notifiers/pushover.go b/pkg/services/alerting/notifiers/pushover.go
index 55dc02c5f4a..6581328b745 100644
--- a/pkg/services/alerting/notifiers/pushover.go
+++ b/pkg/services/alerting/notifiers/pushover.go
@@ -1,8 +1,11 @@
package notifiers
import (
+ "bytes"
"fmt"
- "net/url"
+ "io"
+ "mime/multipart"
+ "os"
"strconv"
"github.com/grafana/grafana/pkg/bus"
@@ -91,6 +94,7 @@ func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error)
retry, _ := strconv.Atoi(model.Settings.Get("retry").MustString())
expire, _ := strconv.Atoi(model.Settings.Get("expire").MustString())
sound := model.Settings.Get("sound").MustString()
+ uploadImage := model.Settings.Get("uploadImage").MustBool(true)
if userKey == "" {
return nil, alerting.ValidationError{Reason: "User key not given"}
@@ -107,6 +111,7 @@ func NewPushoverNotifier(model *m.AlertNotification) (alerting.Notifier, error)
Expire: expire,
Device: device,
Sound: sound,
+ Upload: uploadImage,
log: log.New("alerting.notifier.pushover"),
}, nil
}
@@ -120,6 +125,7 @@ type PushoverNotifier struct {
Expire int
Device string
Sound string
+ Upload bool
log log.Logger
}
@@ -140,38 +146,22 @@ func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error {
if evalContext.Error != nil {
message += fmt.Sprintf("\nError message: %s", evalContext.Error.Error())
}
- if evalContext.ImagePublicUrl != "" {
- message += fmt.Sprintf("\nShow graph image ", evalContext.ImagePublicUrl)
- }
+
if message == "" {
message = "Notification message missing (Set a notification message to replace this text.)"
}
- q := url.Values{}
- q.Add("user", this.UserKey)
- q.Add("token", this.ApiToken)
- q.Add("priority", strconv.Itoa(this.Priority))
- if this.Priority == 2 {
- q.Add("retry", strconv.Itoa(this.Retry))
- q.Add("expire", strconv.Itoa(this.Expire))
+ headers, uploadBody, err := this.genPushoverBody(evalContext, message, ruleUrl)
+ if err != nil {
+ this.log.Error("Failed to generate body for pushover", "error", err)
+ return err
}
- if this.Device != "" {
- q.Add("device", this.Device)
- }
- if this.Sound != "default" {
- q.Add("sound", this.Sound)
- }
- q.Add("title", evalContext.GetNotificationTitle())
- q.Add("url", ruleUrl)
- q.Add("url_title", "Show dashboard with alert")
- q.Add("message", message)
- q.Add("html", "1")
cmd := &m.SendWebhookSync{
Url: PUSHOVER_ENDPOINT,
HttpMethod: "POST",
- HttpHeader: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
- Body: q.Encode(),
+ HttpHeader: headers,
+ Body: uploadBody.String(),
}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
@@ -181,3 +171,109 @@ func (this *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error {
return nil
}
+
+func (this *PushoverNotifier) genPushoverBody(evalContext *alerting.EvalContext, message string, ruleUrl string) (map[string]string, bytes.Buffer, error) {
+ var b bytes.Buffer
+ var err error
+ w := multipart.NewWriter(&b)
+
+ // Add image only if requested and available
+ if this.Upload && evalContext.ImageOnDiskPath != "" {
+ f, err := os.Open(evalContext.ImageOnDiskPath)
+ if err != nil {
+ return nil, b, err
+ }
+ defer f.Close()
+
+ fw, err := w.CreateFormFile("attachment", evalContext.ImageOnDiskPath)
+ if err != nil {
+ return nil, b, err
+ }
+
+ _, err = io.Copy(fw, f)
+ if err != nil {
+ return nil, b, err
+ }
+ }
+
+ // Add the user token
+ err = w.WriteField("user", this.UserKey)
+ if err != nil {
+ return nil, b, err
+ }
+
+ // Add the api token
+ err = w.WriteField("token", this.ApiToken)
+ if err != nil {
+ return nil, b, err
+ }
+
+ // Add priority
+ err = w.WriteField("priority", strconv.Itoa(this.Priority))
+ if err != nil {
+ return nil, b, err
+ }
+
+ if this.Priority == 2 {
+ err = w.WriteField("retry", strconv.Itoa(this.Retry))
+ if err != nil {
+ return nil, b, err
+ }
+
+ err = w.WriteField("expire", strconv.Itoa(this.Expire))
+ if err != nil {
+ return nil, b, err
+ }
+ }
+
+ // Add device
+ if this.Device != "" {
+ err = w.WriteField("device", this.Device)
+ if err != nil {
+ return nil, b, err
+ }
+ }
+
+ // Add sound
+ if this.Sound != "default" {
+ err = w.WriteField("sound", this.Sound)
+ if err != nil {
+ return nil, b, err
+ }
+ }
+
+ // Add title
+ err = w.WriteField("title", evalContext.GetNotificationTitle())
+ if err != nil {
+ return nil, b, err
+ }
+
+ // Add URL
+ err = w.WriteField("url", ruleUrl)
+ if err != nil {
+ return nil, b, err
+ }
+ // Add URL title
+ err = w.WriteField("url_title", "Show dashboard with alert")
+ if err != nil {
+ return nil, b, err
+ }
+
+ // Add message
+ err = w.WriteField("message", message)
+ if err != nil {
+ return nil, b, err
+ }
+
+ // Mark as html message
+ err = w.WriteField("html", "1")
+ if err != nil {
+ return nil, b, err
+ }
+
+ w.Close()
+ headers := map[string]string{
+ "Content-Type": w.FormDataContentType(),
+ }
+ return headers, b, nil
+}
diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go
index 4a4a989d873..ab43f3bce35 100644
--- a/pkg/services/alerting/notifiers/telegram.go
+++ b/pkg/services/alerting/notifiers/telegram.go
@@ -130,7 +130,7 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
defer func() {
err := imageFile.Close()
if err != nil {
- log.Error2("Could not close Telegram inline image.", "err", err)
+ this.log.Error("Could not close Telegram inline image.", "err", err)
}
}()
diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go
index d2a505145ac..902c1660976 100644
--- a/pkg/services/alerting/rule.go
+++ b/pkg/services/alerting/rule.go
@@ -1,16 +1,21 @@
package alerting
import (
+ "errors"
"fmt"
"regexp"
"strconv"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
-
m "github.com/grafana/grafana/pkg/models"
)
+var (
+ ErrFrequencyCannotBeZeroOrLess = errors.New(`"evaluate every" cannot be zero or below`)
+ ErrFrequencyCouldNotBeParsed = errors.New(`"evaluate every" field could not be parsed`)
+)
+
type Rule struct {
Id int64
OrgId int64
@@ -25,7 +30,7 @@ type Rule struct {
ExecutionErrorState m.ExecutionErrorOption
State m.AlertStateType
Conditions []Condition
- Notifications []int64
+ Notifications []string
StateChanges int64
}
@@ -76,7 +81,7 @@ func getTimeDurationStringToSeconds(str string) (int64, error) {
matches := ValueFormatRegex.FindAllString(str, 1)
if len(matches) <= 0 {
- return 0, fmt.Errorf("Frequency could not be parsed")
+ return 0, ErrFrequencyCouldNotBeParsed
}
value, err := strconv.Atoi(matches[0])
@@ -84,6 +89,10 @@ func getTimeDurationStringToSeconds(str string) (int64, error) {
return 0, err
}
+ if value == 0 {
+ return 0, ErrFrequencyCannotBeZeroOrLess
+ }
+
unit := UnitFormatRegex.FindAllString(str, 1)[0]
if val, ok := unitMultiplier[unit]; ok {
@@ -101,7 +110,6 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
model.PanelId = ruleDef.PanelId
model.Name = ruleDef.Name
model.Message = ruleDef.Message
- model.Frequency = ruleDef.Frequency
model.State = ruleDef.State
model.LastStateChange = ruleDef.NewStateDate
model.For = ruleDef.For
@@ -109,13 +117,24 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting"))
model.StateChanges = ruleDef.StateChanges
+ model.Frequency = ruleDef.Frequency
+ // frequency cannot be zero since that would not execute the alert rule.
+ // so we fallback to 60 seconds if `Freqency` is missing
+ if model.Frequency == 0 {
+ model.Frequency = 60
+ }
+
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() {
diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go
index 2a9e95e5723..f4172a57e74 100644
--- a/pkg/services/alerting/rule_test.go
+++ b/pkg/services/alerting/rule_test.go
@@ -5,6 +5,7 @@ import (
"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"
)
@@ -14,56 +15,150 @@ func (f *FakeCondition) Eval(context *EvalContext) (*ConditionResult, error) {
return &ConditionResult{}, nil
}
+func TestAlertRuleFrequencyParsing(t *testing.T) {
+ tcs := []struct {
+ input string
+ err error
+ result int64
+ }{
+ {input: "10s", result: 10},
+ {input: "10m", result: 600},
+ {input: "1h", result: 3600},
+ {input: "1o", result: 1},
+ {input: "0s", err: ErrFrequencyCannotBeZeroOrLess},
+ {input: "0m", err: ErrFrequencyCannotBeZeroOrLess},
+ {input: "0h", err: ErrFrequencyCannotBeZeroOrLess},
+ {input: "0", err: ErrFrequencyCannotBeZeroOrLess},
+ {input: "-1s", err: ErrFrequencyCouldNotBeParsed},
+ }
+
+ for _, tc := range tcs {
+ r, err := getTimeDurationStringToSeconds(tc.input)
+ if err != tc.err {
+ t.Errorf("expected error: '%v' got: '%v'", tc.err, err)
+ return
+ }
+
+ if r != tc.result {
+ t.Errorf("expected result: %d got %d", tc.result, r)
+ }
+ }
+}
+
func TestAlertRuleModel(t *testing.T) {
+ sqlstore.InitTestDB(t)
Convey("Testing alert rule", t, func() {
RegisterCondition("test", func(model *simplejson.Json, index int) (Condition, error) {
return &FakeCondition{}, nil
})
- Convey("Can parse seconds", func() {
- seconds, _ := getTimeDurationStringToSeconds("10s")
- So(seconds, ShouldEqual, 10)
- })
-
- Convey("Can parse minutes", func() {
- seconds, _ := getTimeDurationStringToSeconds("10m")
- So(seconds, ShouldEqual, 600)
- })
-
- Convey("Can parse hours", func() {
- seconds, _ := getTimeDurationStringToSeconds("1h")
- So(seconds, ShouldEqual, 3600)
- })
-
- Convey("defaults to seconds", func() {
- seconds, _ := getTimeDurationStringToSeconds("1o")
- So(seconds, ShouldEqual, 1)
- })
-
Convey("should return err for empty string", func() {
_, err := getTimeDurationStringToSeconds("")
So(err, ShouldNotBeNil)
})
Convey("can construct alert rule model", func() {
+ firstNotification := m.CreateAlertNotificationCommand{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)
+
+ Convey("with notification id and uid", func() {
+ json := `
+ {
+ "name": "name2",
+ "description": "desc2",
+ "handler": 0,
+ "noDataMode": "critical",
+ "enabled": true,
+ "frequency": "60s",
+ "conditions": [
+ {
+ "type": "test",
+ "prop": 123
+ }
+ ],
+ "notifications": [
+ {"id": 1},
+ {"uid": "notifier2"}
+ ]
+ }
+ `
+
+ alertJSON, jsonErr := simplejson.NewJson([]byte(json))
+ So(jsonErr, ShouldBeNil)
+
+ alert := &m.Alert{
+ Id: 1,
+ OrgId: 1,
+ DashboardId: 1,
+ PanelId: 1,
+
+ Settings: alertJSON,
+ }
+
+ alertRule, err := NewRuleFromDBAlert(alert)
+ So(err, ShouldBeNil)
+
+ So(len(alertRule.Conditions), ShouldEqual, 1)
+
+ Convey("Can read notifications", func() {
+ So(len(alertRule.Notifications), ShouldEqual, 2)
+ So(alertRule.Notifications, ShouldContain, "000000001")
+ So(alertRule.Notifications, ShouldContain, "notifier2")
+ })
+ })
+ })
+
+ Convey("can construct alert rule model with invalid frequency", func() {
+ json := `
+ {
+ "name": "name2",
+ "description": "desc2",
+ "noDataMode": "critical",
+ "enabled": true,
+ "frequency": "0s",
+ "conditions": [ { "type": "test", "prop": 123 } ],
+ "notifications": []
+ }`
+
+ alertJSON, jsonErr := simplejson.NewJson([]byte(json))
+ So(jsonErr, ShouldBeNil)
+
+ alert := &m.Alert{
+ Id: 1,
+ OrgId: 1,
+ DashboardId: 1,
+ PanelId: 1,
+ Frequency: 0,
+
+ Settings: alertJSON,
+ }
+
+ alertRule, err := NewRuleFromDBAlert(alert)
+ So(err, ShouldBeNil)
+ So(alertRule.Frequency, ShouldEqual, 60)
+ })
+
+ Convey("raise error in case of missing notification id and uid", func() {
json := `
{
"name": "name2",
"description": "desc2",
- "handler": 0,
"noDataMode": "critical",
"enabled": true,
"frequency": "60s",
- "conditions": [
- {
- "type": "test",
- "prop": 123
+ "conditions": [
+ {
+ "type": "test",
+ "prop": 123
}
- ],
- "notifications": [
- {"id": 1134},
- {"id": 22}
+ ],
+ "notifications": [
+ {"not_id_uid": "1134"}
]
}
`
@@ -76,18 +171,14 @@ func TestAlertRuleModel(t *testing.T) {
OrgId: 1,
DashboardId: 1,
PanelId: 1,
+ Frequency: 0,
Settings: alertJSON,
}
- alertRule, err := NewRuleFromDBAlert(alert)
- So(err, ShouldBeNil)
-
- So(len(alertRule.Conditions), ShouldEqual, 1)
-
- Convey("Can read notifications", func() {
- So(len(alertRule.Notifications), ShouldEqual, 2)
- })
+ _, err := NewRuleFromDBAlert(alert)
+ So(err, ShouldNotBeNil)
+ So(err.Error(), ShouldEqual, "Alert validation error: Neither id nor uid is specified, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1")
})
})
}
diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go
index b6e59f694c8..5ffc8dc58fc 100644
--- a/pkg/services/alerting/test_notification.go
+++ b/pkg/services/alerting/test_notification.go
@@ -18,9 +18,12 @@ type NotificationTestCommand struct {
Settings *simplejson.Json
}
+var (
+ logger = log.New("alerting.testnotification")
+)
+
func init() {
bus.AddHandler("alerting", handleNotificationTestCommand)
-
}
func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
@@ -35,7 +38,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
notifiers, err := InitNotifier(model)
if err != nil {
- log.Error2("Failed to create notifier", "error", err.Error())
+ logger.Error("Failed to create notifier", "error", err.Error())
return err
}
diff --git a/pkg/services/alerting/testdata/dash-without-id.json b/pkg/services/alerting/testdata/dash-without-id.json
index e0a212695d8..02cd2c002f0 100644
--- a/pkg/services/alerting/testdata/dash-without-id.json
+++ b/pkg/services/alerting/testdata/dash-without-id.json
@@ -44,7 +44,10 @@
"noDataState": "no_data",
"notifications": [
{
- "id": 6
+ "uid": "notifier1"
+ },
+ {
+ "id": 2
}
]
},
diff --git a/pkg/services/alerting/testdata/influxdb-alert.json b/pkg/services/alerting/testdata/influxdb-alert.json
index fd6feb31a47..29f1a0c8e5e 100644
--- a/pkg/services/alerting/testdata/influxdb-alert.json
+++ b/pkg/services/alerting/testdata/influxdb-alert.json
@@ -45,7 +45,10 @@
"noDataState": "no_data",
"notifications": [
{
- "id": 6
+ "id": 1
+ },
+ {
+ "uid": "notifier2"
}
]
},
diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go
new file mode 100644
index 00000000000..db4d9d18624
--- /dev/null
+++ b/pkg/services/auth/auth_token.go
@@ -0,0 +1,266 @@
+package auth
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/registry"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+func init() {
+ registry.RegisterService(&UserAuthTokenServiceImpl{})
+}
+
+var (
+ getTime = time.Now
+ UrgentRotateTime = 1 * time.Minute
+ oneYearInSeconds = 31557600 //used as default maxage for session cookies. We validate/rotate them more often.
+)
+
+// UserAuthTokenService are used for generating and validating user auth tokens
+type UserAuthTokenService interface {
+ InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
+ UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
+ UserSignedOutHook(c *models.ReqContext)
+}
+
+type UserAuthTokenServiceImpl struct {
+ SQLStore *sqlstore.SqlStore `inject:""`
+ ServerLockService *serverlock.ServerLockService `inject:""`
+ Cfg *setting.Cfg `inject:""`
+ log log.Logger
+}
+
+// Init this service
+func (s *UserAuthTokenServiceImpl) Init() error {
+ s.log = log.New("auth")
+ return nil
+}
+
+func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext, orgID int64) bool {
+ //auth User
+ unhashedToken := ctx.GetCookie(s.Cfg.LoginCookieName)
+ if unhashedToken == "" {
+ return false
+ }
+
+ userToken, err := s.LookupToken(unhashedToken)
+ if err != nil {
+ ctx.Logger.Info("failed to look up user based on cookie", "error", err)
+ return false
+ }
+
+ query := models.GetSignedInUserQuery{UserId: userToken.UserId, OrgId: orgID}
+ if err := bus.Dispatch(&query); err != nil {
+ ctx.Logger.Error("Failed to get user with id", "userId", userToken.UserId, "error", err)
+ return false
+ }
+
+ ctx.SignedInUser = query.Result
+ ctx.IsSignedIn = true
+
+ //rotate session token if needed.
+ rotated, err := s.RefreshToken(userToken, ctx.RemoteAddr(), ctx.Req.UserAgent())
+ if err != nil {
+ ctx.Logger.Error("failed to rotate token", "error", err, "userId", userToken.UserId, "tokenId", userToken.Id)
+ return true
+ }
+
+ if rotated {
+ s.writeSessionCookie(ctx, userToken.UnhashedToken, oneYearInSeconds)
+ }
+
+ return true
+}
+
+func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) {
+ if setting.Env == setting.DEV {
+ ctx.Logger.Info("new token", "unhashed token", value)
+ }
+
+ ctx.Resp.Header().Del("Set-Cookie")
+ cookie := http.Cookie{
+ Name: s.Cfg.LoginCookieName,
+ Value: url.QueryEscape(value),
+ HttpOnly: true,
+ Path: setting.AppSubUrl + "/",
+ Secure: s.Cfg.SecurityHTTPSCookies,
+ MaxAge: maxAge,
+ }
+
+ http.SetCookie(ctx.Resp, &cookie)
+}
+
+func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *models.ReqContext) error {
+ userToken, err := s.CreateToken(user.Id, c.RemoteAddr(), c.Req.UserAgent())
+ if err != nil {
+ return err
+ }
+
+ s.writeSessionCookie(c, userToken.UnhashedToken, oneYearInSeconds)
+ return nil
+}
+
+func (s *UserAuthTokenServiceImpl) UserSignedOutHook(c *models.ReqContext) {
+ s.writeSessionCookie(c, "", -1)
+}
+
+func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
+ clientIP = util.ParseIPAddress(clientIP)
+ token, err := util.RandomHex(16)
+ if err != nil {
+ return nil, err
+ }
+
+ hashedToken := hashToken(token)
+
+ now := getTime().Unix()
+
+ userToken := userAuthToken{
+ UserId: userId,
+ AuthToken: hashedToken,
+ PrevAuthToken: hashedToken,
+ ClientIp: clientIP,
+ UserAgent: userAgent,
+ RotatedAt: now,
+ CreatedAt: now,
+ UpdatedAt: now,
+ SeenAt: 0,
+ AuthTokenSeen: false,
+ }
+ _, err = s.SQLStore.NewSession().Insert(&userToken)
+ if err != nil {
+ return nil, err
+ }
+
+ userToken.UnhashedToken = token
+
+ return &userToken, nil
+}
+
+func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) {
+ hashedToken := hashToken(unhashedToken)
+ if setting.Env == setting.DEV {
+ s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken)
+ }
+
+ expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix()
+
+ var userToken userAuthToken
+ exists, err := s.SQLStore.NewSession().Where("(auth_token = ? OR prev_auth_token = ?) AND created_at > ?", hashedToken, hashedToken, expireBefore).Get(&userToken)
+ if err != nil {
+ return nil, err
+ }
+
+ if !exists {
+ return nil, ErrAuthTokenNotFound
+ }
+
+ if userToken.AuthToken != hashedToken && userToken.PrevAuthToken == hashedToken && userToken.AuthTokenSeen {
+ userTokenCopy := userToken
+ userTokenCopy.AuthTokenSeen = false
+ expireBefore := getTime().Add(-UrgentRotateTime).Unix()
+ affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND prev_auth_token = ? AND rotated_at < ?", userTokenCopy.Id, userTokenCopy.PrevAuthToken, expireBefore).AllCols().Update(&userTokenCopy)
+ if err != nil {
+ return nil, err
+ }
+
+ if affectedRows == 0 {
+ s.log.Debug("prev seen token unchanged", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ } else {
+ s.log.Debug("prev seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ }
+ }
+
+ if !userToken.AuthTokenSeen && userToken.AuthToken == hashedToken {
+ userTokenCopy := userToken
+ userTokenCopy.AuthTokenSeen = true
+ userTokenCopy.SeenAt = getTime().Unix()
+ affectedRows, err := s.SQLStore.NewSession().Where("id = ? AND auth_token = ?", userTokenCopy.Id, userTokenCopy.AuthToken).AllCols().Update(&userTokenCopy)
+ if err != nil {
+ return nil, err
+ }
+
+ if affectedRows == 1 {
+ userToken = userTokenCopy
+ }
+
+ if affectedRows == 0 {
+ s.log.Debug("seen wrong token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ } else {
+ s.log.Debug("seen token", "userTokenId", userToken.Id, "userId", userToken.UserId, "authToken", userToken.AuthToken, "clientIP", userToken.ClientIp, "userAgent", userToken.UserAgent)
+ }
+ }
+
+ userToken.UnhashedToken = unhashedToken
+
+ return &userToken, nil
+}
+
+func (s *UserAuthTokenServiceImpl) RefreshToken(token *userAuthToken, clientIP, userAgent string) (bool, error) {
+ if token == nil {
+ return false, nil
+ }
+
+ now := getTime()
+
+ needsRotation := false
+ rotatedAt := time.Unix(token.RotatedAt, 0)
+ if token.AuthTokenSeen {
+ needsRotation = rotatedAt.Before(now.Add(-time.Duration(s.Cfg.LoginCookieRotation) * time.Minute))
+ } else {
+ needsRotation = rotatedAt.Before(now.Add(-UrgentRotateTime))
+ }
+
+ if !needsRotation {
+ return false, nil
+ }
+
+ s.log.Debug("refresh token needs rotation?", "auth_token_seen", token.AuthTokenSeen, "rotated_at", rotatedAt, "token.Id", token.Id)
+
+ clientIP = util.ParseIPAddress(clientIP)
+ newToken, _ := util.RandomHex(16)
+ hashedToken := hashToken(newToken)
+
+ // very important that auth_token_seen is set after the prev_auth_token = case when ... for mysql to function correctly
+ sql := `
+ UPDATE user_auth_token
+ SET
+ seen_at = 0,
+ user_agent = ?,
+ client_ip = ?,
+ prev_auth_token = case when auth_token_seen = ? then auth_token else prev_auth_token end,
+ auth_token = ?,
+ auth_token_seen = ?,
+ rotated_at = ?
+ WHERE id = ? AND (auth_token_seen = ? OR rotated_at < ?)`
+
+ res, err := s.SQLStore.NewSession().Exec(sql, userAgent, clientIP, s.SQLStore.Dialect.BooleanStr(true), hashedToken, s.SQLStore.Dialect.BooleanStr(false), now.Unix(), token.Id, s.SQLStore.Dialect.BooleanStr(true), now.Add(-30*time.Second).Unix())
+ if err != nil {
+ return false, err
+ }
+
+ affected, _ := res.RowsAffected()
+ s.log.Debug("rotated", "affected", affected, "auth_token_id", token.Id, "userId", token.UserId)
+ if affected > 0 {
+ token.UnhashedToken = newToken
+ return true, nil
+ }
+
+ return false, nil
+}
+
+func hashToken(token string) string {
+ hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
+ return hex.EncodeToString(hashBytes[:])
+}
diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go
new file mode 100644
index 00000000000..2f75c660d9d
--- /dev/null
+++ b/pkg/services/auth/auth_token_test.go
@@ -0,0 +1,339 @@
+package auth
+
+import (
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/setting"
+
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserAuthToken(t *testing.T) {
+ Convey("Test user auth token", t, func() {
+ ctx := createTestContext(t)
+ userAuthTokenService := ctx.tokenService
+ userID := int64(10)
+
+ t := time.Date(2018, 12, 13, 13, 45, 0, 0, time.UTC)
+ getTime = func() time.Time {
+ return t
+ }
+
+ Convey("When creating token", func() {
+ token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+ So(token.AuthTokenSeen, ShouldBeFalse)
+
+ Convey("When lookup unhashed token should return user auth token", func() {
+ LookupToken, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(LookupToken, ShouldNotBeNil)
+ So(LookupToken.UserId, ShouldEqual, userID)
+ So(LookupToken.AuthTokenSeen, ShouldBeTrue)
+
+ storedAuthToken, err := ctx.getAuthTokenByID(LookupToken.Id)
+ So(err, ShouldBeNil)
+ So(storedAuthToken, ShouldNotBeNil)
+ So(storedAuthToken.AuthTokenSeen, ShouldBeTrue)
+ })
+
+ Convey("When lookup hashed token should return user auth token not found error", func() {
+ LookupToken, err := userAuthTokenService.LookupToken(token.AuthToken)
+ So(err, ShouldEqual, ErrAuthTokenNotFound)
+ So(LookupToken, ShouldBeNil)
+ })
+ })
+
+ Convey("expires correctly", func() {
+ token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+
+ _, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+
+ token, err = ctx.getAuthTokenByID(token.Id)
+ So(err, ShouldBeNil)
+
+ getTime = func() time.Time {
+ return t.Add(time.Hour)
+ }
+
+ refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeTrue)
+
+ _, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+
+ stillGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(stillGood, ShouldNotBeNil)
+
+ getTime = func() time.Time {
+ return t.Add(24 * 7 * time.Hour)
+ }
+ notGood, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldEqual, ErrAuthTokenNotFound)
+ So(notGood, ShouldBeNil)
+ })
+
+ Convey("can properly rotate tokens", func() {
+ token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+
+ prevToken := token.AuthToken
+ unhashedPrev := token.UnhashedToken
+
+ refreshed, err := userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeFalse)
+
+ updated, err := ctx.markAuthTokenAsSeen(token.Id)
+ So(err, ShouldBeNil)
+ So(updated, ShouldBeTrue)
+
+ token, err = ctx.getAuthTokenByID(token.Id)
+ So(err, ShouldBeNil)
+
+ getTime = func() time.Time {
+ return t.Add(time.Hour)
+ }
+
+ refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeTrue)
+
+ unhashedToken := token.UnhashedToken
+
+ token, err = ctx.getAuthTokenByID(token.Id)
+ So(err, ShouldBeNil)
+ token.UnhashedToken = unhashedToken
+
+ So(token.RotatedAt, ShouldEqual, getTime().Unix())
+ So(token.ClientIp, ShouldEqual, "192.168.10.12")
+ So(token.UserAgent, ShouldEqual, "a new user agent")
+ So(token.AuthTokenSeen, ShouldBeFalse)
+ So(token.SeenAt, ShouldEqual, 0)
+ So(token.PrevAuthToken, ShouldEqual, prevToken)
+
+ // ability to auth using an old token
+
+ lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+ So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+ So(lookedUp.SeenAt, ShouldEqual, getTime().Unix())
+
+ lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+ So(lookedUp.Id, ShouldEqual, token.Id)
+ So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+
+ getTime = func() time.Time {
+ return t.Add(time.Hour + (2 * time.Minute))
+ }
+
+ lookedUp, err = userAuthTokenService.LookupToken(unhashedPrev)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+ So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+
+ lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+ So(lookedUp.AuthTokenSeen, ShouldBeFalse)
+
+ refreshed, err = userAuthTokenService.RefreshToken(token, "192.168.10.12:1234", "a new user agent")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeTrue)
+
+ token, err = ctx.getAuthTokenByID(token.Id)
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+ So(token.SeenAt, ShouldEqual, 0)
+ })
+
+ Convey("keeps prev token valid for 1 minute after it is confirmed", func() {
+ token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+
+ lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+
+ getTime = func() time.Time {
+ return t.Add(10 * time.Minute)
+ }
+
+ prevToken := token.UnhashedToken
+ refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeTrue)
+
+ getTime = func() time.Time {
+ return t.Add(20 * time.Minute)
+ }
+
+ current, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(current, ShouldNotBeNil)
+
+ prev, err := userAuthTokenService.LookupToken(prevToken)
+ So(err, ShouldBeNil)
+ So(prev, ShouldNotBeNil)
+ })
+
+ Convey("will not mark token unseen when prev and current are the same", func() {
+ token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+
+ lookedUp, err := userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+
+ lookedUp, err = userAuthTokenService.LookupToken(token.UnhashedToken)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+
+ lookedUp, err = ctx.getAuthTokenByID(lookedUp.Id)
+ So(err, ShouldBeNil)
+ So(lookedUp, ShouldNotBeNil)
+ So(lookedUp.AuthTokenSeen, ShouldBeTrue)
+ })
+
+ Convey("Rotate token", func() {
+ token, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+
+ prevToken := token.AuthToken
+
+ Convey("Should rotate current token and previous token when auth token seen", func() {
+ updated, err := ctx.markAuthTokenAsSeen(token.Id)
+ So(err, ShouldBeNil)
+ So(updated, ShouldBeTrue)
+
+ getTime = func() time.Time {
+ return t.Add(10 * time.Minute)
+ }
+
+ refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeTrue)
+
+ storedToken, err := ctx.getAuthTokenByID(token.Id)
+ So(err, ShouldBeNil)
+ So(storedToken, ShouldNotBeNil)
+ So(storedToken.AuthTokenSeen, ShouldBeFalse)
+ So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+ So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+
+ prevToken = storedToken.AuthToken
+
+ updated, err = ctx.markAuthTokenAsSeen(token.Id)
+ So(err, ShouldBeNil)
+ So(updated, ShouldBeTrue)
+
+ getTime = func() time.Time {
+ return t.Add(20 * time.Minute)
+ }
+
+ refreshed, err = userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeTrue)
+
+ storedToken, err = ctx.getAuthTokenByID(token.Id)
+ So(err, ShouldBeNil)
+ So(storedToken, ShouldNotBeNil)
+ So(storedToken.AuthTokenSeen, ShouldBeFalse)
+ So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+ So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+ })
+
+ Convey("Should rotate current token, but keep previous token when auth token not seen", func() {
+ token.RotatedAt = getTime().Add(-2 * time.Minute).Unix()
+
+ getTime = func() time.Time {
+ return t.Add(2 * time.Minute)
+ }
+
+ refreshed, err := userAuthTokenService.RefreshToken(token, "1.1.1.1", "firefox")
+ So(err, ShouldBeNil)
+ So(refreshed, ShouldBeTrue)
+
+ storedToken, err := ctx.getAuthTokenByID(token.Id)
+ So(err, ShouldBeNil)
+ So(storedToken, ShouldNotBeNil)
+ So(storedToken.AuthTokenSeen, ShouldBeFalse)
+ So(storedToken.PrevAuthToken, ShouldEqual, prevToken)
+ So(storedToken.AuthToken, ShouldNotEqual, prevToken)
+ })
+ })
+
+ Reset(func() {
+ getTime = time.Now
+ })
+ })
+}
+
+func createTestContext(t *testing.T) *testContext {
+ t.Helper()
+
+ sqlstore := sqlstore.InitTestDB(t)
+ tokenService := &UserAuthTokenServiceImpl{
+ SQLStore: sqlstore,
+ Cfg: &setting.Cfg{
+ LoginCookieName: "grafana_session",
+ LoginCookieMaxDays: 7,
+ LoginDeleteExpiredTokensAfterDays: 30,
+ LoginCookieRotation: 10,
+ },
+ log: log.New("test-logger"),
+ }
+
+ UrgentRotateTime = time.Minute
+
+ return &testContext{
+ sqlstore: sqlstore,
+ tokenService: tokenService,
+ }
+}
+
+type testContext struct {
+ sqlstore *sqlstore.SqlStore
+ tokenService *UserAuthTokenServiceImpl
+}
+
+func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
+ sess := c.sqlstore.NewSession()
+ var t userAuthToken
+ found, err := sess.ID(id).Get(&t)
+ if err != nil || !found {
+ return nil, err
+ }
+
+ return &t, nil
+}
+
+func (c *testContext) markAuthTokenAsSeen(id int64) (bool, error) {
+ sess := c.sqlstore.NewSession()
+ res, err := sess.Exec("UPDATE user_auth_token SET auth_token_seen = ? WHERE id = ?", c.sqlstore.Dialect.BooleanStr(true), id)
+ if err != nil {
+ return false, err
+ }
+
+ rowsAffected, err := res.RowsAffected()
+ if err != nil {
+ return false, err
+ }
+ return rowsAffected == 1, nil
+}
diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go
new file mode 100644
index 00000000000..7a0f49539f2
--- /dev/null
+++ b/pkg/services/auth/model.go
@@ -0,0 +1,25 @@
+package auth
+
+import (
+ "errors"
+)
+
+// Typed errors
+var (
+ ErrAuthTokenNotFound = errors.New("User auth token not found")
+)
+
+type userAuthToken struct {
+ Id int64
+ UserId int64
+ AuthToken string
+ PrevAuthToken string
+ UserAgent string
+ ClientIp string
+ AuthTokenSeen bool
+ SeenAt int64
+ RotatedAt int64
+ CreatedAt int64
+ UpdatedAt int64
+ UnhashedToken string `xorm:"-"`
+}
diff --git a/pkg/services/auth/session_cleanup.go b/pkg/services/auth/session_cleanup.go
new file mode 100644
index 00000000000..7e523181a7b
--- /dev/null
+++ b/pkg/services/auth/session_cleanup.go
@@ -0,0 +1,38 @@
+package auth
+
+import (
+ "context"
+ "time"
+)
+
+func (srv *UserAuthTokenServiceImpl) Run(ctx context.Context) error {
+ ticker := time.NewTicker(time.Hour * 12)
+ deleteSessionAfter := time.Hour * 24 * time.Duration(srv.Cfg.LoginDeleteExpiredTokensAfterDays)
+
+ for {
+ select {
+ case <-ticker.C:
+ srv.ServerLockService.LockAndExecute(ctx, "delete old sessions", time.Hour*12, func() {
+ srv.deleteOldSession(deleteSessionAfter)
+ })
+
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+}
+
+func (srv *UserAuthTokenServiceImpl) deleteOldSession(deleteSessionAfter time.Duration) (int64, error) {
+ sql := `DELETE from user_auth_token WHERE rotated_at < ?`
+
+ deleteBefore := getTime().Add(-deleteSessionAfter)
+ res, err := srv.SQLStore.NewSession().Exec(sql, deleteBefore.Unix())
+ if err != nil {
+ return 0, err
+ }
+
+ affected, err := res.RowsAffected()
+ srv.log.Info("deleted old sessions", "count", affected)
+
+ return affected, err
+}
diff --git a/pkg/services/auth/session_cleanup_test.go b/pkg/services/auth/session_cleanup_test.go
new file mode 100644
index 00000000000..eef2cd74d04
--- /dev/null
+++ b/pkg/services/auth/session_cleanup_test.go
@@ -0,0 +1,36 @@
+package auth
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserAuthTokenCleanup(t *testing.T) {
+
+ Convey("Test user auth token cleanup", t, func() {
+ ctx := createTestContext(t)
+
+ insertToken := func(token string, prev string, rotatedAt int64) {
+ ut := userAuthToken{AuthToken: token, PrevAuthToken: prev, RotatedAt: rotatedAt, UserAgent: "", ClientIp: ""}
+ _, err := ctx.sqlstore.NewSession().Insert(&ut)
+ So(err, ShouldBeNil)
+ }
+
+ // insert three old tokens that should be deleted
+ for i := 0; i < 3; i++ {
+ insertToken(fmt.Sprintf("oldA%d", i), fmt.Sprintf("oldB%d", i), int64(i))
+ }
+
+ // insert three active tokens that should not be deleted
+ for i := 0; i < 3; i++ {
+ insertToken(fmt.Sprintf("newA%d", i), fmt.Sprintf("newB%d", i), getTime().Unix())
+ }
+
+ affected, err := ctx.tokenService.deleteOldSession(time.Hour)
+ So(err, ShouldBeNil)
+ So(affected, ShouldEqual, 3)
+ })
+}
diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go
index 6aa1276b093..f8df6763994 100644
--- a/pkg/services/dashboards/dashboard_service.go
+++ b/pkg/services/dashboards/dashboard_service.go
@@ -76,11 +76,11 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
return nil, models.ErrDashboardFolderCannotHaveParent
}
- if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(models.RootFolderName) {
+ if dash.IsFolder && strings.EqualFold(dash.Title, models.RootFolderName) {
return nil, models.ErrDashboardFolderNameExists
}
- if !util.IsValidShortUid(dash.Uid) {
+ if !util.IsValidShortUID(dash.Uid) {
return nil, models.ErrDashboardInvalidUid
} else if len(dash.Uid) > 40 {
return nil, models.ErrDashboardUidToLong
@@ -164,11 +164,7 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand,
User: dto.User,
}
- if err := bus.Dispatch(&alertCmd); err != nil {
- return err
- }
-
- return nil
+ return bus.Dispatch(&alertCmd)
}
func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go
index dbe441c915e..2be3b145372 100644
--- a/pkg/services/notifications/webhook.go
+++ b/pkg/services/notifications/webhook.go
@@ -3,6 +3,7 @@ package notifications
import (
"bytes"
"context"
+ "crypto/tls"
"fmt"
"io"
"io/ioutil"
@@ -26,6 +27,9 @@ type Webhook struct {
}
var netTransport = &http.Transport{
+ TLSClientConfig: &tls.Config{
+ Renegotiation: tls.RenegotiateFreelyAsClient,
+ },
Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go
index bfef06b558e..fa08972961d 100644
--- a/pkg/services/provisioning/dashboards/config_reader.go
+++ b/pkg/services/provisioning/dashboards/config_reader.go
@@ -1,6 +1,7 @@
package dashboards
import (
+ "fmt"
"io/ioutil"
"os"
"path/filepath"
@@ -69,7 +70,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
parsedDashboards, err := cr.parseConfigs(file)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("could not parse provisioning config file: %s error: %v", file.Name(), err)
}
if len(parsedDashboards) > 0 {
diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go
index f9590b01bf1..1c1819df8a9 100644
--- a/pkg/services/provisioning/dashboards/file_reader.go
+++ b/pkg/services/provisioning/dashboards/file_reader.go
@@ -118,6 +118,7 @@ func (fr *fileReader) startWalkingDisk() error {
return nil
}
+
func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs map[string]*models.DashboardProvisioning, filesFoundOnDisk map[string]os.FileInfo) {
if fr.Cfg.DisableDeletion {
return
@@ -180,7 +181,7 @@ func (fr *fileReader) saveDashboard(path string, folderId int64, fileInfo os.Fil
dash.Dashboard.SetId(provisionedData.DashboardId)
}
- fr.log.Debug("saving new dashboard", "file", path)
+ fr.log.Debug("saving new dashboard", "provisoner", fr.Cfg.Name, "file", path, "folderId", dash.Dashboard.FolderId)
dp := &models.DashboardProvisioning{
ExternalId: path,
Name: fr.Cfg.Name,
diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go
index fe849816553..1a9d2216e7a 100644
--- a/pkg/services/provisioning/dashboards/file_reader_test.go
+++ b/pkg/services/provisioning/dashboards/file_reader_test.go
@@ -166,6 +166,36 @@ func TestDashboardFileReader(t *testing.T) {
_, err := NewDashboardFileReader(cfg, logger)
So(err, ShouldBeNil)
})
+
+ Convey("Two dashboard providers should be able to provisioned the same dashboard without uid", func() {
+ cfg1 := &DashboardsAsConfig{Name: "1", Type: "file", OrgId: 1, Folder: "f1", Options: map[string]interface{}{"path": containingId}}
+ cfg2 := &DashboardsAsConfig{Name: "2", Type: "file", OrgId: 1, Folder: "f2", Options: map[string]interface{}{"path": containingId}}
+
+ reader1, err := NewDashboardFileReader(cfg1, logger)
+ So(err, ShouldBeNil)
+
+ err = reader1.startWalkingDisk()
+ So(err, ShouldBeNil)
+
+ reader2, err := NewDashboardFileReader(cfg2, logger)
+ So(err, ShouldBeNil)
+
+ err = reader2.startWalkingDisk()
+ So(err, ShouldBeNil)
+
+ var folderCount int
+ var dashCount int
+ for _, o := range fakeService.inserted {
+ if o.Dashboard.IsFolder {
+ folderCount++
+ } else {
+ dashCount++
+ }
+ }
+
+ So(folderCount, ShouldEqual, 2)
+ So(dashCount, ShouldEqual, 2)
+ })
})
Convey("Should not create new folder if folder name is missing", func() {
@@ -256,7 +286,9 @@ func (ffi FakeFileInfo) Sys() interface{} {
}
func mockDashboardProvisioningService() *fakeDashboardProvisioningService {
- mock := fakeDashboardProvisioningService{}
+ mock := fakeDashboardProvisioningService{
+ provisioned: map[string][]*models.DashboardProvisioning{},
+ }
dashboards.NewProvisioningService = func() dashboards.DashboardProvisioningService {
return &mock
}
@@ -265,17 +297,26 @@ func mockDashboardProvisioningService() *fakeDashboardProvisioningService {
type fakeDashboardProvisioningService struct {
inserted []*dashboards.SaveDashboardDTO
- provisioned []*models.DashboardProvisioning
+ provisioned map[string][]*models.DashboardProvisioning
getDashboard []*models.Dashboard
}
func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
- return s.provisioned, nil
+ if _, ok := s.provisioned[name]; !ok {
+ s.provisioned[name] = []*models.DashboardProvisioning{}
+ }
+
+ return s.provisioned[name], nil
}
func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(dto *dashboards.SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
s.inserted = append(s.inserted, dto)
- s.provisioned = append(s.provisioned, provisioning)
+
+ if _, ok := s.provisioned[provisioning.Name]; !ok {
+ s.provisioned[provisioning.Name] = []*models.DashboardProvisioning{}
+ }
+
+ s.provisioned[provisioning.Name] = append(s.provisioned[provisioning.Name], provisioning)
return dto.Dashboard, nil
}
diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go
new file mode 100644
index 00000000000..7b595a3d32f
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/alert_notifications.go
@@ -0,0 +1,180 @@
+package notifiers
+
+import (
+ "errors"
+
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/models"
+)
+
+var (
+ ErrInvalidConfigTooManyDefault = errors.New("Alert notification provisioning config is invalid. Only one alert notification can be marked as default")
+)
+
+func Provision(configDirectory string) error {
+ dc := newNotificationProvisioner(log.New("provisioning.notifiers"))
+ return dc.applyChanges(configDirectory)
+}
+
+type NotificationProvisioner struct {
+ log log.Logger
+ cfgProvider *configReader
+}
+
+func newNotificationProvisioner(log log.Logger) NotificationProvisioner {
+ return NotificationProvisioner{
+ log: log,
+ cfgProvider: &configReader{log: log},
+ }
+}
+
+func (dc *NotificationProvisioner) apply(cfg *notificationsAsConfig) error {
+ if err := dc.deleteNotifications(cfg.DeleteNotifications); err != nil {
+ return err
+ }
+
+ if err := dc.mergeNotifications(cfg.Notifications); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (dc *NotificationProvisioner) deleteNotifications(notificationToDelete []*deleteNotificationConfig) error {
+ for _, notification := range notificationToDelete {
+ dc.log.Info("Deleting alert notification", "name", notification.Name, "uid", notification.Uid)
+
+ if notification.OrgId == 0 && notification.OrgName != "" {
+ getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName}
+ if err := bus.Dispatch(getOrg); err != nil {
+ return err
+ }
+ notification.OrgId = getOrg.Result.Id
+ } else if notification.OrgId < 0 {
+ notification.OrgId = 1
+ }
+
+ getNotification := &models.GetAlertNotificationsWithUidQuery{Uid: notification.Uid, OrgId: notification.OrgId}
+
+ if err := bus.Dispatch(getNotification); err != nil {
+ return err
+ }
+
+ if getNotification.Result != nil {
+ cmd := &models.DeleteAlertNotificationWithUidCommand{Uid: getNotification.Result.Uid, OrgId: getNotification.OrgId}
+ if err := bus.Dispatch(cmd); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*notificationFromConfig) error {
+ for _, notification := range notificationToMerge {
+
+ if notification.OrgId == 0 && notification.OrgName != "" {
+ getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName}
+ if err := bus.Dispatch(getOrg); err != nil {
+ return err
+ }
+ notification.OrgId = getOrg.Result.Id
+ } else if notification.OrgId < 0 {
+ notification.OrgId = 1
+ }
+
+ cmd := &models.GetAlertNotificationsWithUidQuery{OrgId: notification.OrgId, Uid: notification.Uid}
+ err := bus.Dispatch(cmd)
+ if err != nil {
+ return err
+ }
+
+ if cmd.Result == nil {
+ dc.log.Debug("inserting alert notification from configuration", "name", notification.Name, "uid", notification.Uid)
+ insertCmd := &models.CreateAlertNotificationCommand{
+ Uid: notification.Uid,
+ Name: notification.Name,
+ Type: notification.Type,
+ IsDefault: notification.IsDefault,
+ Settings: notification.SettingsToJson(),
+ OrgId: notification.OrgId,
+ DisableResolveMessage: notification.DisableResolveMessage,
+ Frequency: notification.Frequency,
+ SendReminder: notification.SendReminder,
+ }
+
+ if err := bus.Dispatch(insertCmd); err != nil {
+ return err
+ }
+ } else {
+ dc.log.Debug("updating alert notification from configuration", "name", notification.Name)
+ updateCmd := &models.UpdateAlertNotificationWithUidCommand{
+ Uid: notification.Uid,
+ Name: notification.Name,
+ Type: notification.Type,
+ IsDefault: notification.IsDefault,
+ Settings: notification.SettingsToJson(),
+ OrgId: notification.OrgId,
+ DisableResolveMessage: notification.DisableResolveMessage,
+ Frequency: notification.Frequency,
+ SendReminder: notification.SendReminder,
+ }
+
+ if err := bus.Dispatch(updateCmd); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func (cfg *notificationsAsConfig) mapToNotificationFromConfig() *notificationsAsConfig {
+ r := ¬ificationsAsConfig{}
+ if cfg == nil {
+ return r
+ }
+
+ for _, notification := range cfg.Notifications {
+ r.Notifications = append(r.Notifications, ¬ificationFromConfig{
+ Uid: notification.Uid,
+ OrgId: notification.OrgId,
+ OrgName: notification.OrgName,
+ Name: notification.Name,
+ Type: notification.Type,
+ IsDefault: notification.IsDefault,
+ Settings: notification.Settings,
+ DisableResolveMessage: notification.DisableResolveMessage,
+ Frequency: notification.Frequency,
+ SendReminder: notification.SendReminder,
+ })
+ }
+
+ for _, notification := range cfg.DeleteNotifications {
+ r.DeleteNotifications = append(r.DeleteNotifications, &deleteNotificationConfig{
+ Uid: notification.Uid,
+ OrgId: notification.OrgId,
+ OrgName: notification.OrgName,
+ Name: notification.Name,
+ })
+ }
+
+ return r
+}
+
+func (dc *NotificationProvisioner) applyChanges(configPath string) error {
+ configs, err := dc.cfgProvider.readConfig(configPath)
+ if err != nil {
+ return err
+ }
+
+ for _, cfg := range configs {
+ if err := dc.apply(cfg); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/services/provisioning/notifiers/config_reader.go b/pkg/services/provisioning/notifiers/config_reader.go
new file mode 100644
index 00000000000..e712e8e3eff
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/config_reader.go
@@ -0,0 +1,163 @@
+package notifiers
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/grafana/grafana/pkg/log"
+ m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/alerting"
+ "gopkg.in/yaml.v2"
+)
+
+type configReader struct {
+ log log.Logger
+}
+
+func (cr *configReader) readConfig(path string) ([]*notificationsAsConfig, error) {
+ var notifications []*notificationsAsConfig
+ cr.log.Debug("Looking for alert notification provisioning files", "path", path)
+
+ files, err := ioutil.ReadDir(path)
+ if err != nil {
+ cr.log.Error("Can't read alert notification provisioning files from directory", "path", path)
+ return notifications, nil
+ }
+
+ for _, file := range files {
+ if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") {
+ cr.log.Debug("Parsing alert notifications provisioning file", "path", path, "file.Name", file.Name())
+ notifs, err := cr.parseNotificationConfig(path, file)
+ if err != nil {
+ return nil, err
+ }
+
+ if notifs != nil {
+ notifications = append(notifications, notifs)
+ }
+ }
+ }
+
+ cr.log.Debug("Validating alert notifications")
+ if err = validateRequiredField(notifications); err != nil {
+ return nil, err
+ }
+
+ checkOrgIdAndOrgName(notifications)
+
+ err = validateNotifications(notifications)
+ if err != nil {
+ return nil, err
+ }
+
+ return notifications, nil
+}
+
+func (cr *configReader) parseNotificationConfig(path string, file os.FileInfo) (*notificationsAsConfig, error) {
+ filename, _ := filepath.Abs(filepath.Join(path, file.Name()))
+ yamlFile, err := ioutil.ReadFile(filename)
+ if err != nil {
+ return nil, err
+ }
+
+ var cfg *notificationsAsConfig
+ err = yaml.Unmarshal(yamlFile, &cfg)
+ if err != nil {
+ return nil, err
+ }
+
+ return cfg.mapToNotificationFromConfig(), nil
+}
+
+func checkOrgIdAndOrgName(notifications []*notificationsAsConfig) {
+ for i := range notifications {
+ for _, notification := range notifications[i].Notifications {
+ if notification.OrgId < 1 {
+ if notification.OrgName == "" {
+ notification.OrgId = 1
+ } else {
+ notification.OrgId = 0
+ }
+ }
+ }
+
+ for _, notification := range notifications[i].DeleteNotifications {
+ if notification.OrgId < 1 {
+ if notification.OrgName == "" {
+ notification.OrgId = 1
+ } else {
+ notification.OrgId = 0
+ }
+ }
+ }
+ }
+}
+
+func validateRequiredField(notifications []*notificationsAsConfig) error {
+ for i := range notifications {
+ var errStrings []string
+ for index, notification := range notifications[i].Notifications {
+ if notification.Name == "" {
+ errStrings = append(
+ errStrings,
+ fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field name", index+1),
+ )
+ }
+
+ if notification.Uid == "" {
+ errStrings = append(
+ errStrings,
+ fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field uid", index+1),
+ )
+ }
+ }
+
+ for index, notification := range notifications[i].DeleteNotifications {
+ if notification.Name == "" {
+ errStrings = append(
+ errStrings,
+ fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field name", index+1),
+ )
+ }
+
+ if notification.Uid == "" {
+ errStrings = append(
+ errStrings,
+ fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field uid", index+1),
+ )
+ }
+ }
+
+ if len(errStrings) != 0 {
+ return fmt.Errorf(strings.Join(errStrings, "\n"))
+ }
+ }
+
+ return nil
+}
+
+func validateNotifications(notifications []*notificationsAsConfig) error {
+
+ for i := range notifications {
+ if notifications[i].Notifications == nil {
+ continue
+ }
+
+ for _, notification := range notifications[i].Notifications {
+ _, err := alerting.InitNotifier(&m.AlertNotification{
+ Name: notification.Name,
+ Settings: notification.SettingsToJson(),
+ Type: notification.Type,
+ })
+
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go
new file mode 100644
index 00000000000..87645ee7d31
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/config_reader_test.go
@@ -0,0 +1,313 @@
+package notifiers
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/log"
+ m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/alerting"
+ "github.com/grafana/grafana/pkg/services/alerting/notifiers"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+var (
+ correct_properties = "./testdata/test-configs/correct-properties"
+ incorrect_settings = "./testdata/test-configs/incorrect-settings"
+ no_required_fields = "./testdata/test-configs/no-required-fields"
+ correct_properties_with_orgName = "./testdata/test-configs/correct-properties-with-orgName"
+ brokenYaml = "./testdata/test-configs/broken-yaml"
+ doubleNotificationsConfig = "./testdata/test-configs/double-default"
+ emptyFolder = "./testdata/test-configs/empty_folder"
+ emptyFile = "./testdata/test-configs/empty"
+ twoNotificationsConfig = "./testdata/test-configs/two-notifications"
+ unknownNotifier = "./testdata/test-configs/unknown-notifier"
+)
+
+func TestNotificationAsConfig(t *testing.T) {
+ logger := log.New("fake.log")
+
+ Convey("Testing notification as configuration", t, func() {
+ sqlstore.InitTestDB(t)
+
+ alerting.RegisterNotifier(&alerting.NotifierPlugin{
+ Type: "slack",
+ Name: "slack",
+ Factory: notifiers.NewSlackNotifier,
+ })
+
+ alerting.RegisterNotifier(&alerting.NotifierPlugin{
+ Type: "email",
+ Name: "email",
+ Factory: notifiers.NewEmailNotifier,
+ })
+
+ Convey("Can read correct properties", func() {
+ cfgProvifer := &configReader{log: log.New("test logger")}
+ cfg, err := cfgProvifer.readConfig(correct_properties)
+ if err != nil {
+ t.Fatalf("readConfig return an error %v", err)
+ }
+ So(len(cfg), ShouldEqual, 1)
+
+ ntCfg := cfg[0]
+ nts := ntCfg.Notifications
+ So(len(nts), ShouldEqual, 4)
+
+ nt := nts[0]
+ So(nt.Name, ShouldEqual, "default-slack-notification")
+ So(nt.Type, ShouldEqual, "slack")
+ So(nt.OrgId, ShouldEqual, 2)
+ So(nt.Uid, ShouldEqual, "notifier1")
+ So(nt.IsDefault, ShouldBeTrue)
+ So(nt.Settings, ShouldResemble, map[string]interface{}{
+ "recipient": "XXX", "token": "xoxb", "uploadImage": true, "url": "https://slack.com",
+ })
+
+ nt = nts[1]
+ So(nt.Name, ShouldEqual, "another-not-default-notification")
+ So(nt.Type, ShouldEqual, "email")
+ So(nt.OrgId, ShouldEqual, 3)
+ So(nt.Uid, ShouldEqual, "notifier2")
+ So(nt.IsDefault, ShouldBeFalse)
+
+ nt = nts[2]
+ So(nt.Name, ShouldEqual, "check-unset-is_default-is-false")
+ So(nt.Type, ShouldEqual, "slack")
+ So(nt.OrgId, ShouldEqual, 3)
+ So(nt.Uid, ShouldEqual, "notifier3")
+ So(nt.IsDefault, ShouldBeFalse)
+
+ nt = nts[3]
+ So(nt.Name, ShouldEqual, "Added notification with whitespaces in name")
+ So(nt.Type, ShouldEqual, "email")
+ So(nt.Uid, ShouldEqual, "notifier4")
+ So(nt.OrgId, ShouldEqual, 3)
+
+ deleteNts := ntCfg.DeleteNotifications
+ So(len(deleteNts), ShouldEqual, 4)
+
+ deleteNt := deleteNts[0]
+ So(deleteNt.Name, ShouldEqual, "default-slack-notification")
+ So(deleteNt.Uid, ShouldEqual, "notifier1")
+ So(deleteNt.OrgId, ShouldEqual, 2)
+
+ deleteNt = deleteNts[1]
+ So(deleteNt.Name, ShouldEqual, "deleted-notification-without-orgId")
+ So(deleteNt.OrgId, ShouldEqual, 1)
+ So(deleteNt.Uid, ShouldEqual, "notifier2")
+
+ deleteNt = deleteNts[2]
+ So(deleteNt.Name, ShouldEqual, "deleted-notification-with-0-orgId")
+ So(deleteNt.OrgId, ShouldEqual, 1)
+ So(deleteNt.Uid, ShouldEqual, "notifier3")
+
+ deleteNt = deleteNts[3]
+ So(deleteNt.Name, ShouldEqual, "Deleted notification with whitespaces in name")
+ So(deleteNt.OrgId, ShouldEqual, 1)
+ So(deleteNt.Uid, ShouldEqual, "notifier4")
+ })
+
+ Convey("One configured notification", func() {
+ Convey("no notification in database", func() {
+ dc := newNotificationProvisioner(logger)
+ err := dc.applyChanges(twoNotificationsConfig)
+ if err != nil {
+ t.Fatalf("applyChanges return an error %v", err)
+ }
+ notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldNotBeNil)
+ So(len(notificationsQuery.Result), ShouldEqual, 2)
+ })
+
+ Convey("One notification in database with same name and uid", func() {
+ existingNotificationCmd := m.CreateAlertNotificationCommand{
+ Name: "channel1",
+ OrgId: 1,
+ Uid: "notifier1",
+ Type: "slack",
+ }
+ err := sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
+ So(err, ShouldBeNil)
+ So(existingNotificationCmd.Result, ShouldNotBeNil)
+ notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldNotBeNil)
+ So(len(notificationsQuery.Result), ShouldEqual, 1)
+
+ Convey("should update one notification", func() {
+ dc := newNotificationProvisioner(logger)
+ err = dc.applyChanges(twoNotificationsConfig)
+ if err != nil {
+ t.Fatalf("applyChanges return an error %v", err)
+ }
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldNotBeNil)
+ So(len(notificationsQuery.Result), ShouldEqual, 2)
+
+ nts := notificationsQuery.Result
+ nt1 := nts[0]
+ So(nt1.Type, ShouldEqual, "email")
+ So(nt1.Name, ShouldEqual, "channel1")
+ So(nt1.Uid, ShouldEqual, "notifier1")
+
+ nt2 := nts[1]
+ So(nt2.Type, ShouldEqual, "slack")
+ So(nt2.Name, ShouldEqual, "channel2")
+ So(nt2.Uid, ShouldEqual, "notifier2")
+ })
+ })
+ Convey("Two notifications with is_default", func() {
+ dc := newNotificationProvisioner(logger)
+ err := dc.applyChanges(doubleNotificationsConfig)
+ Convey("should both be inserted", func() {
+ So(err, ShouldBeNil)
+ notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldNotBeNil)
+ So(len(notificationsQuery.Result), ShouldEqual, 2)
+
+ So(notificationsQuery.Result[0].IsDefault, ShouldBeTrue)
+ So(notificationsQuery.Result[1].IsDefault, ShouldBeTrue)
+ })
+ })
+ })
+
+ Convey("Two configured notification", func() {
+ Convey("two other notifications in database", func() {
+ existingNotificationCmd := m.CreateAlertNotificationCommand{
+ Name: "channel0",
+ OrgId: 1,
+ Uid: "notifier0",
+ Type: "slack",
+ }
+ err := sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
+ So(err, ShouldBeNil)
+ existingNotificationCmd = m.CreateAlertNotificationCommand{
+ Name: "channel3",
+ OrgId: 1,
+ Uid: "notifier3",
+ Type: "slack",
+ }
+ err = sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
+ So(err, ShouldBeNil)
+
+ notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldNotBeNil)
+ So(len(notificationsQuery.Result), ShouldEqual, 2)
+
+ Convey("should have two new notifications", func() {
+ dc := newNotificationProvisioner(logger)
+ err := dc.applyChanges(twoNotificationsConfig)
+ if err != nil {
+ t.Fatalf("applyChanges return an error %v", err)
+ }
+ notificationsQuery = m.GetAllAlertNotificationsQuery{OrgId: 1}
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldNotBeNil)
+ So(len(notificationsQuery.Result), ShouldEqual, 4)
+ })
+ })
+ })
+
+ Convey("Can read correct properties with orgName instead of orgId", func() {
+ existingOrg1 := m.CreateOrgCommand{Name: "Main Org. 1"}
+ err := sqlstore.CreateOrg(&existingOrg1)
+ So(err, ShouldBeNil)
+ So(existingOrg1.Result, ShouldNotBeNil)
+ existingOrg2 := m.CreateOrgCommand{Name: "Main Org. 2"}
+ err = sqlstore.CreateOrg(&existingOrg2)
+ So(err, ShouldBeNil)
+ So(existingOrg2.Result, ShouldNotBeNil)
+
+ existingNotificationCmd := m.CreateAlertNotificationCommand{
+ Name: "default-notification-delete",
+ OrgId: existingOrg2.Result.Id,
+ Uid: "notifier2",
+ Type: "slack",
+ }
+ err = sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd)
+ So(err, ShouldBeNil)
+
+ dc := newNotificationProvisioner(logger)
+ err = dc.applyChanges(correct_properties_with_orgName)
+ if err != nil {
+ t.Fatalf("applyChanges return an error %v", err)
+ }
+
+ notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: existingOrg2.Result.Id}
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldNotBeNil)
+ So(len(notificationsQuery.Result), ShouldEqual, 1)
+
+ nt := notificationsQuery.Result[0]
+ So(nt.Name, ShouldEqual, "default-notification-create")
+ So(nt.OrgId, ShouldEqual, existingOrg2.Result.Id)
+
+ })
+
+ Convey("Config doesn't contain required field", func() {
+ dc := newNotificationProvisioner(logger)
+ err := dc.applyChanges(no_required_fields)
+ So(err, ShouldNotBeNil)
+
+ errString := err.Error()
+ So(errString, ShouldContainSubstring, "Deleted alert notification item 1 in configuration doesn't contain required field uid")
+ So(errString, ShouldContainSubstring, "Deleted alert notification item 2 in configuration doesn't contain required field name")
+ So(errString, ShouldContainSubstring, "Added alert notification item 1 in configuration doesn't contain required field name")
+ So(errString, ShouldContainSubstring, "Added alert notification item 2 in configuration doesn't contain required field uid")
+ })
+
+ Convey("Empty yaml file", func() {
+ Convey("should have not changed repo", func() {
+ dc := newNotificationProvisioner(logger)
+ err := dc.applyChanges(emptyFile)
+ if err != nil {
+ t.Fatalf("applyChanges return an error %v", err)
+ }
+ notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1}
+ err = sqlstore.GetAllAlertNotifications(¬ificationsQuery)
+ So(err, ShouldBeNil)
+ So(notificationsQuery.Result, ShouldBeEmpty)
+ })
+ })
+
+ Convey("Broken yaml should return error", func() {
+ reader := &configReader{log: log.New("test logger")}
+ _, err := reader.readConfig(brokenYaml)
+ So(err, ShouldNotBeNil)
+ })
+
+ Convey("Skip invalid directory", func() {
+ cfgProvifer := &configReader{log: log.New("test logger")}
+ cfg, err := cfgProvifer.readConfig(emptyFolder)
+ if err != nil {
+ t.Fatalf("readConfig return an error %v", err)
+ }
+ So(len(cfg), ShouldEqual, 0)
+ })
+
+ Convey("Unknown notifier should return error", func() {
+ cfgProvifer := &configReader{log: log.New("test logger")}
+ _, err := cfgProvifer.readConfig(unknownNotifier)
+ So(err, ShouldNotBeNil)
+ So(err.Error(), ShouldEqual, "Unsupported notification type")
+ })
+
+ Convey("Read incorrect properties", func() {
+ cfgProvifer := &configReader{log: log.New("test logger")}
+ _, err := cfgProvifer.readConfig(incorrect_settings)
+ So(err, ShouldNotBeNil)
+ So(err.Error(), ShouldEqual, "Alert validation error: Could not find url property in settings")
+ })
+ })
+}
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml
new file mode 100644
index 00000000000..72f2fbdbf63
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml
@@ -0,0 +1,9 @@
+notifiers:
+ - name: notification-channel-1
+ type: slack
+ org_id: 2
+ is_default: true
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text
new file mode 100644
index 00000000000..9050f543cef
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text
@@ -0,0 +1,6 @@
+#sfxzgnsxzcvnbzcvn
+cvbn
+cvbn
+c
+vbn
+cvbncvbn
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml
new file mode 100644
index 00000000000..25c4536d1f3
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml
@@ -0,0 +1,12 @@
+notifiers:
+ - name: default-notification-create
+ type: email
+ uid: notifier2
+ settings:
+ addresses: example@example.com
+ org_name: Main Org. 2
+ is_default: false
+delete_notifiers:
+ - name: default-notification-delete
+ org_name: Main Org. 2
+ uid: notifier2
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml
new file mode 100644
index 00000000000..af0736f35a4
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml
@@ -0,0 +1,42 @@
+notifiers:
+ - name: default-slack-notification
+ type: slack
+ uid: notifier1
+ org_id: 2
+ uid: "notifier1"
+ is_default: true
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
+ url: https://slack.com
+ - name: another-not-default-notification
+ type: email
+ settings:
+ addresses: example@exmaple.com
+ org_id: 3
+ uid: "notifier2"
+ is_default: false
+ - name: check-unset-is_default-is-false
+ type: slack
+ org_id: 3
+ uid: "notifier3"
+ settings:
+ url: https://slack.com
+ - name: Added notification with whitespaces in name
+ type: email
+ org_id: 3
+ uid: "notifier4"
+ settings:
+ addresses: example@exmaple.com
+delete_notifiers:
+ - name: default-slack-notification
+ org_id: 2
+ uid: notifier1
+ - name: deleted-notification-without-orgId
+ uid: "notifier2"
+ - name: deleted-notification-with-0-orgId
+ org_id: 0
+ uid: "notifier3"
+ - name: Deleted notification with whitespaces in name
+ uid: "notifier4"
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml
new file mode 100644
index 00000000000..d9d2fe66081
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml
@@ -0,0 +1,7 @@
+notifiers:
+ - name: first-default
+ type: slack
+ uid: notifier1
+ is_default: true
+ settings:
+ url: https://slack.com
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml
new file mode 100644
index 00000000000..878f8b48aa5
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml
@@ -0,0 +1,7 @@
+notifiers:
+ - name: second-default
+ type: email
+ uid: notifier2
+ is_default: true
+ settings:
+ addresses: example@example.com
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore b/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore
new file mode 100644
index 00000000000..86d0cb2726c
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore
@@ -0,0 +1,4 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml
new file mode 100644
index 00000000000..b7ecfbdf012
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml
@@ -0,0 +1,10 @@
+notifiers:
+ - name: slack-notification-without-url-in-settings
+ type: slack
+ org_id: 2
+ uid: notifier1
+ is_default: true
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml
new file mode 100644
index 00000000000..55ff545525e
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml
@@ -0,0 +1,35 @@
+notifiers:
+ - type: slack
+ org_id: 2
+ uid: no-name_added-notification
+ is_default: true
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
+ - name: no-uid
+ type: slack
+ org_id: 2
+ is_default: true
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
+delete_notifiers:
+ - name: no-uid
+ type: slack
+ org_id: 2
+ is_default: true
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
+ - type: slack
+ org_id: 2
+ uid: no-name_added-notification
+ is_default: true
+ settings:
+ recipient: "XXX"
+ token: "xoxb"
+ uploadImage: true
+
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml
new file mode 100644
index 00000000000..aeeb718e6de
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml
@@ -0,0 +1,12 @@
+notifiers:
+ - name: channel1
+ type: email
+ uid: notifier1
+ org_id: 1
+ settings:
+ addresses: example@example.com
+ - name: channel2
+ type: slack
+ uid: notifier2
+ settings:
+ url: http://slack.com
diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml
new file mode 100644
index 00000000000..ca0d3fa3c75
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml
@@ -0,0 +1,4 @@
+notifiers:
+ - name: unknown-notifier
+ type: nonexisting
+ uid: notifier1
\ No newline at end of file
diff --git a/pkg/services/provisioning/notifiers/types.go b/pkg/services/provisioning/notifiers/types.go
new file mode 100644
index 00000000000..f788da79c79
--- /dev/null
+++ b/pkg/services/provisioning/notifiers/types.go
@@ -0,0 +1,38 @@
+package notifiers
+
+import "github.com/grafana/grafana/pkg/components/simplejson"
+
+type notificationsAsConfig struct {
+ Notifications []*notificationFromConfig `json:"notifiers" yaml:"notifiers"`
+ DeleteNotifications []*deleteNotificationConfig `json:"delete_notifiers" yaml:"delete_notifiers"`
+}
+
+type deleteNotificationConfig struct {
+ Uid string `json:"uid" yaml:"uid"`
+ Name string `json:"name" yaml:"name"`
+ OrgId int64 `json:"org_id" yaml:"org_id"`
+ OrgName string `json:"org_name" yaml:"org_name"`
+}
+
+type notificationFromConfig struct {
+ Uid string `json:"uid" yaml:"uid"`
+ OrgId int64 `json:"org_id" yaml:"org_id"`
+ OrgName string `json:"org_name" yaml:"org_name"`
+ Name string `json:"name" yaml:"name"`
+ Type string `json:"type" yaml:"type"`
+ SendReminder bool `json:"send_reminder" yaml:"send_reminder"`
+ DisableResolveMessage bool `json:"disable_resolve_message" yaml:"disable_resolve_message"`
+ Frequency string `json:"frequency" yaml:"frequency"`
+ IsDefault bool `json:"is_default" yaml:"is_default"`
+ Settings map[string]interface{} `json:"settings" yaml:"settings"`
+}
+
+func (notification notificationFromConfig) SettingsToJson() *simplejson.Json {
+ settings := simplejson.New()
+ if len(notification.Settings) > 0 {
+ for k, v := range notification.Settings {
+ settings.Set(k, v)
+ }
+ }
+ return settings
+}
diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go
index 9044ae97389..45f0972b885 100644
--- a/pkg/services/provisioning/provisioning.go
+++ b/pkg/services/provisioning/provisioning.go
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
"github.com/grafana/grafana/pkg/services/provisioning/datasources"
+ "github.com/grafana/grafana/pkg/services/provisioning/notifiers"
"github.com/grafana/grafana/pkg/setting"
)
@@ -25,6 +26,11 @@ func (ps *ProvisioningService) Init() error {
return fmt.Errorf("Datasource provisioning error: %v", err)
}
+ alertNotificationsPath := path.Join(ps.Cfg.ProvisioningPath, "notifiers")
+ if err := notifiers.Provision(alertNotificationsPath); err != nil {
+ return fmt.Errorf("Alert notification provisioning error: %v", err)
+ }
+
return nil
}
diff --git a/pkg/services/session/session.go b/pkg/services/session/session.go
index 5873a6a5b72..2e60b8a25d7 100644
--- a/pkg/services/session/session.go
+++ b/pkg/services/session/session.go
@@ -14,8 +14,6 @@ import (
const (
SESS_KEY_USERID = "uid"
- SESS_KEY_OAUTH_STATE = "state"
- SESS_KEY_APIKEY = "apikey_id" // used for render requests with api keys
SESS_KEY_LASTLDAPSYNC = "last_ldap_sync"
)
diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go
index afe6269510f..2685c6b52a1 100644
--- a/pkg/services/sqlstore/alert_notification.go
+++ b/pkg/services/sqlstore/alert_notification.go
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/util"
)
func init() {
@@ -17,11 +18,15 @@ func init() {
bus.AddHandler("sql", CreateAlertNotificationCommand)
bus.AddHandler("sql", UpdateAlertNotification)
bus.AddHandler("sql", DeleteAlertNotification)
- bus.AddHandler("sql", GetAlertNotificationsToSend)
bus.AddHandler("sql", GetAllAlertNotifications)
bus.AddHandlerCtx("sql", GetOrCreateAlertNotificationState)
bus.AddHandlerCtx("sql", SetAlertNotificationStateToCompleteCommand)
bus.AddHandlerCtx("sql", SetAlertNotificationStateToPendingCommand)
+
+ bus.AddHandler("sql", GetAlertNotificationsWithUid)
+ bus.AddHandler("sql", UpdateAlertNotificationWithUid)
+ bus.AddHandler("sql", DeleteAlertNotificationWithUid)
+ bus.AddHandler("sql", GetAlertNotificationsWithUidToSend)
}
func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
@@ -39,10 +44,33 @@ func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
})
}
+func DeleteAlertNotificationWithUid(cmd *m.DeleteAlertNotificationWithUidCommand) error {
+ existingNotification := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid}
+ if err := getAlertNotificationWithUidInternal(existingNotification, newSession()); err != nil {
+ return err
+ }
+
+ if existingNotification.Result != nil {
+ deleteCommand := &m.DeleteAlertNotificationCommand{
+ Id: existingNotification.Result.Id,
+ OrgId: existingNotification.Result.OrgId,
+ }
+ if err := bus.Dispatch(deleteCommand); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
func GetAlertNotifications(query *m.GetAlertNotificationsQuery) error {
return getAlertNotificationInternal(query, newSession())
}
+func GetAlertNotificationsWithUid(query *m.GetAlertNotificationsWithUidQuery) error {
+ return getAlertNotificationWithUidInternal(query, newSession())
+}
+
func GetAllAlertNotifications(query *m.GetAllAlertNotificationsQuery) error {
results := make([]*m.AlertNotification, 0)
if err := x.Where("org_id = ?", query.OrgId).Find(&results); err != nil {
@@ -53,12 +81,13 @@ func GetAllAlertNotifications(query *m.GetAllAlertNotificationsQuery) error {
return nil
}
-func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) error {
+func GetAlertNotificationsWithUidToSend(query *m.GetAlertNotificationsWithUidToSendQuery) error {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(`SELECT
alert_notification.id,
+ alert_notification.uid,
alert_notification.org_id,
alert_notification.name,
alert_notification.type,
@@ -77,9 +106,10 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
sql.WriteString(` AND ((alert_notification.is_default = ?)`)
params = append(params, dialect.BooleanStr(true))
- if len(query.Ids) > 0 {
- sql.WriteString(` OR alert_notification.id IN (?` + strings.Repeat(",?", len(query.Ids)-1) + ")")
- for _, v := range query.Ids {
+
+ if len(query.Uids) > 0 {
+ sql.WriteString(` OR alert_notification.uid IN (?` + strings.Repeat(",?", len(query.Uids)-1) + ")")
+ for _, v := range query.Uids {
params = append(params, v)
}
}
@@ -142,16 +172,70 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS
return nil
}
+func getAlertNotificationWithUidInternal(query *m.GetAlertNotificationsWithUidQuery, sess *DBSession) error {
+ var sql bytes.Buffer
+ params := make([]interface{}, 0)
+
+ sql.WriteString(`SELECT
+ alert_notification.id,
+ alert_notification.uid,
+ alert_notification.org_id,
+ alert_notification.name,
+ alert_notification.type,
+ alert_notification.created,
+ alert_notification.updated,
+ alert_notification.settings,
+ alert_notification.is_default,
+ alert_notification.disable_resolve_message,
+ alert_notification.send_reminder,
+ alert_notification.frequency
+ FROM alert_notification
+ `)
+
+ sql.WriteString(` WHERE alert_notification.org_id = ? AND alert_notification.uid = ?`)
+ params = append(params, query.OrgId, query.Uid)
+
+ results := make([]*m.AlertNotification, 0)
+ if err := sess.SQL(sql.String(), params...).Find(&results); err != nil {
+ return err
+ }
+
+ if len(results) == 0 {
+ query.Result = nil
+ } else {
+ query.Result = results[0]
+ }
+
+ return nil
+}
+
func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error {
return inTransaction(func(sess *DBSession) error {
- existingQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name}
- err := getAlertNotificationInternal(existingQuery, sess)
+ if cmd.Uid == "" {
+ if uid, uidGenerationErr := generateNewAlertNotificationUid(sess, cmd.OrgId); uidGenerationErr != nil {
+ return uidGenerationErr
+ } else {
+ cmd.Uid = uid
+ }
+ }
+ existingQuery := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid}
+ err := getAlertNotificationWithUidInternal(existingQuery, sess)
if err != nil {
return err
}
if existingQuery.Result != nil {
+ return fmt.Errorf("Alert notification uid %s already exists", cmd.Uid)
+ }
+
+ // check if name exists
+ sameNameQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name}
+ if err := getAlertNotificationInternal(sameNameQuery, sess); err != nil {
+ return err
+ }
+
+ if sameNameQuery.Result != nil {
return fmt.Errorf("Alert notification name %s already exists", cmd.Name)
}
@@ -168,6 +252,7 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
}
alertNotification := &m.AlertNotification{
+ Uid: cmd.Uid,
OrgId: cmd.OrgId,
Name: cmd.Name,
Type: cmd.Type,
@@ -189,6 +274,22 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
})
}
+func generateNewAlertNotificationUid(sess *DBSession, orgId int64) (string, error) {
+ for i := 0; i < 3; i++ {
+ uid := util.GenerateShortUID()
+ exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.AlertNotification{})
+ if err != nil {
+ return "", err
+ }
+
+ if !exists {
+ return uid, nil
+ }
+ }
+
+ return "", m.ErrAlertNotificationFailedGenerateUniqueUid
+}
+
func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
return inTransaction(func(sess *DBSession) (err error) {
current := m.AlertNotification{}
@@ -241,6 +342,39 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
})
}
+func UpdateAlertNotificationWithUid(cmd *m.UpdateAlertNotificationWithUidCommand) error {
+ getAlertNotificationWithUidQuery := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid}
+
+ if err := getAlertNotificationWithUidInternal(getAlertNotificationWithUidQuery, newSession()); err != nil {
+ return err
+ }
+
+ current := getAlertNotificationWithUidQuery.Result
+
+ if current == nil {
+ return fmt.Errorf("Cannot update, alert notification uid %s doesn't exist", cmd.Uid)
+ }
+
+ updateNotification := &m.UpdateAlertNotificationCommand{
+ Id: current.Id,
+ Name: cmd.Name,
+ Type: cmd.Type,
+ SendReminder: cmd.SendReminder,
+ DisableResolveMessage: cmd.DisableResolveMessage,
+ Frequency: cmd.Frequency,
+ IsDefault: cmd.IsDefault,
+ Settings: cmd.Settings,
+
+ OrgId: cmd.OrgId,
+ }
+
+ if err := bus.Dispatch(updateNotification); err != nil {
+ return err
+ }
+
+ return nil
+}
+
func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error {
return inTransactionCtx(ctx, func(sess *DBSession) error {
version := cmd.Version
diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go
index 629a6292eb5..91b84cb91d0 100644
--- a/pkg/services/sqlstore/alert_notification_test.go
+++ b/pkg/services/sqlstore/alert_notification_test.go
@@ -220,11 +220,38 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(cmd.Result.Type, ShouldEqual, "email")
So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
So(cmd.Result.DisableResolveMessage, ShouldBeFalse)
+ So(cmd.Result.Uid, ShouldNotBeEmpty)
Convey("Cannot save Alert Notification with the same name", func() {
err = CreateAlertNotificationCommand(cmd)
So(err, ShouldNotBeNil)
})
+ Convey("Cannot save Alert Notification with the same name and another uid", func() {
+ anotherUidCmd := &models.CreateAlertNotificationCommand{
+ Name: cmd.Name,
+ Type: cmd.Type,
+ OrgId: 1,
+ SendReminder: cmd.SendReminder,
+ Frequency: cmd.Frequency,
+ Settings: cmd.Settings,
+ Uid: "notifier1",
+ }
+ err = CreateAlertNotificationCommand(anotherUidCmd)
+ So(err, ShouldNotBeNil)
+ })
+ Convey("Can save Alert Notification with another name and another uid", func() {
+ anotherUidCmd := &models.CreateAlertNotificationCommand{
+ Name: "another ops",
+ Type: cmd.Type,
+ OrgId: 1,
+ SendReminder: cmd.SendReminder,
+ Frequency: cmd.Frequency,
+ Settings: cmd.Settings,
+ Uid: "notifier2",
+ }
+ err = CreateAlertNotificationCommand(anotherUidCmd)
+ So(err, ShouldBeNil)
+ })
Convey("Can update alert notification", func() {
newCmd := &models.UpdateAlertNotificationCommand{
@@ -274,12 +301,12 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil)
Convey("search", func() {
- query := &models.GetAlertNotificationsToSendQuery{
- Ids: []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231},
+ query := &models.GetAlertNotificationsWithUidToSendQuery{
+ Uids: []string{cmd1.Result.Uid, cmd2.Result.Uid, "112341231"},
OrgId: 1,
}
- err := GetAlertNotificationsToSend(query)
+ err := GetAlertNotificationsWithUidToSend(query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 3)
})
diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go
index bad46c10af4..7ebdbfa65e7 100644
--- a/pkg/services/sqlstore/dashboard.go
+++ b/pkg/services/sqlstore/dashboard.go
@@ -27,7 +27,7 @@ func init() {
bus.AddHandler("sql", HasEditPermissionInFolders)
}
-var generateNewUid func() string = util.GenerateShortUid
+var generateNewUid func() string = util.GenerateShortUID
func SaveDashboard(cmd *m.SaveDashboardCommand) error {
return inTransaction(func(sess *DBSession) error {
diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go
index 33fbb01c5b7..9b180662278 100644
--- a/pkg/services/sqlstore/dashboard_provisioning.go
+++ b/pkg/services/sqlstore/dashboard_provisioning.go
@@ -51,7 +51,7 @@ func SaveProvisionedDashboard(cmd *models.SaveProvisionedDashboardCommand) error
func saveProvionedData(sess *DBSession, cmd *models.DashboardProvisioning, dashboard *models.Dashboard) error {
result := &models.DashboardProvisioning{}
- exist, err := sess.Where("dashboard_id=?", dashboard.Id).Get(result)
+ exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, cmd.Name).Get(result)
if err != nil {
return err
}
diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go
index 8ff78c4a0ff..1a82d314de0 100644
--- a/pkg/services/sqlstore/dashboard_test.go
+++ b/pkg/services/sqlstore/dashboard_test.go
@@ -106,7 +106,7 @@ func TestDashboardDataAccess(t *testing.T) {
if timesCalled <= 2 {
return savedDash.Uid
}
- return util.GenerateShortUid()
+ return util.GenerateShortUID()
}
cmd := m.SaveDashboardCommand{
OrgId: 1,
@@ -119,7 +119,7 @@ func TestDashboardDataAccess(t *testing.T) {
err := SaveDashboard(&cmd)
So(err, ShouldBeNil)
- generateNewUid = util.GenerateShortUid
+ generateNewUid = util.GenerateShortUID
})
Convey("Should be able to create dashboard", func() {
diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go
index 78da198e8e7..ceff2394dce 100644
--- a/pkg/services/sqlstore/login_attempt.go
+++ b/pkg/services/sqlstore/login_attempt.go
@@ -78,14 +78,14 @@ func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error {
}
func toInt64(i interface{}) int64 {
- switch i.(type) {
+ switch i := i.(type) {
case []byte:
- n, _ := strconv.ParseInt(string(i.([]byte)), 10, 64)
+ n, _ := strconv.ParseInt(string(i), 10, 64)
return n
case int:
- return int64(i.(int))
+ return int64(i)
case int64:
- return i.(int64)
+ return i
}
return 0
}
diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go
index b5aeb26483c..84014bd386f 100644
--- a/pkg/services/sqlstore/migrations/alert_mig.go
+++ b/pkg/services/sqlstore/migrations/alert_mig.go
@@ -137,4 +137,21 @@ func addAlertMigrations(mg *Migrator) {
mg.AddMigration("Add for to alert table", NewAddColumnMigration(alertV1, &Column{
Name: "for", Type: DB_BigInt, Nullable: true,
}))
+
+ mg.AddMigration("Add column uid in alert_notification", NewAddColumnMigration(alert_notification, &Column{
+ Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true,
+ }))
+
+ mg.AddMigration("Update uid column values in alert_notification", new(RawSqlMigration).
+ Sqlite("UPDATE alert_notification SET uid=printf('%09d',id) WHERE uid IS NULL;").
+ Postgres("UPDATE alert_notification SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;").
+ Mysql("UPDATE alert_notification SET uid=lpad(id,9,'0') WHERE uid IS NULL;"))
+
+ mg.AddMigration("Add unique index alert_notification_org_id_uid", NewAddIndexMigration(alert_notification, &Index{
+ Cols: []string{"org_id", "uid"}, Type: UniqueIndex,
+ }))
+
+ mg.AddMigration("Remove unique index org_id_name", NewDropIndexMigration(alert_notification, &Index{
+ Cols: []string{"org_id", "name"}, Type: UniqueIndex,
+ }))
}
diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go
index 36cd8e5ed62..931259ec3ed 100644
--- a/pkg/services/sqlstore/migrations/migrations.go
+++ b/pkg/services/sqlstore/migrations/migrations.go
@@ -32,6 +32,7 @@ func AddMigrations(mg *Migrator) {
addLoginAttemptMigrations(mg)
addUserAuthMigrations(mg)
addServerlockMigrations(mg)
+ addUserAuthTokenMigrations(mg)
}
func addMigrationLogMigrations(mg *Migrator) {
diff --git a/pkg/services/sqlstore/migrations/user_auth_token_mig.go b/pkg/services/sqlstore/migrations/user_auth_token_mig.go
new file mode 100644
index 00000000000..9794b7a78c7
--- /dev/null
+++ b/pkg/services/sqlstore/migrations/user_auth_token_mig.go
@@ -0,0 +1,32 @@
+package migrations
+
+import (
+ . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+)
+
+func addUserAuthTokenMigrations(mg *Migrator) {
+ userAuthTokenV1 := Table{
+ Name: "user_auth_token",
+ Columns: []*Column{
+ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+ {Name: "user_id", Type: DB_BigInt, Nullable: false},
+ {Name: "auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
+ {Name: "prev_auth_token", Type: DB_NVarchar, Length: 100, Nullable: false},
+ {Name: "user_agent", Type: DB_NVarchar, Length: 255, Nullable: false},
+ {Name: "client_ip", Type: DB_NVarchar, Length: 255, Nullable: false},
+ {Name: "auth_token_seen", Type: DB_Bool, Nullable: false},
+ {Name: "seen_at", Type: DB_Int, Nullable: true},
+ {Name: "rotated_at", Type: DB_Int, Nullable: false},
+ {Name: "created_at", Type: DB_Int, Nullable: false},
+ {Name: "updated_at", Type: DB_Int, Nullable: false},
+ },
+ Indices: []*Index{
+ {Cols: []string{"auth_token"}, Type: UniqueIndex},
+ {Cols: []string{"prev_auth_token"}, Type: UniqueIndex},
+ },
+ }
+
+ mg.AddMigration("create user auth token table", NewAddTableMigration(userAuthTokenV1))
+ mg.AddMigration("add unique index user_auth_token.auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[0]))
+ mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1]))
+}
diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go
index d0e93177d8b..fb0f0938573 100644
--- a/pkg/services/sqlstore/sqlstore.go
+++ b/pkg/services/sqlstore/sqlstore.go
@@ -21,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
@@ -195,6 +196,23 @@ func (ss *SqlStore) ensureAdminUser() error {
return err
}
+func (ss *SqlStore) buildExtraConnectionString(sep rune) string {
+ if ss.dbCfg.UrlQueryParams == nil {
+ return ""
+ }
+
+ var sb strings.Builder
+ for key, values := range ss.dbCfg.UrlQueryParams {
+ for _, value := range values {
+ sb.WriteRune(sep)
+ sb.WriteString(key)
+ sb.WriteRune('=')
+ sb.WriteString(value)
+ }
+ }
+ return sb.String()
+}
+
func (ss *SqlStore) buildConnectionString() (string, error) {
cnnstr := ss.dbCfg.ConnectionString
@@ -221,14 +239,12 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
mysql.RegisterTLSConfig("custom", tlsCert)
cnnstr += "&tls=custom"
}
+
+ cnnstr += ss.buildExtraConnectionString('&')
case migrator.POSTGRES:
- var host, port = "127.0.0.1", "5432"
- fields := strings.Split(ss.dbCfg.Host, ":")
- if len(fields) > 0 && len(strings.TrimSpace(fields[0])) > 0 {
- host = fields[0]
- }
- if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
- port = fields[1]
+ host, port, err := util.SplitIPPort(ss.dbCfg.Host, "5432")
+ if err != nil {
+ return "", err
}
if ss.dbCfg.Pwd == "" {
ss.dbCfg.Pwd = "''"
@@ -237,6 +253,8 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
ss.dbCfg.User = "''"
}
cnnstr = fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s", ss.dbCfg.User, ss.dbCfg.Pwd, host, port, ss.dbCfg.Name, ss.dbCfg.SslMode, ss.dbCfg.ClientCertPath, ss.dbCfg.ClientKeyPath, ss.dbCfg.CaCertPath)
+
+ cnnstr += ss.buildExtraConnectionString(' ')
case migrator.SQLITE:
// special case for tests
if !filepath.IsAbs(ss.dbCfg.Path) {
@@ -244,6 +262,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
}
os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", ss.dbCfg.Path, ss.dbCfg.CacheMode)
+ cnnstr += ss.buildExtraConnectionString('&')
default:
return "", fmt.Errorf("Unknown database type: %s", ss.dbCfg.Type)
}
@@ -300,6 +319,8 @@ func (ss *SqlStore) readConfig() {
ss.dbCfg.User = userInfo.Username()
ss.dbCfg.Pwd, _ = userInfo.Password()
}
+
+ ss.dbCfg.UrlQueryParams = dbURL.Query()
} else {
ss.dbCfg.Type = sec.Key("type").String()
ss.dbCfg.Host = sec.Key("host").String()
@@ -409,4 +430,5 @@ type DatabaseConfig struct {
MaxIdleConn int
ConnMaxLifetime int
CacheMode string
+ UrlQueryParams map[string][]string
}
diff --git a/pkg/services/sqlstore/sqlstore_test.go b/pkg/services/sqlstore/sqlstore_test.go
new file mode 100644
index 00000000000..76402d6a50d
--- /dev/null
+++ b/pkg/services/sqlstore/sqlstore_test.go
@@ -0,0 +1,101 @@
+package sqlstore
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+type sqlStoreTest struct {
+ name string
+ dbType string
+ dbHost string
+ connStrValues []string
+}
+
+var sqlStoreTestCases = []sqlStoreTest{
+ {
+ name: "MySQL IPv4",
+ dbType: "mysql",
+ dbHost: "1.2.3.4:5678",
+ connStrValues: []string{"tcp(1.2.3.4:5678)"},
+ },
+ {
+ name: "Postgres IPv4",
+ dbType: "postgres",
+ dbHost: "1.2.3.4:5678",
+ connStrValues: []string{"host=1.2.3.4", "port=5678"},
+ },
+ {
+ name: "Postgres IPv4 (Default Port)",
+ dbType: "postgres",
+ dbHost: "1.2.3.4",
+ connStrValues: []string{"host=1.2.3.4", "port=5432"},
+ },
+ {
+ name: "MySQL IPv4 (Default Port)",
+ dbType: "mysql",
+ dbHost: "1.2.3.4",
+ connStrValues: []string{"tcp(1.2.3.4)"},
+ },
+ {
+ name: "MySQL IPv6",
+ dbType: "mysql",
+ dbHost: "[fe80::24e8:31b2:91df:b177]:1234",
+ connStrValues: []string{"tcp([fe80::24e8:31b2:91df:b177]:1234)"},
+ },
+ {
+ name: "Postgres IPv6",
+ dbType: "postgres",
+ dbHost: "[fe80::24e8:31b2:91df:b177]:1234",
+ connStrValues: []string{"host=fe80::24e8:31b2:91df:b177", "port=1234"},
+ },
+ {
+ name: "MySQL IPv6 (Default Port)",
+ dbType: "mysql",
+ dbHost: "::1",
+ connStrValues: []string{"tcp(::1)"},
+ },
+ {
+ name: "Postgres IPv6 (Default Port)",
+ dbType: "postgres",
+ dbHost: "::1",
+ connStrValues: []string{"host=::1", "port=5432"},
+ },
+}
+
+func TestSqlConnectionString(t *testing.T) {
+ Convey("Testing SQL Connection Strings", t, func() {
+ t.Helper()
+
+ for _, testCase := range sqlStoreTestCases {
+ Convey(testCase.name, func() {
+ sqlstore := &SqlStore{}
+ sqlstore.Cfg = makeSqlStoreTestConfig(testCase.dbType, testCase.dbHost)
+ sqlstore.readConfig()
+
+ connStr, err := sqlstore.buildConnectionString()
+
+ So(err, ShouldBeNil)
+ for _, connSubStr := range testCase.connStrValues {
+ So(connStr, ShouldContainSubstring, connSubStr)
+ }
+ })
+ }
+ })
+}
+
+func makeSqlStoreTestConfig(dbType string, host string) *setting.Cfg {
+ cfg := setting.NewCfg()
+
+ sec, _ := cfg.Raw.NewSection("database")
+ sec.NewKey("type", dbType)
+ sec.NewKey("host", host)
+ sec.NewKey("user", "user")
+ sec.NewKey("name", "test_db")
+ sec.NewKey("password", "pass")
+
+ return cfg
+}
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 1417392fdf8..cf486a228ab 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -18,7 +18,7 @@ import (
"github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util"
- "gopkg.in/ini.v1"
+ ini "gopkg.in/ini.v1"
)
type Scheme string
@@ -77,15 +77,13 @@ var (
SocketPath string
RouterLogging bool
DataProxyLogging bool
+ DataProxyTimeout int
StaticRootPath string
EnableGzip bool
EnforceDomain bool
// Security settings.
SecretKey string
- LogInRememberDays int
- CookieUserName string
- CookieRememberName string
DisableGravatar bool
EmailCodeValidMinutes int
DataProxyWhiteList map[string]bool
@@ -222,7 +220,15 @@ type Cfg struct {
MetricsEndpointBasicAuthUsername string
MetricsEndpointBasicAuthPassword string
EnableAlphaPanels bool
+ DisableSanitizeHtml bool
EnterpriseLicensePath string
+
+ LoginCookieName string
+ LoginCookieMaxDays int
+ LoginCookieRotation int
+ LoginDeleteExpiredTokensAfterDays int
+
+ SecurityHTTPSCookies bool
}
type CommandLineArgs struct {
@@ -546,6 +552,16 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
ApplicationName = APP_NAME_ENTERPRISE
}
+ //login
+ login := iniFile.Section("login")
+ cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
+ cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
+ cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
+ cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
+ if cfg.LoginCookieRotation < 2 {
+ cfg.LoginCookieRotation = 2
+ }
+
Env = iniFile.Section("").Key("app_mode").MustString("development")
InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
@@ -582,15 +598,14 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// read data proxy settings
dataproxy := iniFile.Section("dataproxy")
DataProxyLogging = dataproxy.Key("logging").MustBool(false)
+ DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
// read security settings
security := iniFile.Section("security")
SecretKey = security.Key("secret_key").String()
- LogInRememberDays = security.Key("login_remember_days").MustInt()
- CookieUserName = security.Key("cookie_username").String()
- CookieRememberName = security.Key("cookie_remember_name").String()
DisableGravatar = security.Key("disable_gravatar").MustBool(true)
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
+ cfg.SecurityHTTPSCookies = security.Key("https_flag_cookies").MustBool(false)
DisableBruteForceLoginProtection = cfg.DisableBruteForceLoginProtection
// read snapshots settings
@@ -705,10 +720,11 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
AlertingNoDataOrNullValues = alerting.Key("nodata_or_nullvalues").MustString("no_data")
explore := iniFile.Section("explore")
- ExploreEnabled = explore.Key("enabled").MustBool(false)
+ ExploreEnabled = explore.Key("enabled").MustBool(true)
panels := iniFile.Section("panels")
cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
+ cfg.DisableSanitizeHtml = panels.Key("disable_sanitize_html").MustBool(false)
cfg.readSessionConfig()
cfg.readSmtpSettings()
diff --git a/pkg/tsdb/cloudwatch/credentials.go b/pkg/tsdb/cloudwatch/credentials.go
index 165f8fdbe97..fb92c827f7c 100644
--- a/pkg/tsdb/cloudwatch/credentials.go
+++ b/pkg/tsdb/cloudwatch/credentials.go
@@ -3,7 +3,6 @@ package cloudwatch
import (
"fmt"
"os"
- "strings"
"sync"
"time"
@@ -43,7 +42,7 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
secretAccessKey := ""
sessionToken := ""
var expiration *time.Time = nil
- if dsInfo.AuthType == "arn" && strings.Index(dsInfo.AssumeRoleArn, "arn:aws:iam:") == 0 {
+ if dsInfo.AuthType == "arn" {
params := &sts.AssumeRoleInput{
RoleArn: aws.String(dsInfo.AssumeRoleArn),
RoleSessionName: aws.String("GrafanaSession"),
diff --git a/pkg/tsdb/elasticsearch/client/models.go b/pkg/tsdb/elasticsearch/client/models.go
index 5307dc34b27..fcc96aeba93 100644
--- a/pkg/tsdb/elasticsearch/client/models.go
+++ b/pkg/tsdb/elasticsearch/client/models.go
@@ -292,7 +292,7 @@ func (a *MetricAggregation) MarshalJSON() ([]byte, error) {
// PipelineAggregation represents a metric aggregation
type PipelineAggregation struct {
- BucketPath string
+ BucketPath interface{}
Settings map[string]interface{}
}
diff --git a/pkg/tsdb/elasticsearch/client/search_request.go b/pkg/tsdb/elasticsearch/client/search_request.go
index d89a98cbadb..b8c9232f97b 100644
--- a/pkg/tsdb/elasticsearch/client/search_request.go
+++ b/pkg/tsdb/elasticsearch/client/search_request.go
@@ -268,7 +268,7 @@ type AggBuilder interface {
Filters(key string, fn func(a *FiltersAggregation, b AggBuilder)) AggBuilder
GeoHashGrid(key, field string, fn func(a *GeoHashGridAggregation, b AggBuilder)) AggBuilder
Metric(key, metricType, field string, fn func(a *MetricAggregation)) AggBuilder
- Pipeline(key, pipelineType, bucketPath string, fn func(a *PipelineAggregation)) AggBuilder
+ Pipeline(key, pipelineType string, bucketPath interface{}, fn func(a *PipelineAggregation)) AggBuilder
Build() (AggArray, error)
}
@@ -438,7 +438,7 @@ func (b *aggBuilderImpl) Metric(key, metricType, field string, fn func(a *Metric
return b
}
-func (b *aggBuilderImpl) Pipeline(key, pipelineType, bucketPath string, fn func(a *PipelineAggregation)) AggBuilder {
+func (b *aggBuilderImpl) Pipeline(key, pipelineType string, bucketPath interface{}, fn func(a *PipelineAggregation)) AggBuilder {
innerAgg := &PipelineAggregation{
BucketPath: bucketPath,
Settings: make(map[string]interface{}),
diff --git a/pkg/tsdb/elasticsearch/models.go b/pkg/tsdb/elasticsearch/models.go
index 46af5e4b745..d38e21c3ebc 100644
--- a/pkg/tsdb/elasticsearch/models.go
+++ b/pkg/tsdb/elasticsearch/models.go
@@ -25,13 +25,14 @@ type BucketAgg struct {
// MetricAgg represents a metric aggregation of the time series query model of the datasource
type MetricAgg struct {
- Field string `json:"field"`
- Hide bool `json:"hide"`
- ID string `json:"id"`
- PipelineAggregate string `json:"pipelineAgg"`
- Settings *simplejson.Json `json:"settings"`
- Meta *simplejson.Json `json:"meta"`
- Type string `json:"type"`
+ Field string `json:"field"`
+ Hide bool `json:"hide"`
+ ID string `json:"id"`
+ PipelineAggregate string `json:"pipelineAgg"`
+ PipelineVariables map[string]string `json:"pipelineVariables"`
+ Settings *simplejson.Json `json:"settings"`
+ Meta *simplejson.Json `json:"meta"`
+ Type string `json:"type"`
}
var metricAggType = map[string]string{
@@ -45,6 +46,7 @@ var metricAggType = map[string]string{
"cardinality": "Unique Count",
"moving_avg": "Moving Average",
"derivative": "Derivative",
+ "bucket_script": "Bucket Script",
"raw_document": "Raw Document",
}
@@ -60,8 +62,13 @@ var extendedStats = map[string]string{
}
var pipelineAggType = map[string]string{
- "moving_avg": "moving_avg",
- "derivative": "derivative",
+ "moving_avg": "moving_avg",
+ "derivative": "derivative",
+ "bucket_script": "bucket_script",
+}
+
+var pipelineAggWithMultipleBucketPathsType = map[string]string{
+ "bucket_script": "bucket_script",
}
func isPipelineAgg(metricType string) bool {
@@ -71,6 +78,13 @@ func isPipelineAgg(metricType string) bool {
return false
}
+func isPipelineAggWithMultipleBucketPaths(metricType string) bool {
+ if _, ok := pipelineAggWithMultipleBucketPathsType[metricType]; ok {
+ return true
+ }
+ return false
+}
+
func describeMetric(metricType, field string) string {
text := metricAggType[metricType]
if metricType == countType {
diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go
index b2c724a9b93..6bbaa3df34b 100644
--- a/pkg/tsdb/elasticsearch/response_parser.go
+++ b/pkg/tsdb/elasticsearch/response_parser.go
@@ -260,6 +260,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query,
newSeries.Tags["metric"] = metric.Type
newSeries.Tags["field"] = metric.Field
+ newSeries.Tags["metricId"] = metric.ID
for _, v := range esAgg.Get("buckets").MustArray() {
bucket := simplejson.NewFromAny(v)
key := castToNullFloat(bucket.Get("key"))
@@ -459,20 +460,42 @@ func (rp *responseParser) getSeriesName(series *tsdb.TimeSeries, target *Query,
}
// todo, if field and pipelineAgg
if field != "" && isPipelineAgg(metricType) {
- found := false
- for _, metric := range target.Metrics {
- if metric.ID == field {
- metricName += " " + describeMetric(metric.Type, field)
- found = true
+ if isPipelineAggWithMultipleBucketPaths(metricType) {
+ metricID := ""
+ if v, ok := series.Tags["metricId"]; ok {
+ metricID = v
+ }
+
+ for _, metric := range target.Metrics {
+ if metric.ID == metricID {
+ metricName = metric.Settings.Get("script").MustString()
+ for name, pipelineAgg := range metric.PipelineVariables {
+ for _, m := range target.Metrics {
+ if m.ID == pipelineAgg {
+ metricName = strings.Replace(metricName, "params."+name, describeMetric(m.Type, m.Field), -1)
+ }
+ }
+ }
+ }
+ }
+ } else {
+ found := false
+ for _, metric := range target.Metrics {
+ if metric.ID == field {
+ metricName += " " + describeMetric(metric.Type, field)
+ found = true
+ }
+ }
+ if !found {
+ metricName = "Unset"
}
- }
- if !found {
- metricName = "Unset"
}
} else if field != "" {
metricName += " " + field
}
+ delete(series.Tags, "metricId")
+
if len(series.Tags) == 0 {
return metricName
}
diff --git a/pkg/tsdb/elasticsearch/response_parser_test.go b/pkg/tsdb/elasticsearch/response_parser_test.go
index b00c14cf946..e9cd8ad0980 100644
--- a/pkg/tsdb/elasticsearch/response_parser_test.go
+++ b/pkg/tsdb/elasticsearch/response_parser_test.go
@@ -787,6 +787,84 @@ func TestResponseParser(t *testing.T) {
So(rows[0][2].(null.Float).Float64, ShouldEqual, 3000)
})
+ Convey("With bucket_script", func() {
+ targets := map[string]string{
+ "A": `{
+ "timeField": "@timestamp",
+ "metrics": [
+ { "id": "1", "type": "sum", "field": "@value" },
+ { "id": "3", "type": "max", "field": "@value" },
+ {
+ "id": "4",
+ "field": "select field",
+ "pipelineVariables": [{ "name": "var1", "pipelineAgg": "1" }, { "name": "var2", "pipelineAgg": "3" }],
+ "settings": { "script": "params.var1 * params.var2" },
+ "type": "bucket_script"
+ }
+ ],
+ "bucketAggs": [{ "type": "date_histogram", "field": "@timestamp", "id": "2" }]
+ }`,
+ }
+ response := `{
+ "responses": [
+ {
+ "aggregations": {
+ "2": {
+ "buckets": [
+ {
+ "1": { "value": 2 },
+ "3": { "value": 3 },
+ "4": { "value": 6 },
+ "doc_count": 60,
+ "key": 1000
+ },
+ {
+ "1": { "value": 3 },
+ "3": { "value": 4 },
+ "4": { "value": 12 },
+ "doc_count": 60,
+ "key": 2000
+ }
+ ]
+ }
+ }
+ }
+ ]
+ }`
+ rp, err := newResponseParserForTest(targets, response)
+ So(err, ShouldBeNil)
+ result, err := rp.getTimeSeries()
+ So(err, ShouldBeNil)
+ So(result.Results, ShouldHaveLength, 1)
+
+ queryRes := result.Results["A"]
+ So(queryRes, ShouldNotBeNil)
+ So(queryRes.Series, ShouldHaveLength, 3)
+ seriesOne := queryRes.Series[0]
+ So(seriesOne.Name, ShouldEqual, "Sum @value")
+ So(seriesOne.Points, ShouldHaveLength, 2)
+ So(seriesOne.Points[0][0].Float64, ShouldEqual, 2)
+ So(seriesOne.Points[0][1].Float64, ShouldEqual, 1000)
+ So(seriesOne.Points[1][0].Float64, ShouldEqual, 3)
+ So(seriesOne.Points[1][1].Float64, ShouldEqual, 2000)
+
+ seriesTwo := queryRes.Series[1]
+ So(seriesTwo.Name, ShouldEqual, "Max @value")
+ So(seriesTwo.Points, ShouldHaveLength, 2)
+ So(seriesTwo.Points[0][0].Float64, ShouldEqual, 3)
+ So(seriesTwo.Points[0][1].Float64, ShouldEqual, 1000)
+ So(seriesTwo.Points[1][0].Float64, ShouldEqual, 4)
+ So(seriesTwo.Points[1][1].Float64, ShouldEqual, 2000)
+
+ seriesThree := queryRes.Series[2]
+ So(seriesThree.Name, ShouldEqual, "Sum @value * Max @value")
+ So(seriesThree.Points, ShouldHaveLength, 2)
+ So(seriesThree.Points[0][0].Float64, ShouldEqual, 6)
+ So(seriesThree.Points[0][1].Float64, ShouldEqual, 1000)
+ So(seriesThree.Points[1][0].Float64, ShouldEqual, 12)
+ So(seriesThree.Points[1][1].Float64, ShouldEqual, 2000)
+ })
+
// Convey("Raw documents query", func() {
// targets := map[string]string{
// "A": `{
diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go
index 28a930df3a2..e1cd466275e 100644
--- a/pkg/tsdb/elasticsearch/time_series_query.go
+++ b/pkg/tsdb/elasticsearch/time_series_query.go
@@ -94,26 +94,56 @@ func (e *timeSeriesQuery) execute() (*tsdb.Response, error) {
}
if isPipelineAgg(m.Type) {
- if _, err := strconv.Atoi(m.PipelineAggregate); err == nil {
- var appliedAgg *MetricAgg
- for _, pipelineMetric := range q.Metrics {
- if pipelineMetric.ID == m.PipelineAggregate {
- appliedAgg = pipelineMetric
- break
- }
- }
- if appliedAgg != nil {
- bucketPath := m.PipelineAggregate
- if appliedAgg.Type == countType {
- bucketPath = "_count"
+ if isPipelineAggWithMultipleBucketPaths(m.Type) {
+ if len(m.PipelineVariables) > 0 {
+ bucketPaths := map[string]interface{}{}
+ for name, pipelineAgg := range m.PipelineVariables {
+ if _, err := strconv.Atoi(pipelineAgg); err == nil {
+ var appliedAgg *MetricAgg
+ for _, pipelineMetric := range q.Metrics {
+ if pipelineMetric.ID == pipelineAgg {
+ appliedAgg = pipelineMetric
+ break
+ }
+ }
+ if appliedAgg != nil {
+ if appliedAgg.Type == countType {
+ bucketPaths[name] = "_count"
+ } else {
+ bucketPaths[name] = pipelineAgg
+ }
+ }
+ }
}
- aggBuilder.Pipeline(m.ID, m.Type, bucketPath, func(a *es.PipelineAggregation) {
+ aggBuilder.Pipeline(m.ID, m.Type, bucketPaths, func(a *es.PipelineAggregation) {
a.Settings = m.Settings.MustMap()
})
+ } else {
+ continue
}
} else {
- continue
+ if _, err := strconv.Atoi(m.PipelineAggregate); err == nil {
+ var appliedAgg *MetricAgg
+ for _, pipelineMetric := range q.Metrics {
+ if pipelineMetric.ID == m.PipelineAggregate {
+ appliedAgg = pipelineMetric
+ break
+ }
+ }
+ if appliedAgg != nil {
+ bucketPath := m.PipelineAggregate
+ if appliedAgg.Type == countType {
+ bucketPath = "_count"
+ }
+
+ aggBuilder.Pipeline(m.ID, m.Type, bucketPath, func(a *es.PipelineAggregation) {
+ a.Settings = m.Settings.MustMap()
+ })
+ }
+ } else {
+ continue
+ }
}
} else {
aggBuilder.Metric(m.ID, m.Type, m.Field, func(a *es.MetricAggregation) {
@@ -328,12 +358,20 @@ func (p *timeSeriesQueryParser) parseMetrics(model *simplejson.Json) ([]*MetricA
metric.PipelineAggregate = metricJSON.Get("pipelineAgg").MustString()
metric.Settings = simplejson.NewFromAny(metricJSON.Get("settings").MustMap())
metric.Meta = simplejson.NewFromAny(metricJSON.Get("meta").MustMap())
-
metric.Type, err = metricJSON.Get("type").String()
if err != nil {
return nil, err
}
+ if isPipelineAggWithMultipleBucketPaths(metric.Type) {
+ metric.PipelineVariables = map[string]string{}
+ pvArr := metricJSON.Get("pipelineVariables").MustArray()
+ for _, v := range pvArr {
+ kv := v.(map[string]interface{})
+ metric.PipelineVariables[kv["name"].(string)] = kv["pipelineAgg"].(string)
+ }
+ }
+
result = append(result, metric)
}
return result, nil
diff --git a/pkg/tsdb/elasticsearch/time_series_query_test.go b/pkg/tsdb/elasticsearch/time_series_query_test.go
index a4f305242fa..3a558c32782 100644
--- a/pkg/tsdb/elasticsearch/time_series_query_test.go
+++ b/pkg/tsdb/elasticsearch/time_series_query_test.go
@@ -543,6 +543,77 @@ func TestExecuteTimeSeriesQuery(t *testing.T) {
plAgg := derivativeAgg.Aggregation.Aggregation.(*es.PipelineAggregation)
So(plAgg.BucketPath, ShouldEqual, "_count")
})
+
+ Convey("With bucket_script", func() {
+ c := newFakeClient(5)
+ _, err := executeTsdbQuery(c, `{
+ "timeField": "@timestamp",
+ "bucketAggs": [
+ { "type": "date_histogram", "field": "@timestamp", "id": "4" }
+ ],
+ "metrics": [
+ { "id": "3", "type": "sum", "field": "@value" },
+ { "id": "5", "type": "max", "field": "@value" },
+ {
+ "id": "2",
+ "type": "bucket_script",
+ "pipelineVariables": [
+ { "name": "var1", "pipelineAgg": "3" },
+ { "name": "var2", "pipelineAgg": "5" }
+ ],
+ "settings": { "script": "params.var1 * params.var2" }
+ }
+ ]
+ }`, from, to, 15*time.Second)
+ So(err, ShouldBeNil)
+ sr := c.multisearchRequests[0].Requests[0]
+
+ firstLevel := sr.Aggs[0]
+ So(firstLevel.Key, ShouldEqual, "4")
+ So(firstLevel.Aggregation.Type, ShouldEqual, "date_histogram")
+
+ bucketScriptAgg := firstLevel.Aggregation.Aggs[2]
+ So(bucketScriptAgg.Key, ShouldEqual, "2")
+ plAgg := bucketScriptAgg.Aggregation.Aggregation.(*es.PipelineAggregation)
+ So(plAgg.BucketPath.(map[string]interface{}), ShouldResemble, map[string]interface{}{
+ "var1": "3",
+ "var2": "5",
+ })
+ })
+
+ Convey("With bucket_script doc count", func() {
+ c := newFakeClient(5)
+ _, err := executeTsdbQuery(c, `{
+ "timeField": "@timestamp",
+ "bucketAggs": [
+ { "type": "date_histogram", "field": "@timestamp", "id": "4" }
+ ],
+ "metrics": [
+ { "id": "3", "type": "count", "field": "select field" },
+ {
+ "id": "2",
+ "type": "bucket_script",
+ "pipelineVariables": [
+ { "name": "var1", "pipelineAgg": "3" }
+ ],
+ "settings": { "script": "params.var1 * 1000" }
+ }
+ ]
+ }`, from, to, 15*time.Second)
+ So(err, ShouldBeNil)
+ sr := c.multisearchRequests[0].Requests[0]
+
+ firstLevel := sr.Aggs[0]
+ So(firstLevel.Key, ShouldEqual, "4")
+ So(firstLevel.Aggregation.Type, ShouldEqual, "date_histogram")
+
+ bucketScriptAgg := firstLevel.Aggregation.Aggs[0]
+ So(bucketScriptAgg.Key, ShouldEqual, "2")
+ plAgg := bucketScriptAgg.Aggregation.Aggregation.(*es.PipelineAggregation)
+ So(plAgg.BucketPath.(map[string]interface{}), ShouldResemble, map[string]interface{}{
+ "var1": "_count",
+ })
+ })
})
}
diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go
index dac18f3ac03..ec3f103a4ef 100644
--- a/pkg/tsdb/mssql/macros.go
+++ b/pkg/tsdb/mssql/macros.go
@@ -96,6 +96,15 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
+ case "__unixEpochNanoFilter":
+ if len(args) == 0 {
+ return "", fmt.Errorf("missing time column argument for macro %v", name)
+ }
+ return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsTimeUTC().UnixNano(), args[0], m.timeRange.GetToAsTimeUTC().UnixNano()), nil
+ case "__unixEpochNanoFrom":
+ return fmt.Sprintf("%d", m.timeRange.GetFromAsTimeUTC().UnixNano()), nil
+ case "__unixEpochNanoTo":
+ return fmt.Sprintf("%d", m.timeRange.GetToAsTimeUTC().UnixNano()), nil
case "__unixEpochGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
diff --git a/pkg/tsdb/mssql/macros_test.go b/pkg/tsdb/mssql/macros_test.go
index 43cbe9fefda..829a8b1ff1a 100644
--- a/pkg/tsdb/mssql/macros_test.go
+++ b/pkg/tsdb/mssql/macros_test.go
@@ -132,6 +132,26 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
})
+ Convey("interpolate __unixEpochNanoFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()))
+ })
+ Convey("interpolate __unixEpochNanoFrom function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano()))
+ })
+
+ Convey("interpolate __unixEpochNanoTo function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano()))
+ })
+
Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@@ -163,6 +183,13 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
})
+
+ Convey("interpolate __unixEpochNanoFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()))
+ })
})
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -183,6 +210,13 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
})
+
+ Convey("interpolate __unixEpochNanoFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time_column)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.UnixNano(), to.UnixNano()))
+ })
})
})
}
diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go
index 469d6baa5de..bd4510f6cf3 100644
--- a/pkg/tsdb/mssql/mssql.go
+++ b/pkg/tsdb/mssql/mssql.go
@@ -4,13 +4,13 @@ import (
"database/sql"
"fmt"
"strconv"
- "strings"
_ "github.com/denisenkom/go-mssqldb"
"github.com/go-xorm/core"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb"
+ "github.com/grafana/grafana/pkg/util"
)
func init() {
@@ -20,7 +20,10 @@ func init() {
func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
logger := log.New("tsdb.mssql")
- cnnstr := generateConnectionString(datasource)
+ cnnstr, err := generateConnectionString(datasource)
+ if err != nil {
+ return nil, err
+ }
logger.Debug("getEngine", "connection", cnnstr)
config := tsdb.SqlQueryEndpointConfiguration{
@@ -37,7 +40,7 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
return tsdb.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger)
}
-func generateConnectionString(datasource *models.DataSource) string {
+func generateConnectionString(datasource *models.DataSource) (string, error) {
password := ""
for key, value := range datasource.SecureJsonData.Decrypt() {
if key == "password" {
@@ -46,12 +49,11 @@ func generateConnectionString(datasource *models.DataSource) string {
}
}
- hostParts := strings.Split(datasource.Url, ":")
- if len(hostParts) < 2 {
- hostParts = append(hostParts, "1433")
+ server, port, err := util.SplitIPPort(datasource.Url, "1433")
+ if err != nil {
+ return "", err
}
- server, port := hostParts[0], hostParts[1]
encrypt := datasource.JsonData.Get("encrypt").MustString("false")
connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
server,
@@ -63,7 +65,7 @@ func generateConnectionString(datasource *models.DataSource) string {
if encrypt != "false" {
connStr += fmt.Sprintf("encrypt=%s;", encrypt)
}
- return connStr
+ return connStr, nil
}
type mssqlRowTransformer struct {
diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go
index c0ed64aa65c..bbd928b0563 100644
--- a/pkg/tsdb/mysql/macros.go
+++ b/pkg/tsdb/mysql/macros.go
@@ -91,6 +91,15 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
+ case "__unixEpochNanoFilter":
+ if len(args) == 0 {
+ return "", fmt.Errorf("missing time column argument for macro %v", name)
+ }
+ return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsTimeUTC().UnixNano(), args[0], m.timeRange.GetToAsTimeUTC().UnixNano()), nil
+ case "__unixEpochNanoFrom":
+ return fmt.Sprintf("%d", m.timeRange.GetFromAsTimeUTC().UnixNano()), nil
+ case "__unixEpochNanoTo":
+ return fmt.Sprintf("%d", m.timeRange.GetToAsTimeUTC().UnixNano()), nil
case "__unixEpochGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go
index 49633347a4c..7d129e845b1 100644
--- a/pkg/tsdb/mysql/macros_test.go
+++ b/pkg/tsdb/mysql/macros_test.go
@@ -84,6 +84,27 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
+ Convey("interpolate __unixEpochNanoFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
+ })
+
+ Convey("interpolate __unixEpochNanoFrom function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano()))
+ })
+
+ Convey("interpolate __unixEpochNanoTo function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano()))
+ })
+
Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go
index 26a3d1e53ee..fbba582c262 100644
--- a/pkg/tsdb/postgres/macros.go
+++ b/pkg/tsdb/postgres/macros.go
@@ -86,11 +86,11 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
- return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
+ return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339Nano), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339Nano)), nil
case "__timeFrom":
- return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
+ return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339Nano)), nil
case "__timeTo":
- return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
+ return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339Nano)), nil
case "__timeGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
@@ -122,6 +122,15 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
+ case "__unixEpochNanoFilter":
+ if len(args) == 0 {
+ return "", fmt.Errorf("missing time column argument for macro %v", name)
+ }
+ return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsTimeUTC().UnixNano(), args[0], m.timeRange.GetToAsTimeUTC().UnixNano()), nil
+ case "__unixEpochNanoFrom":
+ return fmt.Sprintf("%d", m.timeRange.GetFromAsTimeUTC().UnixNano()), nil
+ case "__unixEpochNanoTo":
+ return fmt.Sprintf("%d", m.timeRange.GetToAsTimeUTC().UnixNano()), nil
case "__unixEpochGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go
index 6a71caedeb9..eb14656c546 100644
--- a/pkg/tsdb/postgres/macros_test.go
+++ b/pkg/tsdb/postgres/macros_test.go
@@ -41,7 +41,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
- So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
+ So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
})
Convey("interpolate __timeFrom function", func() {
@@ -115,6 +115,25 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
+ Convey("interpolate __unixEpochNanoFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
+ })
+ Convey("interpolate __unixEpochNanoFrom function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFrom()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", from.UnixNano()))
+ })
+
+ Convey("interpolate __unixEpochNanoTo function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoTo()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", to.UnixNano()))
+ })
Convey("interpolate __unixEpochGroup function", func() {
@@ -138,7 +157,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
- So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
+ So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
})
Convey("interpolate __unixEpochFilter function", func() {
@@ -147,6 +166,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
+ Convey("interpolate __unixEpochNanoFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
+ })
})
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -158,7 +183,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
- So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
+ So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
})
Convey("interpolate __unixEpochFilter function", func() {
@@ -167,6 +192,29 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
+ Convey("interpolate __unixEpochNanoFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochNanoFilter(time)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.UnixNano(), to.UnixNano()))
+ })
})
+
+ Convey("Given a time range between 1960-02-01 07:00:00.5 and 1980-02-03 08:00:00.5", func() {
+ from := time.Date(1960, 2, 1, 7, 0, 0, 500e6, time.UTC)
+ to := time.Date(1980, 2, 3, 8, 0, 0, 500e6, time.UTC)
+ timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10))
+
+ So(from.Format(time.RFC3339Nano), ShouldEqual, "1960-02-01T07:00:00.5Z")
+ So(to.Format(time.RFC3339Nano), ShouldEqual, "1980-02-03T08:00:00.5Z")
+ Convey("interpolate __timeFilter function", func() {
+ sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
+ })
+
+ })
+
})
}
diff --git a/pkg/tsdb/stackdriver/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go
index 2ad47cc4b83..8317e2a748a 100644
--- a/pkg/tsdb/stackdriver/stackdriver.go
+++ b/pkg/tsdb/stackdriver/stackdriver.go
@@ -233,12 +233,12 @@ func buildFilterString(metricType string, filterParts []interface{}) string {
}
func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) {
- primaryAggregation := query.Model.Get("primaryAggregation").MustString()
+ crossSeriesReducer := query.Model.Get("crossSeriesReducer").MustString()
perSeriesAligner := query.Model.Get("perSeriesAligner").MustString()
alignmentPeriod := query.Model.Get("alignmentPeriod").MustString()
- if primaryAggregation == "" {
- primaryAggregation = "REDUCE_NONE"
+ if crossSeriesReducer == "" {
+ crossSeriesReducer = "REDUCE_NONE"
}
if perSeriesAligner == "" {
@@ -267,7 +267,7 @@ func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) {
alignmentPeriod = "+3600s"
}
- params.Add("aggregation.crossSeriesReducer", primaryAggregation)
+ params.Add("aggregation.crossSeriesReducer", crossSeriesReducer)
params.Add("aggregation.perSeriesAligner", perSeriesAligner)
params.Add("aggregation.alignmentPeriod", alignmentPeriod)
diff --git a/pkg/tsdb/stackdriver/stackdriver_test.go b/pkg/tsdb/stackdriver/stackdriver_test.go
index 784bf4a7fbb..4b16b6b5294 100644
--- a/pkg/tsdb/stackdriver/stackdriver_test.go
+++ b/pkg/tsdb/stackdriver/stackdriver_test.go
@@ -173,7 +173,7 @@ func TestStackdriver(t *testing.T) {
Convey("and query has aggregation mean set", func() {
tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
"metricType": "a/metric/type",
- "primaryAggregation": "REDUCE_MEAN",
+ "crossSeriesReducer": "REDUCE_SUM",
"view": "FULL",
})
@@ -182,11 +182,11 @@ func TestStackdriver(t *testing.T) {
So(len(queries), ShouldEqual, 1)
So(queries[0].RefID, ShouldEqual, "A")
- So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_MEAN&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
+ So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_SUM&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
So(len(queries[0].Params), ShouldEqual, 7)
So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
- So(queries[0].Params["aggregation.crossSeriesReducer"][0], ShouldEqual, "REDUCE_MEAN")
+ So(queries[0].Params["aggregation.crossSeriesReducer"][0], ShouldEqual, "REDUCE_SUM")
So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, "+60s")
So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
@@ -196,7 +196,7 @@ func TestStackdriver(t *testing.T) {
Convey("and query has group bys", func() {
tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
"metricType": "a/metric/type",
- "primaryAggregation": "REDUCE_NONE",
+ "crossSeriesReducer": "REDUCE_NONE",
"groupBys": []interface{}{"metric.label.group1", "metric.label.group2"},
"view": "FULL",
})
diff --git a/pkg/util/encoding.go b/pkg/util/encoding.go
index 0edb721e422..c8c3d72e6d7 100644
--- a/pkg/util/encoding.go
+++ b/pkg/util/encoding.go
@@ -12,6 +12,7 @@ import (
"strings"
)
+// GetRandomString generate random string by specify chars.
// source: https://github.com/gogits/gogs/blob/9ee80e3e5426821f03a4e99fad34418f5c736413/modules/base/tool.go#L58
func GetRandomString(n int, alphabets ...byte) string {
const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -27,18 +28,21 @@ func GetRandomString(n int, alphabets ...byte) string {
return string(bytes)
}
+// EncodePassword encodes a password using PBKDF2.
func EncodePassword(password string, salt string) string {
newPasswd := PBKDF2([]byte(password), []byte(salt), 10000, 50, sha256.New)
return hex.EncodeToString(newPasswd)
}
-// Encode string to md5 hex value.
+// EncodeMd5 encodes a string to md5 hex value.
func EncodeMd5(str string) string {
m := md5.New()
m.Write([]byte(str))
return hex.EncodeToString(m.Sum(nil))
}
+// PBKDF2 implements Password-Based Key Derivation Function 2), aimed to reduce
+// the vulnerability of encrypted keys to brute force attacks.
// http://code.google.com/p/go/source/browse/pbkdf2/pbkdf2.go?repo=crypto
func PBKDF2(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte {
prf := hmac.New(h, password)
@@ -77,11 +81,13 @@ func PBKDF2(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte
return dk[:keyLen]
}
+// GetBasicAuthHeader returns a base64 encoded string from user and password.
func GetBasicAuthHeader(user string, password string) string {
var userAndPass = user + ":" + password
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userAndPass))
}
+// DecodeBasicAuthHeader decodes user and password from a basic auth header.
func DecodeBasicAuthHeader(header string) (string, string, error) {
var code string
parts := strings.SplitN(header, " ", 2)
@@ -101,3 +107,12 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
return userAndPass[0], userAndPass[1], nil
}
+
+// RandomHex returns a random string from a n seed.
+func RandomHex(n int) (string, error) {
+ bytes := make([]byte, n)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
diff --git a/pkg/util/encryption.go b/pkg/util/encryption.go
index d41665bafd6..6580e049206 100644
--- a/pkg/util/encryption.go
+++ b/pkg/util/encryption.go
@@ -11,6 +11,7 @@ import (
const saltLength = 8
+// Decrypt decrypts a payload with a given secret.
func Decrypt(payload []byte, secret string) ([]byte, error) {
salt := payload[:saltLength]
key := encryptionKeyToBytes(secret, string(salt))
@@ -36,6 +37,7 @@ func Decrypt(payload []byte, secret string) ([]byte, error) {
return payloadDst, nil
}
+// Encrypt encrypts a payload with a given secret.
func Encrypt(payload []byte, secret string) ([]byte, error) {
salt := GetRandomString(saltLength)
diff --git a/pkg/util/filepath.go b/pkg/util/filepath.go
index d304236fcb1..368c6fa40d5 100644
--- a/pkg/util/filepath.go
+++ b/pkg/util/filepath.go
@@ -8,8 +8,8 @@ import (
"path/filepath"
)
-//WalkSkipDir is the Error returned when we want to skip descending into a directory
-var WalkSkipDir = errors.New("skip this directory")
+//ErrWalkSkipDir is the Error returned when we want to skip descending into a directory
+var ErrWalkSkipDir = errors.New("skip this directory")
//WalkFunc is a callback function called for each path as a directory is walked
//If resolvedPath != "", then we are following symbolic links.
@@ -50,7 +50,7 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow
}
err := walkFn(resolvedPath, info, nil)
if err != nil {
- if info.IsDir() && err == WalkSkipDir {
+ if info.IsDir() && err == ErrWalkSkipDir {
err = nil
}
return err
diff --git a/pkg/util/ip.go b/pkg/util/ip.go
new file mode 100644
index 00000000000..d3809318191
--- /dev/null
+++ b/pkg/util/ip.go
@@ -0,0 +1,25 @@
+package util
+
+import (
+ "net"
+)
+
+// SplitIPPort splits the ip string and port.
+func SplitIPPort(ipStr string, portDefault string) (ip string, port string, err error) {
+ ipAddr := net.ParseIP(ipStr)
+
+ if ipAddr == nil {
+ // Port was included
+ ip, port, err = net.SplitHostPort(ipStr)
+
+ if err != nil {
+ return "", "", err
+ }
+ } else {
+ // No port was included
+ ip = ipAddr.String()
+ port = portDefault
+ }
+
+ return ip, port, nil
+}
diff --git a/pkg/util/ip_address.go b/pkg/util/ip_address.go
new file mode 100644
index 00000000000..d8d95ef3acd
--- /dev/null
+++ b/pkg/util/ip_address.go
@@ -0,0 +1,29 @@
+package util
+
+import (
+ "net"
+ "strings"
+)
+
+// ParseIPAddress parses an IP address and removes port and/or IPV6 format
+func ParseIPAddress(input string) string {
+ s := input
+ lastIndex := strings.LastIndex(input, ":")
+
+ if lastIndex != -1 {
+ if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" {
+ s = input[:lastIndex]
+ }
+ }
+
+ s = strings.Replace(s, "[", "", -1)
+ s = strings.Replace(s, "]", "", -1)
+
+ ip := net.ParseIP(s)
+
+ if ip.IsLoopback() {
+ return "127.0.0.1"
+ }
+
+ return ip.String()
+}
diff --git a/pkg/util/ip_address_test.go b/pkg/util/ip_address_test.go
new file mode 100644
index 00000000000..fd3e3ea8587
--- /dev/null
+++ b/pkg/util/ip_address_test.go
@@ -0,0 +1,16 @@
+package util
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestParseIPAddress(t *testing.T) {
+ Convey("Test parse ip address", t, func() {
+ So(ParseIPAddress("192.168.0.140:456"), ShouldEqual, "192.168.0.140")
+ So(ParseIPAddress("[::1:456]"), ShouldEqual, "127.0.0.1")
+ So(ParseIPAddress("[::1]"), ShouldEqual, "127.0.0.1")
+ So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140")
+ })
+}
diff --git a/pkg/util/ip_test.go b/pkg/util/ip_test.go
new file mode 100644
index 00000000000..3a62a080e26
--- /dev/null
+++ b/pkg/util/ip_test.go
@@ -0,0 +1,43 @@
+package util
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestSplitIPPort(t *testing.T) {
+
+ Convey("When parsing an IPv4 without explicit port", t, func() {
+ ip, port, err := SplitIPPort("1.2.3.4", "5678")
+
+ So(err, ShouldEqual, nil)
+ So(ip, ShouldEqual, "1.2.3.4")
+ So(port, ShouldEqual, "5678")
+ })
+
+ Convey("When parsing an IPv6 without explicit port", t, func() {
+ ip, port, err := SplitIPPort("::1", "5678")
+
+ So(err, ShouldEqual, nil)
+ So(ip, ShouldEqual, "::1")
+ So(port, ShouldEqual, "5678")
+ })
+
+ Convey("When parsing an IPv4 with explicit port", t, func() {
+ ip, port, err := SplitIPPort("1.2.3.4:56", "78")
+
+ So(err, ShouldEqual, nil)
+ So(ip, ShouldEqual, "1.2.3.4")
+ So(port, ShouldEqual, "56")
+ })
+
+ Convey("When parsing an IPv6 with explicit port", t, func() {
+ ip, port, err := SplitIPPort("[::1]:56", "78")
+
+ So(err, ShouldEqual, nil)
+ So(ip, ShouldEqual, "::1")
+ So(port, ShouldEqual, "56")
+ })
+
+}
diff --git a/pkg/util/json.go b/pkg/util/json.go
index a75a8490d93..7a2ec53cc2a 100644
--- a/pkg/util/json.go
+++ b/pkg/util/json.go
@@ -1,3 +1,4 @@
package util
+// DynMap defines a dynamic map interface.
type DynMap map[string]interface{}
diff --git a/pkg/util/md5.go b/pkg/util/md5.go
index 2473a1a406c..99641ca398f 100644
--- a/pkg/util/md5.go
+++ b/pkg/util/md5.go
@@ -19,7 +19,7 @@ func Md5Sum(reader io.Reader) (string, error) {
return returnMD5String, nil
}
-// Md5Sum calculates the md5sum of a string
+// Md5SumString calculates the md5sum of a string
func Md5SumString(input string) (string, error) {
buffer := strings.NewReader(input)
return Md5Sum(buffer)
diff --git a/pkg/util/shortid_generator.go b/pkg/util/shortid_generator.go
index f900cb8275e..c035e088944 100644
--- a/pkg/util/shortid_generator.go
+++ b/pkg/util/shortid_generator.go
@@ -8,19 +8,19 @@ import (
var allowedChars = shortid.DefaultABC
-var validUidPattern = regexp.MustCompile(`^[a-zA-Z0-9\-\_]*$`).MatchString
+var validUIDPattern = regexp.MustCompile(`^[a-zA-Z0-9\-\_]*$`).MatchString
func init() {
gen, _ := shortid.New(1, allowedChars, 1)
shortid.SetDefault(gen)
}
-// IsValidShortUid checks if short unique identifier contains valid characters
-func IsValidShortUid(uid string) bool {
- return validUidPattern(uid)
+// IsValidShortUID checks if short unique identifier contains valid characters
+func IsValidShortUID(uid string) bool {
+ return validUIDPattern(uid)
}
-// GenerateShortUid generates a short unique identifier.
-func GenerateShortUid() string {
+// GenerateShortUID generates a short unique identifier.
+func GenerateShortUID() string {
return shortid.MustGenerate()
}
diff --git a/pkg/util/shortid_generator_test.go b/pkg/util/shortid_generator_test.go
index 359e054a0ca..94055f0bcfd 100644
--- a/pkg/util/shortid_generator_test.go
+++ b/pkg/util/shortid_generator_test.go
@@ -4,7 +4,7 @@ import "testing"
func TestAllowedCharMatchesUidPattern(t *testing.T) {
for _, c := range allowedChars {
- if !IsValidShortUid(string(c)) {
+ if !IsValidShortUID(string(c)) {
t.Fatalf("charset for creating new shortids contains chars not present in uid pattern")
}
}
diff --git a/pkg/util/strings.go b/pkg/util/strings.go
index 854c009d1b1..9eaa141edbf 100644
--- a/pkg/util/strings.go
+++ b/pkg/util/strings.go
@@ -7,10 +7,12 @@ import (
"time"
)
+// StringsFallback2 returns the first of two not empty strings.
func StringsFallback2(val1 string, val2 string) string {
return stringsFallback(val1, val2)
}
+// StringsFallback3 returns the first of three not empty strings.
func StringsFallback3(val1 string, val2 string, val3 string) string {
return stringsFallback(val1, val2, val3)
}
@@ -24,6 +26,7 @@ func stringsFallback(vals ...string) string {
return ""
}
+// SplitString splits a string by commas or empty spaces.
func SplitString(str string) []string {
if len(str) == 0 {
return []string{}
@@ -32,6 +35,7 @@ func SplitString(str string) []string {
return regexp.MustCompile("[, ]+").Split(str, -1)
}
+// GetAgeString returns a string representing certain time from years to minutes.
func GetAgeString(t time.Time) string {
if t.IsZero() {
return "?"
diff --git a/pkg/util/url.go b/pkg/util/url.go
index fad2d79a6d0..790164c046d 100644
--- a/pkg/util/url.go
+++ b/pkg/util/url.go
@@ -5,22 +5,26 @@ import (
"strings"
)
-type UrlQueryReader struct {
+// URLQueryReader is a URL query type.
+type URLQueryReader struct {
values url.Values
}
-func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) {
+// NewURLQueryReader parses a raw query and returns it as a URLQueryReader type.
+func NewURLQueryReader(urlInfo *url.URL) (*URLQueryReader, error) {
u, err := url.ParseQuery(urlInfo.RawQuery)
if err != nil {
return nil, err
}
- return &UrlQueryReader{
+ return &URLQueryReader{
values: u,
}, nil
}
-func (r *UrlQueryReader) Get(name string, def string) string {
+// Get parse parameters from an URL. If the parameter does not exist, it returns
+// the default value.
+func (r *URLQueryReader) Get(name string, def string) string {
val := r.values[name]
if len(val) == 0 {
return def
@@ -29,7 +33,8 @@ func (r *UrlQueryReader) Get(name string, def string) string {
return val[0]
}
-func JoinUrlFragments(a, b string) string {
+// JoinURLFragments joins two URL fragments into only one URL string.
+func JoinURLFragments(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
diff --git a/pkg/util/url_test.go b/pkg/util/url_test.go
index ee29956f60d..ae7660a4a43 100644
--- a/pkg/util/url_test.go
+++ b/pkg/util/url_test.go
@@ -1,60 +1,60 @@
package util
import (
+ "net/url"
"testing"
. "github.com/smartystreets/goconvey/convey"
- "net/url"
)
func TestUrl(t *testing.T) {
Convey("When joining two urls where right hand side is empty", t, func() {
- result := JoinUrlFragments("http://localhost:8080", "")
+ result := JoinURLFragments("http://localhost:8080", "")
So(result, ShouldEqual, "http://localhost:8080")
})
Convey("When joining two urls where right hand side is empty and lefthand side has a trailing slash", t, func() {
- result := JoinUrlFragments("http://localhost:8080/", "")
+ result := JoinURLFragments("http://localhost:8080/", "")
So(result, ShouldEqual, "http://localhost:8080/")
})
Convey("When joining two urls where neither has a trailing slash", t, func() {
- result := JoinUrlFragments("http://localhost:8080", "api")
+ result := JoinURLFragments("http://localhost:8080", "api")
So(result, ShouldEqual, "http://localhost:8080/api")
})
Convey("When joining two urls where lefthand side has a trailing slash", t, func() {
- result := JoinUrlFragments("http://localhost:8080/", "api")
+ result := JoinURLFragments("http://localhost:8080/", "api")
So(result, ShouldEqual, "http://localhost:8080/api")
})
Convey("When joining two urls where righthand side has preceding slash", t, func() {
- result := JoinUrlFragments("http://localhost:8080", "/api")
+ result := JoinURLFragments("http://localhost:8080", "/api")
So(result, ShouldEqual, "http://localhost:8080/api")
})
Convey("When joining two urls where righthand side has trailing slash", t, func() {
- result := JoinUrlFragments("http://localhost:8080", "api/")
+ result := JoinURLFragments("http://localhost:8080", "api/")
So(result, ShouldEqual, "http://localhost:8080/api/")
})
Convey("When joining two urls where lefthand side has a trailing slash and righthand side has preceding slash", t, func() {
- result := JoinUrlFragments("http://localhost:8080/", "/api/")
+ result := JoinURLFragments("http://localhost:8080/", "/api/")
So(result, ShouldEqual, "http://localhost:8080/api/")
})
}
-func TestNewUrlQueryReader(t *testing.T) {
+func TestNewURLQueryReader(t *testing.T) {
u, _ := url.Parse("http://www.abc.com/foo?bar=baz&bar2=baz2")
- uqr, _ := NewUrlQueryReader(u)
+ uqr, _ := NewURLQueryReader(u)
Convey("when trying to retrieve the first query value", t, func() {
result := uqr.Get("bar", "foodef")
diff --git a/pkg/util/validation.go b/pkg/util/validation.go
index dd7404e6de4..88f92fdec21 100644
--- a/pkg/util/validation.go
+++ b/pkg/util/validation.go
@@ -13,6 +13,7 @@ var (
regexEmail = regexp.MustCompile(emailRegexPattern)
)
+// IsEmail checks if a string is a valid email address.
func IsEmail(str string) bool {
return regexEmail.MatchString(strings.ToLower(str))
}
diff --git a/public/app/app.ts b/public/app/app.ts
index 2b94ec0fe33..03f332e357b 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -19,6 +19,7 @@ import angular from 'angular';
import config from 'app/core/config';
import _ from 'lodash';
import moment from 'moment';
+import { addClassIfNoOverlayScrollbar } from 'app/core/utils/scrollbar';
// add move to lodash for backward compatabiltiy
_.move = (array, fromIndex, toIndex) => {
@@ -45,6 +46,7 @@ export class GrafanaApp {
preBootModules: any[];
constructor() {
+ addClassIfNoOverlayScrollbar('no-overlay-scrollbar');
this.preBootModules = [];
this.registerFunctions = {};
this.ngModuleDependencies = [];
diff --git a/public/app/core/actions/location.ts b/public/app/core/actions/location.ts
index 6f7ac67363e..8669788fa16 100644
--- a/public/app/core/actions/location.ts
+++ b/public/app/core/actions/location.ts
@@ -1,13 +1,17 @@
import { LocationUpdate } from 'app/types';
+export enum CoreActionTypes {
+ UpdateLocation = 'UPDATE_LOCATION',
+}
+
export type Action = UpdateLocationAction;
export interface UpdateLocationAction {
- type: 'UPDATE_LOCATION';
+ type: CoreActionTypes.UpdateLocation;
payload: LocationUpdate;
}
export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
- type: 'UPDATE_LOCATION',
+ type: CoreActionTypes.UpdateLocation,
payload: location,
});
diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts
index 5609c058a27..4806275e87d 100644
--- a/public/app/core/angular_wrappers.ts
+++ b/public/app/core/angular_wrappers.ts
@@ -1,11 +1,15 @@
import { react2AngularDirective } from 'app/core/utils/react2angular';
+import { QueryEditor as StackdriverQueryEditor } from 'app/plugins/datasource/stackdriver/components/QueryEditor';
+import { AnnotationQueryEditor as StackdriverAnnotationQueryEditor } from 'app/plugins/datasource/stackdriver/components/AnnotationQueryEditor';
import { PasswordStrength } from './components/PasswordStrength';
import PageHeader from './components/PageHeader/PageHeader';
import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
import { SearchResult } from './components/search/SearchResult';
import { TagFilter } from './components/TagFilter/TagFilter';
import { SideMenu } from './components/sidemenu/SideMenu';
+import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
+import { ColorPicker, SeriesColorPickerPopover } from '@grafana/ui';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -19,4 +23,38 @@ export function registerAngularDirectives() {
['onChange', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }],
]);
+ react2AngularDirective('colorPicker', ColorPicker, [
+ 'color',
+ ['onChange', { watchDepth: 'reference', wrapApply: true }],
+ ]);
+ react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
+ 'color',
+ 'series',
+ 'onColorChange',
+ 'onToggleAxis',
+ ]);
+ react2AngularDirective('metricSelect', MetricSelect, [
+ 'options',
+ 'onChange',
+ 'value',
+ 'isSearchable',
+ 'className',
+ 'placeholder',
+ ['variables', { watchDepth: 'reference' }],
+ ]);
+ react2AngularDirective('stackdriverQueryEditor', StackdriverQueryEditor, [
+ 'target',
+ 'onQueryChange',
+ 'onExecuteQuery',
+ ['events', { watchDepth: 'reference' }],
+ ['datasource', { watchDepth: 'reference' }],
+ ['templateSrv', { watchDepth: 'reference' }],
+ ]);
+ react2AngularDirective('stackdriverAnnotationQueryEditor', StackdriverAnnotationQueryEditor, [
+ 'target',
+ 'onQueryChange',
+ 'onExecuteQuery',
+ ['datasource', { watchDepth: 'reference' }],
+ ['templateSrv', { watchDepth: 'reference' }],
+ ]);
}
diff --git a/public/app/core/components/Animations/FadeIn.tsx b/public/app/core/components/Animations/FadeIn.tsx
index e12f22486f1..ea9a92d5f0f 100644
--- a/public/app/core/components/Animations/FadeIn.tsx
+++ b/public/app/core/components/Animations/FadeIn.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import Transition from 'react-transition-group/Transition';
interface Props {
@@ -8,7 +8,7 @@ interface Props {
unmountOnExit?: boolean;
}
-export const FadeIn: SFC = props => {
+export const FadeIn: FC = props => {
const defaultStyle = {
transition: `opacity ${props.duration}ms linear`,
opacity: 0,
diff --git a/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx b/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx
deleted file mode 100644
index 590c11c0615..00000000000
--- a/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React, { PureComponent } from 'react';
-import Scrollbars from 'react-custom-scrollbars';
-
-interface Props {
- customClassName?: string;
- autoHide?: boolean;
- autoHideTimeout?: number;
- autoHideDuration?: number;
- hideTracksWhenNotNeeded?: boolean;
-}
-
-/**
- * Wraps component into component from `react-custom-scrollbars`
- */
-class CustomScrollbar extends PureComponent {
- static defaultProps: Partial = {
- customClassName: 'custom-scrollbars',
- autoHide: true,
- autoHideTimeout: 200,
- autoHideDuration: 200,
- hideTracksWhenNotNeeded: false,
- };
-
- render() {
- const { customClassName, children, ...scrollProps } = this.props;
-
- return (
-
}
- renderTrackVertical={props =>
}
- renderThumbHorizontal={props =>
}
- renderThumbVertical={props =>
}
- renderView={props =>
}
- {...scrollProps}
- >
- {children}
-
- );
- }
-}
-
-export default CustomScrollbar;
diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
index ae0e39cc26d..d63af72ae4d 100644
--- a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
+++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
@@ -24,12 +24,14 @@ class EmptyListCTA extends Component {
{buttonTitle}
-
+ {proTip && (
+
+ )}
);
}
diff --git a/public/app/core/components/ErrorBoundary/ErrorBoundary.tsx b/public/app/core/components/ErrorBoundary/ErrorBoundary.tsx
new file mode 100644
index 00000000000..188750b0fef
--- /dev/null
+++ b/public/app/core/components/ErrorBoundary/ErrorBoundary.tsx
@@ -0,0 +1,44 @@
+import { Component } from 'react';
+
+interface ErrorInfo {
+ componentStack: string;
+}
+
+interface RenderProps {
+ error: Error;
+ errorInfo: ErrorInfo;
+}
+
+interface Props {
+ children: (r: RenderProps) => JSX.Element;
+}
+
+interface State {
+ error: Error;
+ errorInfo: ErrorInfo;
+}
+
+class ErrorBoundary extends Component
{
+ readonly state: State = {
+ error: null,
+ errorInfo: null,
+ };
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ this.setState({
+ error: error,
+ errorInfo: errorInfo
+ });
+ }
+
+ render() {
+ const { children } = this.props;
+ const { error, errorInfo } = this.state;
+ return children({
+ error,
+ errorInfo,
+ });
+ }
+}
+
+export default ErrorBoundary;
diff --git a/public/app/core/components/Footer/Footer.tsx b/public/app/core/components/Footer/Footer.tsx
new file mode 100644
index 00000000000..101168beb66
--- /dev/null
+++ b/public/app/core/components/Footer/Footer.tsx
@@ -0,0 +1,50 @@
+import React, { FC } from 'react';
+import { Tooltip } from '@grafana/ui';
+
+interface Props {
+ appName: string;
+ buildVersion: string;
+ buildCommit: string;
+ newGrafanaVersionExists: boolean;
+ newGrafanaVersion: string;
+}
+
+export const Footer: FC = React.memo(({appName, buildVersion, buildCommit, newGrafanaVersionExists, newGrafanaVersion}) => {
+ return (
+
+ );
+});
+
+export default Footer;
diff --git a/public/app/core/components/Label/Label.tsx b/public/app/core/components/Label/Label.tsx
deleted file mode 100644
index 362c3c577f7..00000000000
--- a/public/app/core/components/Label/Label.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React, { SFC, ReactNode } from 'react';
-import Tooltip from '../Tooltip/Tooltip';
-
-interface Props {
- tooltip?: string;
- for?: string;
- children: ReactNode;
- width?: number;
- className?: string;
-}
-
-export const Label: SFC = props => {
- return (
-
- {props.children}
- {props.tooltip && (
-
-
-
- )}
-
- );
-};
diff --git a/public/app/core/components/LayoutSelector/LayoutSelector.tsx b/public/app/core/components/LayoutSelector/LayoutSelector.tsx
index d9e00102438..3afa1f931f2 100644
--- a/public/app/core/components/LayoutSelector/LayoutSelector.tsx
+++ b/public/app/core/components/LayoutSelector/LayoutSelector.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
@@ -12,7 +12,7 @@ interface Props {
onLayoutModeChanged: (mode: LayoutMode) => {};
}
-const LayoutSelector: SFC = props => {
+const LayoutSelector: FC = props => {
const { mode, onLayoutModeChanged } = props;
return (
diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx
new file mode 100644
index 00000000000..c4846ecf85d
--- /dev/null
+++ b/public/app/core/components/Page/Page.tsx
@@ -0,0 +1,75 @@
+// Libraries
+import React, { Component } from 'react';
+import config from 'app/core/config';
+import { NavModel } from 'app/types';
+import { getTitleFromNavModel } from 'app/core/selectors/navModel';
+
+// Components
+import PageHeader from '../PageHeader/PageHeader';
+import Footer from '../Footer/Footer';
+import PageContents from './PageContents';
+import { CustomScrollbar } from '@grafana/ui';
+import { isEqual } from 'lodash';
+
+interface Props {
+ children: JSX.Element[] | JSX.Element;
+ navModel: NavModel;
+}
+
+class Page extends Component
{
+ private bodyClass = 'is-react';
+ private body = document.body;
+ static Header = PageHeader;
+ static Contents = PageContents;
+
+ componentDidMount() {
+ this.body.classList.add(this.bodyClass);
+ this.updateTitle();
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (!isEqual(prevProps.navModel, this.props.navModel)) {
+ this.updateTitle();
+ }
+ }
+
+ componentWillUnmount() {
+ this.body.classList.remove(this.bodyClass);
+ }
+
+ updateTitle = () => {
+ const title = this.getPageTitle;
+ document.title = title ? title + ' - Grafana' : 'Grafana';
+ }
+
+ get getPageTitle () {
+ const { navModel } = this.props;
+ if (navModel) {
+ return getTitleFromNavModel(navModel) || undefined;
+ }
+ return undefined;
+ }
+
+ render() {
+ const { navModel } = this.props;
+ const { buildInfo } = config;
+ return (
+
+
+
+
+ {this.props.children}
+
+
+
+
+ );
+ }
+}
+
+export default Page;
diff --git a/public/app/core/components/Page/PageContents.tsx b/public/app/core/components/Page/PageContents.tsx
new file mode 100644
index 00000000000..6970857d383
--- /dev/null
+++ b/public/app/core/components/Page/PageContents.tsx
@@ -0,0 +1,26 @@
+// Libraries
+import React, { Component } from 'react';
+
+// Components
+import PageLoader from '../PageLoader/PageLoader';
+
+interface Props {
+ isLoading?: boolean;
+ children: JSX.Element[] | JSX.Element;
+}
+
+class PageContents extends Component {
+
+ render() {
+ const { isLoading } = this.props;
+
+ return (
+
+ {isLoading &&
}
+ {this.props.children}
+
+ );
+ }
+}
+
+export default PageContents;
diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx
index c176095afa4..623070651d1 100644
--- a/public/app/core/components/PageHeader/PageHeader.tsx
+++ b/public/app/core/components/PageHeader/PageHeader.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { FormEvent } from 'react';
import { NavModel, NavModelItem } from 'app/types';
import classNames from 'classnames';
import appEvents from 'app/core/app_events';
@@ -12,8 +12,8 @@ const SelectNav = ({ main, customCss }: { main: NavModelItem; customCss: string
return navItem.active === true;
});
- const gotoUrl = evt => {
- const element = evt.target;
+ const gotoUrl = (evt: FormEvent) => {
+ const element = evt.target as HTMLSelectElement;
const url = element.options[element.selectedIndex].value;
appEvents.emit('location-change', { href: url });
};
@@ -80,7 +80,7 @@ const Navigation = ({ main }: { main: NavModelItem }) => {
};
export default class PageHeader extends React.Component {
- constructor(props) {
+ constructor(props: Props) {
super(props);
}
diff --git a/public/app/core/components/PageLoader/PageLoader.tsx b/public/app/core/components/PageLoader/PageLoader.tsx
index dcb67dde220..6deeabf9a41 100644
--- a/public/app/core/components/PageLoader/PageLoader.tsx
+++ b/public/app/core/components/PageLoader/PageLoader.tsx
@@ -1,10 +1,10 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
interface Props {
- pageName: string;
+ pageName?: string;
}
-const PageLoader: SFC = ({ pageName }) => {
+const PageLoader: FC = ({ pageName = '' }) => {
const loadingText = `Loading ${pageName}...`;
return (
diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx
index 749bef680bf..30219371257 100644
--- a/public/app/core/components/PermissionList/AddPermission.tsx
+++ b/public/app/core/components/PermissionList/AddPermission.tsx
@@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { UserPicker } from 'app/core/components/Select/UserPicker';
import { TeamPicker, Team } from 'app/core/components/Select/TeamPicker';
-import { Select, SelectOptionItem } from 'app/core/components/Select/Select';
+import { Select, SelectOptionItem } from '@grafana/ui';
import { User } from 'app/types';
import {
dashboardPermissionLevels,
diff --git a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
index d3f9ddbb1fb..ebf3cbad1bc 100644
--- a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
+++ b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
@@ -1,5 +1,5 @@
import React, { Component } from 'react';
-import Select from 'app/core/components/Select/Select';
+import { Select } from '@grafana/ui';
import { dashboardPermissionLevels } from 'app/types/acl';
export interface Props {
diff --git a/public/app/core/components/PermissionList/PermissionListItem.tsx b/public/app/core/components/PermissionList/PermissionListItem.tsx
index e726667cfbb..c33b564154a 100644
--- a/public/app/core/components/PermissionList/PermissionListItem.tsx
+++ b/public/app/core/components/PermissionList/PermissionListItem.tsx
@@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
-import { Select } from 'app/core/components/Select/Select';
+import { Select } from '@grafana/ui';
import { dashboardPermissionLevels, DashboardAcl, PermissionLevel } from 'app/types/acl';
import { FolderInfo } from 'app/types';
diff --git a/public/app/core/components/ScrollBar/ScrollBar.tsx b/public/app/core/components/ScrollBar/ScrollBar.tsx
deleted file mode 100644
index 24d17f67367..00000000000
--- a/public/app/core/components/ScrollBar/ScrollBar.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react';
-import baron from 'baron';
-
-export interface Props {
- children: any;
- className: string;
-}
-
-export default class ScrollBar extends React.Component
{
- private container: any;
- private scrollbar: baron;
-
- constructor(props) {
- super(props);
- }
-
- componentDidMount() {
- this.scrollbar = baron({
- root: this.container.parentElement,
- scroller: this.container,
- bar: '.baron__bar',
- barOnCls: '_scrollbar',
- scrollingCls: '_scrolling',
- track: '.baron__track',
- });
- }
-
- componentDidUpdate() {
- this.scrollbar.update();
- }
-
- componentWillUnmount() {
- this.scrollbar.dispose();
- }
-
- // methods can be invoked by outside
- setScrollTop(top) {
- if (this.container) {
- this.container.scrollTop = top;
- this.scrollbar.update();
-
- return true;
- }
- return false;
- }
-
- setScrollLeft(left) {
- if (this.container) {
- this.container.scrollLeft = left;
- this.scrollbar.update();
-
- return true;
- }
- return false;
- }
-
- update() {
- this.scrollbar.update();
- }
-
- handleRef = ref => {
- this.container = ref;
- };
-
- render() {
- return (
-
-
- {this.props.children}
-
-
-
-
- );
- }
-}
diff --git a/public/app/core/components/Select/DataSourcePicker.tsx b/public/app/core/components/Select/DataSourcePicker.tsx
index 1a9081038c0..d028682b215 100644
--- a/public/app/core/components/Select/DataSourcePicker.tsx
+++ b/public/app/core/components/Select/DataSourcePicker.tsx
@@ -3,10 +3,10 @@ import React, { PureComponent } from 'react';
import _ from 'lodash';
// Components
-import Select from './Select';
+import { Select } from '@grafana/ui';
// Types
-import { DataSourceSelectItem } from 'app/types';
+import { DataSourceSelectItem } from '@grafana/ui/src/types';
export interface Props {
onChange: (ds: DataSourceSelectItem) => void;
diff --git a/public/app/core/components/Select/MetricSelect.tsx b/public/app/core/components/Select/MetricSelect.tsx
new file mode 100644
index 00000000000..c9247198052
--- /dev/null
+++ b/public/app/core/components/Select/MetricSelect.tsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import _ from 'lodash';
+
+import { Select } from '@grafana/ui';
+import { SelectOptionItem } from '@grafana/ui';
+import { Variable } from 'app/types/templates';
+
+export interface Props {
+ onChange: (value: string) => void;
+ options: SelectOptionItem[];
+ isSearchable: boolean;
+ value: string;
+ placeholder?: string;
+ className?: string;
+ variables?: Variable[];
+}
+
+interface State {
+ options: any[];
+}
+
+export class MetricSelect extends React.Component {
+ static defaultProps = {
+ variables: [],
+ options: [],
+ isSearchable: true,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = { options: [] };
+ }
+
+ componentDidMount() {
+ this.setState({ options: this.buildOptions(this.props) });
+ }
+
+ componentWillReceiveProps(nextProps: Props) {
+ if (nextProps.options.length > 0 || nextProps.variables.length) {
+ this.setState({ options: this.buildOptions(nextProps) });
+ }
+ }
+
+ shouldComponentUpdate(nextProps: Props) {
+ const nextOptions = this.buildOptions(nextProps);
+ return nextProps.value !== this.props.value || !_.isEqual(nextOptions, this.state.options);
+ }
+
+ buildOptions({ variables = [], options }) {
+ return variables.length > 0 ? [this.getVariablesGroup(), ...options] : options;
+ }
+
+ getVariablesGroup() {
+ return {
+ label: 'Template Variables',
+ options: this.props.variables.map(v => ({
+ label: `$${v.name}`,
+ value: `$${v.name}`,
+ })),
+ };
+ }
+
+ getSelectedOption() {
+ const { options } = this.state;
+ const allOptions = options.every(o => o.options) ? _.flatten(options.map(o => o.options)) : options;
+ return allOptions.find(option => option.value === this.props.value);
+ }
+
+ render() {
+ const { placeholder, className, isSearchable, onChange } = this.props;
+ const { options } = this.state;
+ const selectedOption = this.getSelectedOption();
+
+ return (
+ onChange(item.value)}
+ options={options}
+ isSearchable={isSearchable}
+ maxMenuHeight={500}
+ placeholder={placeholder}
+ noOptionsMessage={() => 'No options found'}
+ value={selectedOption}
+ />
+ );
+ }
+}
diff --git a/public/app/core/components/Select/ResetStyles.tsx b/public/app/core/components/Select/ResetStyles.tsx
deleted file mode 100644
index c34abb544ab..00000000000
--- a/public/app/core/components/Select/ResetStyles.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-export default {
- clearIndicator: () => ({}),
- container: () => ({}),
- control: () => ({}),
- dropdownIndicator: () => ({}),
- group: () => ({}),
- groupHeading: () => ({}),
- indicatorsContainer: () => ({}),
- indicatorSeparator: () => ({}),
- input: () => ({}),
- loadingIndicator: () => ({}),
- loadingMessage: () => ({}),
- menu: () => ({}),
- menuList: ({ maxHeight }: { maxHeight: number }) => ({
- maxHeight,
- }),
- multiValue: () => ({}),
- multiValueLabel: () => ({}),
- multiValueRemove: () => ({}),
- noOptionsMessage: () => ({}),
- option: () => ({}),
- placeholder: () => ({}),
- singleValue: () => ({}),
- valueContainer: () => ({}),
-};
diff --git a/public/app/core/components/Select/TeamPicker.tsx b/public/app/core/components/Select/TeamPicker.tsx
index bc608318806..8d9e1d48d81 100644
--- a/public/app/core/components/Select/TeamPicker.tsx
+++ b/public/app/core/components/Select/TeamPicker.tsx
@@ -1,6 +1,6 @@
import React, { Component } from 'react';
import _ from 'lodash';
-import { AsyncSelect } from './Select';
+import { AsyncSelect } from '@grafana/ui';
import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
diff --git a/public/app/core/components/Select/UnitPicker.tsx b/public/app/core/components/Select/UnitPicker.tsx
index 75885cbbb84..f9dbc0ae421 100644
--- a/public/app/core/components/Select/UnitPicker.tsx
+++ b/public/app/core/components/Select/UnitPicker.tsx
@@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
-import Select from './Select';
-import kbn from 'app/core/utils/kbn';
+import { getValueFormats } from '@grafana/ui';
+import { Select } from '@grafana/ui';
interface Props {
onChange: (item: any) => void;
@@ -16,7 +16,7 @@ export default class UnitPicker extends PureComponent {
render() {
const { defaultValue, onChange, width } = this.props;
- const unitGroups = kbn.getUnitFormats();
+ const unitGroups = getValueFormats();
// Need to transform the data structure to work well with Select
const groupOptions = unitGroups.map(group => {
diff --git a/public/app/core/components/Select/UserPicker.tsx b/public/app/core/components/Select/UserPicker.tsx
index 8496d707105..ff4ae32f068 100644
--- a/public/app/core/components/Select/UserPicker.tsx
+++ b/public/app/core/components/Select/UserPicker.tsx
@@ -3,7 +3,7 @@ import React, { Component } from 'react';
import _ from 'lodash';
// Components
-import { AsyncSelect } from './Select';
+import { AsyncSelect } from '@grafana/ui';
// Utils & Services
import { debounce } from 'lodash';
diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
index d41626d9a2f..33aca1de2aa 100644
--- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx
+++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx
@@ -1,7 +1,6 @@
import React, { PureComponent } from 'react';
-import { Label } from 'app/core/components/Label/Label';
-import Select from 'app/core/components/Select/Select';
+import { FormLabel, Select } from '@grafana/ui';
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchHit } from 'app/types';
@@ -100,12 +99,12 @@ export class SharedPreferences extends PureComponent {
/>
-
Home Dashboard
-
+
dashboard.id === homeDashboardId)}
getOptionValue={i => i.id}
diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx
index 4b2de6b1b16..7e8bc9c6fd2 100644
--- a/public/app/core/components/TagFilter/TagFilter.tsx
+++ b/public/app/core/components/TagFilter/TagFilter.tsx
@@ -1,12 +1,10 @@
import React from 'react';
+import { NoOptionsMessage, IndicatorsContainer, resetSelectStyles } from '@grafana/ui';
import AsyncSelect from '@torkelo/react-select/lib/Async';
import { TagOption } from './TagOption';
import { TagBadge } from './TagBadge';
-import IndicatorsContainer from 'app/core/components/Select/IndicatorsContainer';
-import NoOptionsMessage from 'app/core/components/Select/NoOptionsMessage';
import { components } from '@torkelo/react-select';
-import ResetStyles from 'app/core/components/Select/ResetStyles';
export interface Props {
tags: string[];
@@ -51,7 +49,7 @@ export class TagFilter extends React.Component {
getOptionValue: i => i.value,
getOptionLabel: i => i.label,
value: tags,
- styles: ResetStyles,
+ styles: resetSelectStyles(),
filterOption: (option, searchQuery) => {
const regex = RegExp(searchQuery, 'i');
return regex.test(option.value);
diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
index 2524a265054..a2c06eef9f5 100644
--- a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
+++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
@@ -1,5 +1,5 @@
-import React, { SFC, ReactNode, PureComponent } from 'react';
-import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import React, { FC, ReactNode, PureComponent } from 'react';
+import { Tooltip } from '@grafana/ui';
interface ToggleButtonGroupProps {
label?: string;
@@ -29,7 +29,7 @@ interface ToggleButtonProps {
tooltip?: string;
}
-export const ToggleButton: SFC = ({
+export const ToggleButton: FC = ({
children,
selected,
className = '',
diff --git a/public/app/core/components/Tooltip/Popover.test.tsx b/public/app/core/components/Tooltip/Popover.test.tsx
deleted file mode 100644
index eea416a9262..00000000000
--- a/public/app/core/components/Tooltip/Popover.test.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import Popover from './Popover';
-
-describe('Popover', () => {
- it('renders correctly', () => {
- const tree = renderer
- .create(
-
- Button with Popover
-
- )
- .toJSON();
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/public/app/core/components/Tooltip/Popover.tsx b/public/app/core/components/Tooltip/Popover.tsx
deleted file mode 100644
index 62397243c1c..00000000000
--- a/public/app/core/components/Tooltip/Popover.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React, { PureComponent } from 'react';
-import Popper from './Popper';
-import withPopper, { UsingPopperProps } from './withPopper';
-
-class Popover extends PureComponent {
- render() {
- const { children, hidePopper, showPopper, className, ...restProps } = this.props;
-
- const togglePopper = restProps.show ? hidePopper : showPopper;
-
- return (
-
- );
- }
-}
-
-export default withPopper(Popover);
diff --git a/public/app/core/components/Tooltip/Popper.tsx b/public/app/core/components/Tooltip/Popper.tsx
deleted file mode 100644
index 36cf0fe837e..00000000000
--- a/public/app/core/components/Tooltip/Popper.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React, { PureComponent } from 'react';
-import Portal from 'app/core/components/Portal/Portal';
-import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
-import Transition from 'react-transition-group/Transition';
-
-const defaultTransitionStyles = {
- transition: 'opacity 200ms linear',
- opacity: 0,
-};
-
-const transitionStyles = {
- exited: { opacity: 0 },
- entering: { opacity: 0 },
- entered: { opacity: 1 },
- exiting: { opacity: 0 },
-};
-
-interface Props {
- renderContent: (content: any) => any;
- show: boolean;
- placement?: any;
- content: string | ((props: any) => JSX.Element);
- refClassName?: string;
-}
-
-class Popper extends PureComponent {
- render() {
- const { children, renderContent, show, placement, refClassName } = this.props;
- const { content } = this.props;
-
- return (
-
-
- {({ ref }) => (
-
- {children}
-
- )}
-
-
- {transitionState => (
-
-
- {({ ref, style, placement, arrowProps }) => {
- return (
-
-
- {renderContent(content)}
-
-
-
- );
- }}
-
-
- )}
-
-
- );
- }
-}
-
-export default Popper;
diff --git a/public/app/core/components/Tooltip/Tooltip.tsx b/public/app/core/components/Tooltip/Tooltip.tsx
deleted file mode 100644
index 795da94a03c..00000000000
--- a/public/app/core/components/Tooltip/Tooltip.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import React, { PureComponent } from 'react';
-import Popper from './Popper';
-import withPopper, { UsingPopperProps } from './withPopper';
-
-class Tooltip extends PureComponent {
- render() {
- const { children, hidePopper, showPopper, className, ...restProps } = this.props;
-
- return (
-
- );
- }
-}
-
-export default withPopper(Tooltip);
diff --git a/public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap b/public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap
deleted file mode 100644
index b36a4fe9af9..00000000000
--- a/public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap
+++ /dev/null
@@ -1,16 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Popover renders correctly 1`] = `
-
-
-
- Button with Popover
-
-
-
-`;
diff --git a/public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap b/public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
deleted file mode 100644
index c7d680049f4..00000000000
--- a/public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
+++ /dev/null
@@ -1,19 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Tooltip renders correctly 1`] = `
-
-`;
diff --git a/public/app/core/components/Tooltip/withPopper.tsx b/public/app/core/components/Tooltip/withPopper.tsx
deleted file mode 100644
index 4ba05937531..00000000000
--- a/public/app/core/components/Tooltip/withPopper.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import React from 'react';
-
-export interface UsingPopperProps {
- showPopper: (prevState: object) => void;
- hidePopper: (prevState: object) => void;
- renderContent: (content: any) => any;
- show: boolean;
- placement?: string;
- content: string | ((props: any) => JSX.Element);
- className?: string;
- refClassName?: string;
-}
-
-interface Props {
- placement?: string;
- className?: string;
- refClassName?: string;
- content: string | ((props: any) => JSX.Element);
-}
-
-interface State {
- placement: string;
- show: boolean;
-}
-
-export default function withPopper(WrappedComponent) {
- return class extends React.Component {
- constructor(props) {
- super(props);
- this.setState = this.setState.bind(this);
- this.state = {
- placement: this.props.placement || 'auto',
- show: false,
- };
- }
-
- componentWillReceiveProps(nextProps) {
- if (nextProps.placement && nextProps.placement !== this.state.placement) {
- this.setState(prevState => {
- return {
- ...prevState,
- placement: nextProps.placement,
- };
- });
- }
- }
-
- showPopper = () => {
- this.setState(prevState => ({
- ...prevState,
- show: true,
- }));
- };
-
- hidePopper = () => {
- this.setState(prevState => ({
- ...prevState,
- show: false,
- }));
- };
-
- renderContent(content) {
- if (typeof content === 'function') {
- // If it's a function we assume it's a React component
- const ReactComponent = content;
- return ;
- }
- return content;
- }
-
- render() {
- const { show, placement } = this.state;
- const className = this.props.className || '';
-
- return (
-
- );
- }
- };
-}
diff --git a/public/app/core/components/colorpicker/ColorPicker.tsx b/public/app/core/components/colorpicker/ColorPicker.tsx
deleted file mode 100644
index 9541001b0a8..00000000000
--- a/public/app/core/components/colorpicker/ColorPicker.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import Drop from 'tether-drop';
-import { ColorPickerPopover } from './ColorPickerPopover';
-import { react2AngularDirective } from 'app/core/utils/react2angular';
-
-export interface Props {
- color: string;
- onChange: (c: string) => void;
-}
-
-export class ColorPicker extends React.Component {
- pickerElem: HTMLElement;
- colorPickerDrop: any;
-
- openColorPicker = () => {
- const dropContent = ;
-
- const dropContentElem = document.createElement('div');
- ReactDOM.render(dropContent, dropContentElem);
-
- const drop = new Drop({
- target: this.pickerElem,
- content: dropContentElem,
- position: 'top center',
- classes: 'drop-popover',
- openOn: 'click',
- hoverCloseDelay: 200,
- tetherOptions: {
- constraints: [{ to: 'scrollParent', attachment: 'none both' }],
- },
- });
-
- 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 => {
- this.props.onChange(color);
- };
-
- render() {
- return (
- (this.pickerElem = element)}>
-
-
- );
- }
-}
-
-react2AngularDirective('colorPicker', ColorPicker, [
- 'color',
- ['onChange', { watchDepth: 'reference', wrapApply: true }],
-]);
diff --git a/public/app/core/components/colorpicker/ColorPickerPopover.tsx b/public/app/core/components/colorpicker/ColorPickerPopover.tsx
deleted file mode 100644
index c42bcfa1d06..00000000000
--- a/public/app/core/components/colorpicker/ColorPickerPopover.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import React from 'react';
-import $ from 'jquery';
-import tinycolor from 'tinycolor2';
-import { ColorPalette } from './ColorPalette';
-import { SpectrumPicker } from './SpectrumPicker';
-
-const DEFAULT_COLOR = '#000000';
-
-export interface Props {
- color: string;
- onColorSelect: (c: string) => void;
-}
-
-export class ColorPickerPopover extends React.Component {
- pickerNavElem: any;
-
- constructor(props) {
- super(props);
- this.state = {
- tab: 'palette',
- color: this.props.color || DEFAULT_COLOR,
- colorString: this.props.color || DEFAULT_COLOR,
- };
- }
-
- setPickerNavElem(elem) {
- this.pickerNavElem = $(elem);
- }
-
- setColor(color) {
- const newColor = tinycolor(color);
- if (newColor.isValid()) {
- this.setState({
- color: newColor.toString(),
- colorString: newColor.toString(),
- });
- this.props.onColorSelect(color);
- }
- }
-
- sampleColorSelected(color) {
- this.setColor(color);
- }
-
- spectrumColorSelected(color) {
- const rgbColor = color.toRgbString();
- this.setColor(rgbColor);
- }
-
- onColorStringChange(e) {
- 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);
- }
- }
-
- onColorStringBlur(e) {
- const colorString = e.target.value;
- this.setColor(colorString);
- }
-
- componentDidMount() {
- this.pickerNavElem.find('li:first').addClass('active');
- this.pickerNavElem.on('show', e => {
- // use href attr (#name => name)
- const tab = e.target.hash.slice(1);
- this.setState({
- tab: tab,
- });
- });
- }
-
- render() {
- const paletteTab = (
-
-
-
- );
- const spectrumTab = (
-
-
-
- );
- const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab;
-
- return (
-
- );
- }
-}
diff --git a/public/app/core/components/colorpicker/SeriesColorPicker.tsx b/public/app/core/components/colorpicker/SeriesColorPicker.tsx
deleted file mode 100644
index 32b7554e38d..00000000000
--- a/public/app/core/components/colorpicker/SeriesColorPicker.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import Drop from 'tether-drop';
-import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
-
-export interface SeriesColorPickerProps {
- color: string;
- yaxis?: number;
- optionalClass?: string;
- onColorChange: (newColor: string) => void;
- onToggleAxis?: () => void;
-}
-
-export class SeriesColorPicker extends React.Component {
- pickerElem: any;
- colorPickerDrop: any;
-
- static defaultProps = {
- optionalClass: '',
- yaxis: undefined,
- onToggleAxis: () => {},
- };
-
- constructor(props) {
- super(props);
- }
-
- componentWillUnmount() {
- this.destroyDrop();
- }
-
- onClickToOpen = () => {
- if (this.colorPickerDrop) {
- this.destroyDrop();
- }
-
- const { color, yaxis, onColorChange, onToggleAxis } = this.props;
- const dropContent = (
-
- );
- const dropContentElem = document.createElement('div');
- ReactDOM.render(dropContent, dropContentElem);
-
- const drop = new Drop({
- target: this.pickerElem,
- content: dropContentElem,
- position: 'bottom center',
- classes: 'drop-popover',
- openOn: 'hover',
- hoverCloseDelay: 200,
- remove: true,
- tetherOptions: {
- constraints: [{ to: 'scrollParent', attachment: 'none both' }],
- },
- });
-
- drop.on('close', this.closeColorPicker.bind(this));
-
- this.colorPickerDrop = drop;
- this.colorPickerDrop.open();
- };
-
- closeColorPicker() {
- setTimeout(() => {
- this.destroyDrop();
- }, 100);
- }
-
- destroyDrop() {
- if (this.colorPickerDrop && this.colorPickerDrop.tether) {
- this.colorPickerDrop.destroy();
- this.colorPickerDrop = null;
- }
- }
-
- render() {
- const { optionalClass, children } = this.props;
- return (
- (this.pickerElem = e)} onClick={this.onClickToOpen}>
- {children}
-
- );
- }
-}
diff --git a/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx b/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx
deleted file mode 100644
index 085d554300d..00000000000
--- a/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import { ColorPickerPopover } from './ColorPickerPopover';
-import { react2AngularDirective } from 'app/core/utils/react2angular';
-
-export interface SeriesColorPickerPopoverProps {
- color: string;
- yaxis?: number;
- onColorChange: (color: string) => void;
- onToggleAxis?: () => void;
-}
-
-export class SeriesColorPickerPopover extends React.PureComponent {
- render() {
- return (
-
- {this.props.yaxis &&
}
-
-
- );
- }
-}
-
-interface AxisSelectorProps {
- yaxis: number;
- onToggleAxis: () => void;
-}
-
-interface AxisSelectorState {
- yaxis: number;
-}
-
-export class AxisSelector extends React.PureComponent {
- constructor(props) {
- super(props);
- this.state = {
- yaxis: this.props.yaxis,
- };
- this.onToggleAxis = this.onToggleAxis.bind(this);
- }
-
- onToggleAxis() {
- this.setState({
- yaxis: this.state.yaxis === 2 ? 1 : 2,
- });
- this.props.onToggleAxis();
- }
-
- render() {
- const leftButtonClass = this.state.yaxis === 1 ? 'btn-success' : 'btn-inverse';
- const rightButtonClass = this.state.yaxis === 2 ? 'btn-success' : 'btn-inverse';
-
- return (
-
- Y Axis:
-
- Left
-
-
- Right
-
-
- );
- }
-}
-
-react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
- 'series',
- 'onColorChange',
- 'onToggleAxis',
-]);
diff --git a/public/app/core/components/colorpicker/SpectrumPicker.tsx b/public/app/core/components/colorpicker/SpectrumPicker.tsx
deleted file mode 100644
index 15a76068e9b..00000000000
--- a/public/app/core/components/colorpicker/SpectrumPicker.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import _ from 'lodash';
-import $ from 'jquery';
-import 'vendor/spectrum';
-
-export interface Props {
- color: string;
- options: object;
- onColorSelect: (c: string) => void;
-}
-
-export class SpectrumPicker extends React.Component {
- elem: any;
- isMoving: boolean;
-
- constructor(props) {
- super(props);
- this.onSpectrumMove = this.onSpectrumMove.bind(this);
- this.setComponentElem = this.setComponentElem.bind(this);
- }
-
- setComponentElem(elem) {
- this.elem = $(elem);
- }
-
- onSpectrumMove(color) {
- 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) {
- // If user move pointer over spectrum field this produce 'move' event and component
- // may update props.color. We don't want to update spectrum color in this case, so we can use
- // isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which
- // is called after updating occurs (when user finished moving).
- if (!this.isMoving) {
- this.elem.spectrum('set', nextProps.color);
- }
- }
-
- componentDidUpdate() {
- if (this.isMoving) {
- this.isMoving = false;
- }
- }
-
- componentWillUnmount() {
- this.elem.spectrum('destroy');
- }
-
- render() {
- return
;
- }
-}
diff --git a/public/app/core/components/sidemenu/DropDownChild.tsx b/public/app/core/components/sidemenu/DropDownChild.tsx
index 1a577d185e5..41aa794999e 100644
--- a/public/app/core/components/sidemenu/DropDownChild.tsx
+++ b/public/app/core/components/sidemenu/DropDownChild.tsx
@@ -1,10 +1,10 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
export interface Props {
child: any;
}
-const DropDownChild: SFC = props => {
+const DropDownChild: FC = props => {
const { child } = props;
const listItemClassName = child.divider ? 'divider' : '';
diff --git a/public/app/core/components/sidemenu/SideMenuDropDown.tsx b/public/app/core/components/sidemenu/SideMenuDropDown.tsx
index 7cd7554f82c..db2172039c6 100644
--- a/public/app/core/components/sidemenu/SideMenuDropDown.tsx
+++ b/public/app/core/components/sidemenu/SideMenuDropDown.tsx
@@ -1,16 +1,18 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import DropDownChild from './DropDownChild';
interface Props {
link: any;
}
-const SideMenuDropDown: SFC = props => {
+const SideMenuDropDown: FC = props => {
const { link } = props;
return (
- {link.text}
+
+ {link.text}
+
{link.children &&
link.children.map((child, index) => {
diff --git a/public/app/core/components/sidemenu/SignIn.tsx b/public/app/core/components/sidemenu/SignIn.tsx
index 17dd913823a..50b3aef2d9b 100644
--- a/public/app/core/components/sidemenu/SignIn.tsx
+++ b/public/app/core/components/sidemenu/SignIn.tsx
@@ -1,6 +1,6 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
-const SignIn: SFC = () => {
+const SignIn: FC = () => {
const loginUrl = `login?redirect=${encodeURIComponent(window.location.pathname)}`;
return (
diff --git a/public/app/core/components/sidemenu/TopSection.tsx b/public/app/core/components/sidemenu/TopSection.tsx
index c6bf5df8242..827b868ea67 100644
--- a/public/app/core/components/sidemenu/TopSection.tsx
+++ b/public/app/core/components/sidemenu/TopSection.tsx
@@ -1,9 +1,9 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import _ from 'lodash';
import TopSectionItem from './TopSectionItem';
import config from '../../config';
-const TopSection: SFC
= () => {
+const TopSection: FC = () => {
const navTree = _.cloneDeep(config.bootData.navTree);
const mainLinks = _.filter(navTree, item => !item.hideFromMenu);
diff --git a/public/app/core/components/sidemenu/TopSectionItem.tsx b/public/app/core/components/sidemenu/TopSectionItem.tsx
index 7b3bf96dce8..0aca32c3ba3 100644
--- a/public/app/core/components/sidemenu/TopSectionItem.tsx
+++ b/public/app/core/components/sidemenu/TopSectionItem.tsx
@@ -1,11 +1,11 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import SideMenuDropDown from './SideMenuDropDown';
export interface Props {
link: any;
}
-const TopSectionItem: SFC = props => {
+const TopSectionItem: FC = props => {
const { link } = props;
return (
diff --git a/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
index 861168c1cc3..20d0a3ef3a4 100644
--- a/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
+++ b/public/app/core/components/sidemenu/__snapshots__/SideMenuDropDown.test.tsx.snap
@@ -8,11 +8,15 @@ exports[`Render should render children 1`] = `
-
- link
-
+
+ link
+
+
-
- link
-
+
+ link
+
+
`;
diff --git a/public/app/core/config.ts b/public/app/core/config.ts
index 13d84772ecf..395e40e914b 100644
--- a/public/app/core/config.ts
+++ b/public/app/core/config.ts
@@ -6,6 +6,8 @@ export interface BuildInfo {
commit: string;
isEnterprise: boolean;
env: string;
+ latestVersion: string;
+ hasUpdate: boolean;
}
export class Settings {
@@ -32,8 +34,10 @@ export class Settings {
disableUserSignUp: boolean;
loginHint: any;
loginError: any;
+ viewersCanEdit: boolean;
+ disableSanitizeHtml: boolean;
- constructor(options) {
+ constructor(options: Settings) {
const defaults = {
datasources: {},
windowTitlePrefix: 'Grafana - ',
@@ -48,6 +52,8 @@ export class Settings {
env: 'production',
isEnterprise: false,
},
+ viewersCanEdit: false,
+ disableSanitizeHtml: false
};
_.extend(this, defaults, options);
diff --git a/public/app/core/controllers/all.ts b/public/app/core/controllers/all.ts
index 0dbcdf4cb28..f6a4e51bad4 100644
--- a/public/app/core/controllers/all.ts
+++ b/public/app/core/controllers/all.ts
@@ -1,4 +1,3 @@
-import './inspect_ctrl';
import './json_editor_ctrl';
import './login_ctrl';
import './invited_ctrl';
diff --git a/public/app/core/controllers/inspect_ctrl.ts b/public/app/core/controllers/inspect_ctrl.ts
deleted file mode 100644
index d106b42da16..00000000000
--- a/public/app/core/controllers/inspect_ctrl.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-import $ from 'jquery';
-import coreModule from '../core_module';
-
-export class InspectCtrl {
- /** @ngInject */
- constructor($scope, $sanitize) {
- const model = $scope.inspector;
-
- $scope.init = function() {
- $scope.editor = { index: 0 };
-
- if (!model.error) {
- return;
- }
-
- if (_.isString(model.error.data)) {
- $scope.response = $('' + model.error.data + '
').text();
- } else if (model.error.data) {
- if (model.error.data.response) {
- $scope.response = $sanitize(model.error.data.response);
- } else {
- $scope.response = angular.toJson(model.error.data, true);
- }
- } else if (model.error.message) {
- $scope.message = model.error.message;
- }
-
- if (model.error.config && model.error.config.params) {
- $scope.request_parameters = _.map(model.error.config.params, (value, key) => {
- return { key: key, value: value };
- });
- }
-
- if (model.error.stack) {
- $scope.editor.index = 3;
- $scope.stack_trace = model.error.stack;
- $scope.message = model.error.message;
- }
-
- if (model.error.config && model.error.config.data) {
- $scope.editor.index = 2;
-
- if (_.isString(model.error.config.data)) {
- $scope.request_parameters = this.getParametersFromQueryString(model.error.config.data);
- } else {
- $scope.request_parameters = _.map(model.error.config.data, (value, key) => {
- return { key: key, value: angular.toJson(value, true) };
- });
- }
- }
- };
- }
- getParametersFromQueryString(queryString) {
- const result = [];
- const parameters = queryString.split('&');
- for (let i = 0; i < parameters.length; i++) {
- const keyValue = parameters[i].split('=');
- if (keyValue[1].length > 0) {
- result.push({
- key: keyValue[0],
- value: (window as any).unescape(keyValue[1]),
- });
- }
- }
- return result;
- }
-}
-
-coreModule.controller('InspectCtrl', InspectCtrl);
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index 257a2077c97..fb38cefd435 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -1,5 +1,6 @@
import './directives/dash_class';
import './directives/dropdown_typeahead';
+import './directives/autofill_event_fix';
import './directives/metric_segment';
import './directives/misc';
import './directives/ng_model_on_blur';
@@ -13,11 +14,10 @@ import './partials';
import './components/jsontree/jsontree';
import './components/code_editor/code_editor';
import './utils/outline';
-import './components/colorpicker/ColorPicker';
-import './components/colorpicker/SeriesColorPickerPopover';
import './components/colorpicker/spectrum_picker';
import './services/search_srv';
import './services/ng_react';
+import { colors } from '@grafana/ui/';
import { searchDirective } from './components/search/search';
import { infoPopover } from './components/info_popover';
@@ -36,7 +36,6 @@ import 'app/core/services/all';
import './filters/filters';
import coreModule from './core_module';
import appEvents from './app_events';
-import colors from './utils/colors';
import { assignModelProperties } from './utils/model_utils';
import { contextSrv } from './services/context_srv';
import { KeybindingSrv } from './services/keybindingSrv';
diff --git a/public/app/core/directives/autofill_event_fix.ts b/public/app/core/directives/autofill_event_fix.ts
new file mode 100644
index 00000000000..51d278fe7c9
--- /dev/null
+++ b/public/app/core/directives/autofill_event_fix.ts
@@ -0,0 +1,35 @@
+import coreModule from '../core_module';
+
+/** @ngInject */
+export function autofillEventFix($compile) {
+ return {
+ link: ($scope: any, elem: any) => {
+ const input = elem[0];
+ const dispatchChangeEvent = () => {
+ const event = new Event('change');
+ return input.dispatchEvent(event);
+ };
+ const onAnimationStart = ({ animationName }: AnimationEvent) => {
+ switch (animationName) {
+ case 'onAutoFillStart':
+ return dispatchChangeEvent();
+ case 'onAutoFillCancel':
+ return dispatchChangeEvent();
+ }
+ return null;
+ };
+
+ // const onChange = (evt: Event) => console.log(evt);
+
+ input.addEventListener('animationstart', onAnimationStart);
+ // input.addEventListener('change', onChange);
+
+ $scope.$on('$destroy', () => {
+ input.removeEventListener('animationstart', onAnimationStart);
+ // input.removeEventListener('change', onChange);
+ });
+ }
+ };
+}
+
+coreModule.directive('autofillEventFix', autofillEventFix);
diff --git a/public/app/core/directives/dropdown_typeahead.ts b/public/app/core/directives/dropdown_typeahead.ts
index a4bed4fe2b7..dfc3eddbcbb 100644
--- a/public/app/core/directives/dropdown_typeahead.ts
+++ b/public/app/core/directives/dropdown_typeahead.ts
@@ -141,6 +141,9 @@ export function dropdownTypeahead2($compile) {
link: ($scope, elem, attrs) => {
const $input = $(inputTemplate);
const $button = $(buttonTemplate);
+ const timeoutId = {
+ blur: null
+ };
$input.appendTo(elem);
$button.appendTo(elem);
@@ -177,6 +180,14 @@ export function dropdownTypeahead2($compile) {
[]
);
+ const closeDropdownMenu = () => {
+ $input.hide();
+ $input.val('');
+ $button.show();
+ $button.focus();
+ elem.removeClass('open');
+ };
+
$scope.menuItemSelected = (index, subIndex) => {
const menuItem = $scope.menuItems[index];
const payload: any = { $item: menuItem };
@@ -184,6 +195,7 @@ export function dropdownTypeahead2($compile) {
payload.$subItem = menuItem.submenu[subIndex];
}
$scope.dropdownTypeaheadOnSelect(payload);
+ closeDropdownMenu();
};
$input.attr('data-provide', 'typeahead');
@@ -223,16 +235,15 @@ export function dropdownTypeahead2($compile) {
elem.toggleClass('open', $input.val() === '');
});
+ elem.mousedown((evt: Event) => {
+ evt.preventDefault();
+ timeoutId.blur = null;
+ });
+
$input.blur(() => {
- $input.hide();
- $input.val('');
- $button.show();
- $button.focus();
- // clicking the function dropdown menu won't
- // work if you remove class at once
- setTimeout(() => {
- elem.removeClass('open');
- }, 200);
+ timeoutId.blur = setTimeout(() => {
+ closeDropdownMenu();
+ }, 1);
});
$compile(elem.contents())($scope);
diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts
index 4e8c6207959..a3f78e7152a 100644
--- a/public/app/core/logs_model.ts
+++ b/public/app/core/logs_model.ts
@@ -1,6 +1,8 @@
import _ from 'lodash';
+import { colors } from '@grafana/ui';
+
import { TimeSeries } from 'app/core/core';
-import colors, { getThemeColor } from 'app/core/utils/colors';
+import { getThemeColor } from 'app/core/utils/colors';
/**
* Mapping of log level abbreviation to canonical log level.
@@ -40,7 +42,7 @@ export interface LogSearchMatch {
text: string;
}
-export interface LogRow {
+export interface LogRowModel {
duplicates?: number;
entry: string;
key: string; // timestamp + labels
@@ -54,7 +56,7 @@ export interface LogRow {
uniqueLabels?: LogsStreamLabels;
}
-export interface LogsLabelStat {
+export interface LogLabelStatsModel {
active?: boolean;
count: number;
proportion: number;
@@ -76,7 +78,7 @@ export interface LogsMetaItem {
export interface LogsModel {
id: string; // Identify one logs result from another
meta?: LogsMetaItem[];
- rows: LogRow[];
+ rows: LogRowModel[];
series?: TimeSeries[];
}
@@ -186,13 +188,13 @@ export const LogsParsers: { [name: string]: LogsParser } = {
},
};
-export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] {
+export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] {
// Consider only rows that satisfy the matcher
const rowsWithField = rows.filter(row => extractor.test(row.entry));
const rowCount = rowsWithField.length;
// Get field value counts for eligible rows
- const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]);
+ const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]);
const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count')
@@ -202,13 +204,13 @@ export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabe
return sortedCounts;
}
-export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] {
+export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
// Consider only rows that have the given label
const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
const rowCount = rowsWithLabel.length;
// Get label value counts for eligible rows
- const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]);
+ const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]);
const sortedCounts = _.chain(countsByValue)
.map((count, value) => ({ count, value, proportion: count / rowCount }))
.sortBy('count')
@@ -219,7 +221,7 @@ export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabe
}
const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
-function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean {
+function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean {
switch (strategy) {
case LogsDedupStrategy.exact:
// Exact still strips dates
@@ -241,7 +243,7 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
return logs;
}
- const dedupedRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => {
+ const dedupedRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
const previous = result[result.length - 1];
if (index > 0 && isDuplicateRow(row, previous, strategy)) {
previous.duplicates++;
@@ -276,7 +278,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set)
return logs;
}
- const filteredRows = logs.rows.reduce((result: LogRow[], row: LogRow, index, list) => {
+ const filteredRows = logs.rows.reduce((result: LogRowModel[], row: LogRowModel, index, list) => {
if (!hiddenLogLevels.has(row.logLevel)) {
result.push(row);
}
@@ -289,7 +291,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set)
};
}
-export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] {
+export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): TimeSeries[] {
// currently interval is rangeMs / resolution, which is too low for showing series as bars.
// need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain
// when executing queries & interval calculated and not here but this is a temporary fix.
diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts
index a42bd813782..6b39710dcca 100644
--- a/public/app/core/reducers/location.ts
+++ b/public/app/core/reducers/location.ts
@@ -1,4 +1,4 @@
-import { Action } from 'app/core/actions/location';
+import { Action, CoreActionTypes } from 'app/core/actions/location';
import { LocationState } from 'app/types';
import { renderUrl } from 'app/core/utils/url';
import _ from 'lodash';
@@ -12,7 +12,7 @@ export const initialState: LocationState = {
export const locationReducer = (state = initialState, action: Action): LocationState => {
switch (action.type) {
- case 'UPDATE_LOCATION': {
+ case CoreActionTypes.UpdateLocation: {
const { path, routeParams } = action.payload;
let query = action.payload.query || state.query;
@@ -24,9 +24,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
return {
url: renderUrl(path || state.path, query),
path: path || state.path,
- query: {
- ...query,
- },
+ query: { ...query },
routeParams: routeParams || state.routeParams,
};
}
diff --git a/public/app/core/selectors/navModel.ts b/public/app/core/selectors/navModel.ts
index aa508616962..7d745b58002 100644
--- a/public/app/core/selectors/navModel.ts
+++ b/public/app/core/selectors/navModel.ts
@@ -41,3 +41,7 @@ export function getNavModel(navIndex: NavIndex, id: string, fallback?: NavModel)
return getNotFoundModel();
}
+
+export const getTitleFromNavModel = (navModel: NavModel) => {
+ return `${navModel.main.text}${navModel.node.text ? ': ' + navModel.node.text : '' }`;
+};
diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts
index 854169ad4b0..38d7f2b76cb 100644
--- a/public/app/core/services/backend_srv.ts
+++ b/public/app/core/services/backend_srv.ts
@@ -1,7 +1,7 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
-import { DashboardModel } from 'app/features/dashboard/dashboard_model';
+import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
export class BackendSrv {
private inFlightRequests = {};
diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts
index c4134598175..7bb753e6f71 100644
--- a/public/app/core/services/context_srv.ts
+++ b/public/app/core/services/context_srv.ts
@@ -59,6 +59,10 @@ export class ContextSrv {
this.sidemenu = !this.sidemenu;
store.set('grafana.sidemenu', this.sidemenu);
}
+
+ hasAccessToExplore() {
+ return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
+ }
}
const contextSrv = new ContextSrv();
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index c02f6850e8b..989746fd067 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/public/app/core/services/keybindingSrv.ts
@@ -1,13 +1,13 @@
import $ from 'jquery';
import _ from 'lodash';
-import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
+import { ContextSrv } from './context_srv';
export class KeybindingSrv {
helpModal: boolean;
@@ -21,7 +21,7 @@ export class KeybindingSrv {
private $timeout,
private datasourceSrv,
private timeSrv,
- private contextSrv
+ private contextSrv: ContextSrv
) {
// clear out all shortcuts on route change
$rootScope.$on('$routeChangeSuccess', () => {
@@ -196,7 +196,7 @@ export class KeybindingSrv {
});
// jump to explore if permissions allow
- if (this.contextSrv.isEditor && config.exploreEnabled) {
+ if (this.contextSrv.hasAccessToExplore()) {
this.bind('x', async () => {
if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
@@ -236,7 +236,7 @@ export class KeybindingSrv {
shareScope.dashboard = dashboard;
appEvents.emit('show-modal', {
- src: 'public/app/features/dashboard/partials/shareModal.html',
+ src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: shareScope,
});
}
diff --git a/public/app/core/specs/factors.test.ts b/public/app/core/specs/factors.test.ts
new file mode 100644
index 00000000000..aed59b5be8b
--- /dev/null
+++ b/public/app/core/specs/factors.test.ts
@@ -0,0 +1,8 @@
+import getFactors from 'app/core/utils/factors';
+
+describe('factors', () => {
+ it('should return factors for 12', () => {
+ const factors = getFactors(12);
+ expect(factors).toEqual([1, 2, 3, 4, 6, 12]);
+ });
+});
diff --git a/public/app/core/specs/kbn.test.ts b/public/app/core/specs/kbn.test.ts
deleted file mode 100644
index e621cdef632..00000000000
--- a/public/app/core/specs/kbn.test.ts
+++ /dev/null
@@ -1,493 +0,0 @@
-import kbn from '../utils/kbn';
-import * as dateMath from '../utils/datemath';
-import moment from 'moment';
-
-describe('unit format menu', () => {
- const menu = kbn.getUnitFormats();
- menu.map(submenu => {
- describe('submenu ' + submenu.text, () => {
- it('should have a title', () => {
- expect(typeof submenu.text).toBe('string');
- });
-
- it('should have a submenu', () => {
- expect(Array.isArray(submenu.submenu)).toBe(true);
- });
-
- submenu.submenu.map(entry => {
- describe('entry ' + entry.text, () => {
- it('should have a title', () => {
- expect(typeof entry.text).toBe('string');
- });
- it('should have a format', () => {
- expect(typeof entry.value).toBe('string');
- });
- it('should have a valid format', () => {
- expect(typeof kbn.valueFormats[entry.value]).toBe('function');
- });
- });
- });
- });
- });
-});
-
-function describeValueFormat(desc, value, tickSize, tickDecimals, result) {
- describe('value format: ' + desc, () => {
- it('should translate ' + value + ' as ' + result, () => {
- const scaledDecimals = tickDecimals - Math.floor(Math.log(tickSize) / Math.LN10);
- const str = kbn.valueFormats[desc](value, tickDecimals, scaledDecimals);
- expect(str).toBe(result);
- });
- });
-}
-
-describeValueFormat('ms', 0.0024, 0.0005, 4, '0.0024 ms');
-describeValueFormat('ms', 100, 1, 0, '100 ms');
-describeValueFormat('ms', 1250, 10, 0, '1.25 s');
-describeValueFormat('ms', 1250, 300, 0, '1.3 s');
-describeValueFormat('ms', 65150, 10000, 0, '1.1 min');
-describeValueFormat('ms', 6515000, 1500000, 0, '1.8 hour');
-describeValueFormat('ms', 651500000, 150000000, 0, '8 day');
-
-describeValueFormat('none', 2.75e-10, 0, 10, '3e-10');
-describeValueFormat('none', 0, 0, 2, '0');
-describeValueFormat('dB', 10, 1000, 2, '10.00 dB');
-
-describeValueFormat('percent', 0, 0, 0, '0%');
-describeValueFormat('percent', 53, 0, 1, '53.0%');
-describeValueFormat('percentunit', 0.0, 0, 0, '0%');
-describeValueFormat('percentunit', 0.278, 0, 1, '27.8%');
-describeValueFormat('percentunit', 1.0, 0, 0, '100%');
-
-describeValueFormat('currencyUSD', 7.42, 10000, 2, '$7.42');
-describeValueFormat('currencyUSD', 1532.82, 1000, 1, '$1.53K');
-describeValueFormat('currencyUSD', 18520408.7, 10000000, 0, '$19M');
-
-describeValueFormat('bytes', -1.57e308, -1.57e308, 2, 'NA');
-
-describeValueFormat('ns', 25, 1, 0, '25 ns');
-describeValueFormat('ns', 2558, 50, 0, '2.56 µs');
-
-describeValueFormat('ops', 123, 1, 0, '123 ops');
-describeValueFormat('rps', 456000, 1000, -1, '456K rps');
-describeValueFormat('rps', 123456789, 1000000, 2, '123.457M rps');
-describeValueFormat('wps', 789000000, 1000000, -1, '789M wps');
-describeValueFormat('iops', 11000000000, 1000000000, -1, '11B iops');
-
-describeValueFormat('s', 1.23456789e-7, 1e-10, 8, '123.5 ns');
-describeValueFormat('s', 1.23456789e-4, 1e-7, 5, '123.5 µs');
-describeValueFormat('s', 1.23456789e-3, 1e-6, 4, '1.235 ms');
-describeValueFormat('s', 1.23456789e-2, 1e-5, 3, '12.35 ms');
-describeValueFormat('s', 1.23456789e-1, 1e-4, 2, '123.5 ms');
-describeValueFormat('s', 24, 1, 0, '24 s');
-describeValueFormat('s', 246, 1, 0, '4.1 min');
-describeValueFormat('s', 24567, 100, 0, '6.82 hour');
-describeValueFormat('s', 24567890, 10000, 0, '40.62 week');
-describeValueFormat('s', 24567890000, 1000000, 0, '778.53 year');
-
-describeValueFormat('m', 24, 1, 0, '24 min');
-describeValueFormat('m', 246, 10, 0, '4.1 hour');
-describeValueFormat('m', 6545, 10, 0, '4.55 day');
-describeValueFormat('m', 24567, 100, 0, '2.44 week');
-describeValueFormat('m', 24567892, 10000, 0, '46.7 year');
-
-describeValueFormat('h', 21, 1, 0, '21 hour');
-describeValueFormat('h', 145, 1, 0, '6.04 day');
-describeValueFormat('h', 1234, 100, 0, '7.3 week');
-describeValueFormat('h', 9458, 1000, 0, '1.08 year');
-
-describeValueFormat('d', 3, 1, 0, '3 day');
-describeValueFormat('d', 245, 100, 0, '35 week');
-describeValueFormat('d', 2456, 10, 0, '6.73 year');
-
-describe('date time formats', () => {
- const epoch = 1505634997920;
- const utcTime = moment.utc(epoch);
- const browserTime = moment(epoch);
-
- it('should format as iso date', () => {
- const expected = browserTime.format('YYYY-MM-DD HH:mm:ss');
- const actual = kbn.valueFormats.dateTimeAsIso(epoch);
- expect(actual).toBe(expected);
- });
-
- it('should format as iso date (in UTC)', () => {
- const expected = utcTime.format('YYYY-MM-DD HH:mm:ss');
- const actual = kbn.valueFormats.dateTimeAsIso(epoch, true);
- expect(actual).toBe(expected);
- });
-
- it('should format as iso date and skip date when today', () => {
- const now = moment();
- const expected = now.format('HH:mm:ss');
- const actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), false);
- expect(actual).toBe(expected);
- });
-
- it('should format as iso date (in UTC) and skip date when today', () => {
- const now = moment.utc();
- const expected = now.format('HH:mm:ss');
- const actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), true);
- expect(actual).toBe(expected);
- });
-
- it('should format as US date', () => {
- const expected = browserTime.format('MM/DD/YYYY h:mm:ss a');
- const actual = kbn.valueFormats.dateTimeAsUS(epoch, false);
- expect(actual).toBe(expected);
- });
-
- it('should format as US date (in UTC)', () => {
- const expected = utcTime.format('MM/DD/YYYY h:mm:ss a');
- const actual = kbn.valueFormats.dateTimeAsUS(epoch, true);
- expect(actual).toBe(expected);
- });
-
- it('should format as US date and skip date when today', () => {
- const now = moment();
- const expected = now.format('h:mm:ss a');
- const actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), false);
- expect(actual).toBe(expected);
- });
-
- it('should format as US date (in UTC) and skip date when today', () => {
- const now = moment.utc();
- const expected = now.format('h:mm:ss a');
- const actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), true);
- expect(actual).toBe(expected);
- });
-
- it('should format as from now with days', () => {
- const daysAgo = moment().add(-7, 'd');
- const expected = '7 days ago';
- const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
- expect(actual).toBe(expected);
- });
-
- it('should format as from now with days (in UTC)', () => {
- const daysAgo = moment.utc().add(-7, 'd');
- const expected = '7 days ago';
- const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
- expect(actual).toBe(expected);
- });
-
- it('should format as from now with minutes', () => {
- const daysAgo = moment().add(-2, 'm');
- const expected = '2 minutes ago';
- const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
- expect(actual).toBe(expected);
- });
-
- it('should format as from now with minutes (in UTC)', () => {
- const daysAgo = moment.utc().add(-2, 'm');
- const expected = '2 minutes ago';
- const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
- expect(actual).toBe(expected);
- });
-});
-
-describe('kbn.toFixed and negative decimals', () => {
- it('should treat as zero decimals', () => {
- const str = kbn.toFixed(186.123, -2);
- expect(str).toBe('186');
- });
-});
-
-describe('kbn ms format when scaled decimals is null do not use it', () => {
- it('should use specified decimals', () => {
- const str = kbn.valueFormats['ms'](10000086.123, 1, null);
- expect(str).toBe('2.8 hour');
- });
-});
-
-describe('kbn kbytes format when scaled decimals is null do not use it', () => {
- it('should use specified decimals', () => {
- const str = kbn.valueFormats['kbytes'](10000000, 3, null);
- expect(str).toBe('9.537 GiB');
- });
-});
-
-describe('kbn deckbytes format when scaled decimals is null do not use it', () => {
- it('should use specified decimals', () => {
- const str = kbn.valueFormats['deckbytes'](10000000, 3, null);
- expect(str).toBe('10.000 GB');
- });
-});
-
-describe('kbn roundValue', () => {
- it('should should handle null value', () => {
- const str = kbn.roundValue(null, 2);
- expect(str).toBe(null);
- });
- it('should round value', () => {
- const str = kbn.roundValue(200.877, 2);
- expect(str).toBe(200.88);
- });
-});
-
-describe('calculateInterval', () => {
- it('1h 100 resultion', () => {
- const range = { from: dateMath.parse('now-1h'), to: dateMath.parse('now') };
- const res = kbn.calculateInterval(range, 100, null);
- expect(res.interval).toBe('30s');
- });
-
- it('10m 1600 resolution', () => {
- const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
- const res = kbn.calculateInterval(range, 1600, null);
- expect(res.interval).toBe('500ms');
- expect(res.intervalMs).toBe(500);
- });
-
- it('fixed user min interval', () => {
- const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
- const res = kbn.calculateInterval(range, 1600, '10s');
- expect(res.interval).toBe('10s');
- expect(res.intervalMs).toBe(10000);
- });
-
- it('short time range and user low limit', () => {
- const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
- const res = kbn.calculateInterval(range, 1600, '>10s');
- expect(res.interval).toBe('10s');
- });
-
- it('large time range and user low limit', () => {
- const range = { from: dateMath.parse('now-14d'), to: dateMath.parse('now') };
- const res = kbn.calculateInterval(range, 1000, '>10s');
- expect(res.interval).toBe('20m');
- });
-
- it('10s 900 resolution and user low limit in ms', () => {
- const range = { from: dateMath.parse('now-10s'), to: dateMath.parse('now') };
- const res = kbn.calculateInterval(range, 900, '>15ms');
- expect(res.interval).toBe('15ms');
- });
-
- it('1d 1 resolution', () => {
- const range = { from: dateMath.parse('now-1d'), to: dateMath.parse('now') };
- const res = kbn.calculateInterval(range, 1, null);
- expect(res.interval).toBe('1d');
- expect(res.intervalMs).toBe(86400000);
- });
-
- it('86399s 1 resolution', () => {
- const range = {
- from: dateMath.parse('now-86390s'),
- to: dateMath.parse('now'),
- };
- const res = kbn.calculateInterval(range, 1, null);
- expect(res.interval).toBe('12h');
- expect(res.intervalMs).toBe(43200000);
- });
-});
-
-describe('hex', () => {
- it('positive integer', () => {
- const str = kbn.valueFormats.hex(100, 0);
- expect(str).toBe('64');
- });
- it('negative integer', () => {
- const str = kbn.valueFormats.hex(-100, 0);
- expect(str).toBe('-64');
- });
- it('null', () => {
- const str = kbn.valueFormats.hex(null, 0);
- expect(str).toBe('');
- });
- it('positive float', () => {
- const str = kbn.valueFormats.hex(50.52, 1);
- expect(str).toBe('32.8');
- });
- it('negative float', () => {
- const str = kbn.valueFormats.hex(-50.333, 2);
- expect(str).toBe('-32.547AE147AE14');
- });
-});
-
-describe('hex 0x', () => {
- it('positive integeter', () => {
- const str = kbn.valueFormats.hex0x(7999, 0);
- expect(str).toBe('0x1F3F');
- });
- it('negative integer', () => {
- const str = kbn.valueFormats.hex0x(-584, 0);
- expect(str).toBe('-0x248');
- });
- it('null', () => {
- const str = kbn.valueFormats.hex0x(null, 0);
- expect(str).toBe('');
- });
- it('positive float', () => {
- const str = kbn.valueFormats.hex0x(74.443, 3);
- expect(str).toBe('0x4A.716872B020C4');
- });
- it('negative float', () => {
- const str = kbn.valueFormats.hex0x(-65.458, 1);
- expect(str).toBe('-0x41.8');
- });
-});
-
-describe('duration', () => {
- it('null', () => {
- const str = kbn.toDuration(null, 0, 'millisecond');
- expect(str).toBe('');
- });
- it('0 milliseconds', () => {
- const str = kbn.toDuration(0, 0, 'millisecond');
- expect(str).toBe('0 milliseconds');
- });
- it('1 millisecond', () => {
- const str = kbn.toDuration(1, 0, 'millisecond');
- expect(str).toBe('1 millisecond');
- });
- it('-1 millisecond', () => {
- const str = kbn.toDuration(-1, 0, 'millisecond');
- expect(str).toBe('1 millisecond ago');
- });
- it('seconds', () => {
- const str = kbn.toDuration(1, 0, 'second');
- expect(str).toBe('1 second');
- });
- it('minutes', () => {
- const str = kbn.toDuration(1, 0, 'minute');
- expect(str).toBe('1 minute');
- });
- it('hours', () => {
- const str = kbn.toDuration(1, 0, 'hour');
- expect(str).toBe('1 hour');
- });
- it('days', () => {
- const str = kbn.toDuration(1, 0, 'day');
- expect(str).toBe('1 day');
- });
- it('weeks', () => {
- const str = kbn.toDuration(1, 0, 'week');
- expect(str).toBe('1 week');
- });
- it('months', () => {
- const str = kbn.toDuration(1, 0, 'month');
- expect(str).toBe('1 month');
- });
- it('years', () => {
- const str = kbn.toDuration(1, 0, 'year');
- expect(str).toBe('1 year');
- });
- it('decimal days', () => {
- const str = kbn.toDuration(1.5, 2, 'day');
- expect(str).toBe('1 day, 12 hours, 0 minutes');
- });
- it('decimal months', () => {
- const str = kbn.toDuration(1.5, 3, 'month');
- expect(str).toBe('1 month, 2 weeks, 1 day, 0 hours');
- });
- it('no decimals', () => {
- const str = kbn.toDuration(38898367008, 0, 'millisecond');
- expect(str).toBe('1 year');
- });
- it('1 decimal', () => {
- const str = kbn.toDuration(38898367008, 1, 'millisecond');
- expect(str).toBe('1 year, 2 months');
- });
- it('too many decimals', () => {
- const str = kbn.toDuration(38898367008, 20, 'millisecond');
- expect(str).toBe('1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, 7 seconds, 8 milliseconds');
- });
- it('floating point error', () => {
- const str = kbn.toDuration(36993906007, 8, 'millisecond');
- expect(str).toBe('1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds');
- });
-});
-
-describe('clock', () => {
- it('null', () => {
- const str = kbn.toClock(null, 0);
- expect(str).toBe('');
- });
- it('size less than 1 second', () => {
- const str = kbn.toClock(999, 0);
- expect(str).toBe('999ms');
- });
- describe('size less than 1 minute', () => {
- it('default', () => {
- const str = kbn.toClock(59999);
- expect(str).toBe('59s:999ms');
- });
- it('decimals equals 0', () => {
- const str = kbn.toClock(59999, 0);
- expect(str).toBe('59s');
- });
- });
- describe('size less than 1 hour', () => {
- it('default', () => {
- const str = kbn.toClock(3599999);
- expect(str).toBe('59m:59s:999ms');
- });
- it('decimals equals 0', () => {
- const str = kbn.toClock(3599999, 0);
- expect(str).toBe('59m');
- });
- it('decimals equals 1', () => {
- const str = kbn.toClock(3599999, 1);
- expect(str).toBe('59m:59s');
- });
- });
- describe('size greater than or equal 1 hour', () => {
- it('default', () => {
- const str = kbn.toClock(7199999);
- expect(str).toBe('01h:59m:59s:999ms');
- });
- it('decimals equals 0', () => {
- const str = kbn.toClock(7199999, 0);
- expect(str).toBe('01h');
- });
- it('decimals equals 1', () => {
- const str = kbn.toClock(7199999, 1);
- expect(str).toBe('01h:59m');
- });
- it('decimals equals 2', () => {
- const str = kbn.toClock(7199999, 2);
- expect(str).toBe('01h:59m:59s');
- });
- });
- describe('size greater than or equal 1 day', () => {
- it('default', () => {
- const str = kbn.toClock(89999999);
- expect(str).toBe('24h:59m:59s:999ms');
- });
- it('decimals equals 0', () => {
- const str = kbn.toClock(89999999, 0);
- expect(str).toBe('24h');
- });
- it('decimals equals 1', () => {
- const str = kbn.toClock(89999999, 1);
- expect(str).toBe('24h:59m');
- });
- it('decimals equals 2', () => {
- const str = kbn.toClock(89999999, 2);
- expect(str).toBe('24h:59m:59s');
- });
- });
-});
-
-describe('volume', () => {
- it('1000m3', () => {
- const str = kbn.valueFormats['m3'](1000, 1, null);
- expect(str).toBe('1000.0 m³');
- });
-});
-
-describe('hh:mm:ss', () => {
- it('00:04:06', () => {
- const str = kbn.valueFormats['dthms'](246, 1);
- expect(str).toBe('00:04:06');
- });
- it('24:00:00', () => {
- const str = kbn.valueFormats['dthms'](86400, 1);
- expect(str).toBe('24:00:00');
- });
- it('6824413:53:20', () => {
- const str = kbn.valueFormats['dthms'](24567890000, 1);
- expect(str).toBe('6824413:53:20');
- });
-});
diff --git a/public/app/core/specs/url.test.ts b/public/app/core/specs/url.test.ts
index b5994488128..3b7f81494f9 100644
--- a/public/app/core/specs/url.test.ts
+++ b/public/app/core/specs/url.test.ts
@@ -14,3 +14,12 @@ describe('toUrlParams', () => {
expect(url).toBe('server=backend-01&hasSpace=has%20space&many=1&many=2&many=3&true&number=20&isNull=&isUndefined=');
});
});
+
+describe('toUrlParams', () => {
+ it('should encode the same way as angularjs', () => {
+ const url = toUrlParams({
+ server: ':@',
+ });
+ expect(url).toBe('server=:@');
+ });
+});
diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts
index 76af75d0935..23a0a0c19ea 100644
--- a/public/app/core/time_series2.ts
+++ b/public/app/core/time_series2.ts
@@ -1,6 +1,7 @@
import kbn from 'app/core/utils/kbn';
import { getFlotTickDecimals } from 'app/core/utils/ticks';
import _ from 'lodash';
+import { getValueFormat } from '@grafana/ui';
function matchSeriesOverride(aliasOrRegex, seriesAlias) {
if (!aliasOrRegex) {
@@ -31,13 +32,13 @@ export function updateLegendValues(data: TimeSeries[], panel, height) {
const yaxes = panel.yaxes;
const seriesYAxis = series.yaxis || 1;
const axis = yaxes[seriesYAxis - 1];
- const formater = kbn.valueFormats[axis.format];
+ const formatter = getValueFormat(axis.format);
// decimal override
if (_.isNumber(panel.decimals)) {
- series.updateLegendValues(formater, panel.decimals, null);
+ series.updateLegendValues(formatter, panel.decimals, null);
} else if (_.isNumber(axis.decimals)) {
- series.updateLegendValues(formater, axis.decimals + 1, null);
+ series.updateLegendValues(formatter, axis.decimals + 1, null);
} else {
// auto decimals
// legend and tooltip gets one more decimal precision
@@ -45,7 +46,7 @@ export function updateLegendValues(data: TimeSeries[], panel, height) {
const { datamin, datamax } = getDataMinMax(data);
const { tickDecimals, scaledDecimals } = getFlotTickDecimals(datamin, datamax, axis, height);
const tickDecimalsPlusOne = (tickDecimals || -1) + 1;
- series.updateLegendValues(formater, tickDecimalsPlusOne, scaledDecimals + 2);
+ series.updateLegendValues(formatter, tickDecimalsPlusOne, scaledDecimals + 2);
}
}
}
@@ -105,7 +106,7 @@ export default class TimeSeries {
this.aliasEscaped = _.escape(opts.alias);
this.color = opts.color;
this.bars = { fillColor: opts.color };
- this.valueFormater = kbn.valueFormats.none;
+ this.valueFormater = getValueFormat('none');
this.stats = {};
this.legend = true;
this.unit = opts.unit;
@@ -356,7 +357,7 @@ export default class TimeSeries {
return false;
}
- setColor(color) {
+ setColor(color: string) {
this.color = color;
this.bars.fillColor = color;
}
diff --git a/public/app/core/utils/CancelablePromise.ts b/public/app/core/utils/CancelablePromise.ts
new file mode 100644
index 00000000000..e8c3ec07a28
--- /dev/null
+++ b/public/app/core/utils/CancelablePromise.ts
@@ -0,0 +1,22 @@
+// https://github.com/facebook/react/issues/5465
+
+export interface CancelablePromise {
+ promise: Promise;
+ cancel: () => void;
+}
+
+export const makePromiseCancelable = (promise: Promise): CancelablePromise => {
+ let hasCanceled_ = false;
+
+ const wrappedPromise = new Promise((resolve, reject) => {
+ promise.then(val => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val)));
+ promise.catch(error => (hasCanceled_ ? reject({ isCanceled: true }) : reject(error)));
+ });
+
+ return {
+ promise: wrappedPromise,
+ cancel() {
+ hasCanceled_ = true;
+ },
+ };
+};
diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx
new file mode 100644
index 00000000000..6883401ad27
--- /dev/null
+++ b/public/app/core/utils/ConfigProvider.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import config, { Settings } from 'app/core/config';
+import { GrafanaTheme } from '@grafana/ui';
+
+export const ConfigContext = React.createContext(config);
+export const ConfigConsumer = ConfigContext.Consumer;
+
+export const provideConfig = (component: React.ComponentType) => {
+ const ConfigProvider = (props: any) => (
+ {React.createElement(component, { ...props })}
+ );
+
+ return ConfigProvider;
+};
+
+interface ThemeProviderProps {
+ children: (theme: GrafanaTheme) => JSX.Element;
+}
+
+export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+ return (
+
+ {({ bootData }) => {
+ return children(bootData.user.lightTheme ? GrafanaTheme.Light : GrafanaTheme.Dark);
+ }}
+
+ );
+};
diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts
index 34508e94a9f..6d73ab9fbd8 100644
--- a/public/app/core/utils/colors.ts
+++ b/public/app/core/utils/colors.ts
@@ -1,99 +1,5 @@
-import _ from 'lodash';
-import tinycolor from 'tinycolor2';
import config from 'app/core/config';
-export const PALETTE_ROWS = 4;
-export const PALETTE_COLUMNS = 14;
-export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)';
-export const OK_COLOR = 'rgba(11, 237, 50, 1)';
-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;
-
-const colors = [
- '#7EB26D', // 0: pale green
- '#EAB839', // 1: mustard
- '#6ED0E0', // 2: light blue
- '#EF843C', // 3: orange
- '#E24D42', // 4: red
- '#1F78C1', // 5: ocean
- '#BA43A9', // 6: purple
- '#705DA0', // 7: violet
- '#508642', // 8: dark green
- '#CCA300', // 9: dark sand
- '#447EBC',
- '#C15C17',
- '#890F02',
- '#0A437C',
- '#6D1F62',
- '#584477',
- '#B7DBAB',
- '#F4D598',
- '#70DBED',
- '#F9BA8F',
- '#F29191',
- '#82B5D8',
- '#E5A8E2',
- '#AEA2E0',
- '#629E51',
- '#E5AC0E',
- '#64B0C8',
- '#E0752D',
- '#BF1B00',
- '#0A50A1',
- '#962D82',
- '#614D93',
- '#9AC48A',
- '#F2C96D',
- '#65C5DB',
- '#F9934E',
- '#EA6460',
- '#5195CE',
- '#D683CE',
- '#806EB7',
- '#3F6833',
- '#967302',
- '#2F575E',
- '#99440A',
- '#58140C',
- '#052B51',
- '#511749',
- '#3F2B5B',
- '#E0F9D7',
- '#FCEACA',
- '#CFFAFF',
- '#F9E2D2',
- '#FCE2DE',
- '#BADFF4',
- '#F9D9F9',
- '#DEDAF7',
-];
-
-export function sortColorsByHue(hexColors) {
- const hslColors = _.map(hexColors, hexToHsl);
-
- let sortedHSLColors = _.sortBy(hslColors, ['h']);
- sortedHSLColors = _.chunk(sortedHSLColors, PALETTE_ROWS);
- sortedHSLColors = _.map(sortedHSLColors, chunk => {
- return _.sortBy(chunk, 'l');
- });
- sortedHSLColors = _.flattenDeep(_.zip(...sortedHSLColors));
-
- return _.map(sortedHSLColors, hslToHex);
-}
-
-export function hexToHsl(color) {
- return tinycolor(color).toHsl();
-}
-
-export function hslToHex(color) {
- return tinycolor(color).toHexString();
-}
-
export function getThemeColor(dark: string, light: string): string {
return config.bootData.user.lightTheme ? light : dark;
}
-
-export let sortedColors = sortColorsByHue(colors);
-export default colors;
diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts
index a3b08516d16..32135eab90a 100644
--- a/public/app/core/utils/explore.test.ts
+++ b/public/app/core/utils/explore.test.ts
@@ -6,26 +6,13 @@ import {
clearHistory,
hasNonEmptyQuery,
} from './explore';
-import { ExploreState } from 'app/types/explore';
+import { ExploreUrlState } from 'app/types/explore';
import store from 'app/core/store';
-const DEFAULT_EXPLORE_STATE: ExploreState = {
+const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
datasource: null,
- datasourceError: null,
- datasourceLoading: null,
- datasourceMissing: false,
- exploreDatasources: [],
- graphInterval: 1000,
- history: [],
- initialQueries: [],
- queryTransactions: [],
+ queries: [],
range: DEFAULT_RANGE,
- showingGraph: true,
- showingLogs: true,
- showingTable: true,
- supportsGraph: null,
- supportsLogs: null,
- supportsTable: null,
};
describe('state functions', () => {
@@ -68,21 +55,19 @@ describe('state functions', () => {
it('returns url parameter value for a state object', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
- initialDatasource: 'foo',
+ datasource: 'foo',
+ queries: [
+ {
+ expr: 'metric{test="a/b"}',
+ },
+ {
+ expr: 'super{foo="x/z"}',
+ },
+ ],
range: {
from: 'now-5h',
to: 'now',
},
- initialQueries: [
- {
- refId: '1',
- expr: 'metric{test="a/b"}',
- },
- {
- refId: '2',
- expr: 'super{foo="x/z"}',
- },
- ],
};
expect(serializeStateToUrlParam(state)).toBe(
'{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
@@ -93,21 +78,19 @@ describe('state functions', () => {
it('returns url parameter value for a state object', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
- initialDatasource: 'foo',
+ datasource: 'foo',
+ queries: [
+ {
+ expr: 'metric{test="a/b"}',
+ },
+ {
+ expr: 'super{foo="x/z"}',
+ },
+ ],
range: {
from: 'now-5h',
to: 'now',
},
- initialQueries: [
- {
- refId: '1',
- expr: 'metric{test="a/b"}',
- },
- {
- refId: '2',
- expr: 'super{foo="x/z"}',
- },
- ],
};
expect(serializeStateToUrlParam(state, true)).toBe(
'["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
@@ -119,35 +102,24 @@ describe('state functions', () => {
it('can parse the serialized state into the original state', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
- initialDatasource: 'foo',
+ datasource: 'foo',
+ queries: [
+ {
+ expr: 'metric{test="a/b"}',
+ },
+ {
+ expr: 'super{foo="x/z"}',
+ },
+ ],
range: {
from: 'now - 5h',
to: 'now',
},
- initialQueries: [
- {
- refId: '1',
- expr: 'metric{test="a/b"}',
- },
- {
- refId: '2',
- expr: 'super{foo="x/z"}',
- },
- ],
};
const serialized = serializeStateToUrlParam(state);
const parsed = parseUrlState(serialized);
- // Account for datasource vs datasourceName
- const { datasource, queries, ...rest } = parsed;
- const resultState = {
- ...rest,
- datasource: DEFAULT_EXPLORE_STATE.datasource,
- initialDatasource: datasource,
- initialQueries: queries,
- };
-
- expect(state).toMatchObject(resultState);
+ expect(state).toMatchObject(parsed);
});
});
});
diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts
index bea166075dc..7a9f54a0cae 100644
--- a/public/app/core/utils/explore.ts
+++ b/public/app/core/utils/explore.ts
@@ -1,16 +1,26 @@
+// Libraries
import _ from 'lodash';
+// Services & Utils
+import * as dateMath from 'app/core/utils/datemath';
import { renderUrl } from 'app/core/utils/url';
import kbn from 'app/core/utils/kbn';
import store from 'app/core/store';
-import colors from 'app/core/utils/colors';
import { parse as parseDate } from 'app/core/utils/datemath';
-
-import TimeSeries from 'app/core/time_series2';
+import { colors } from '@grafana/ui';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
-import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
-import { DataQuery, DataSourceApi } from 'app/types/series';
-import { RawTimeRange, IntervalValues } from '@grafana/ui';
+
+// Types
+import { RawTimeRange, IntervalValues, DataQuery } from '@grafana/ui/src/types';
+import TimeSeries from 'app/core/time_series2';
+import {
+ ExploreUrlState,
+ HistoryItem,
+ QueryTransaction,
+ ResultType,
+ QueryIntervals,
+ QueryOptions,
+} from 'app/types/explore';
export const DEFAULT_RANGE = {
from: 'now-6h',
@@ -19,6 +29,8 @@ export const DEFAULT_RANGE = {
const MAX_HISTORY_ITEMS = 100;
+export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
+
/**
* Returns an Explore-URL that contains a panel's queries and the dashboard time range.
*
@@ -72,12 +84,68 @@ export async function getExploreUrl(
}
const exploreState = JSON.stringify(state);
- url = renderUrl('/explore', { state: exploreState });
+ url = renderUrl('/explore', { left: exploreState });
}
return url;
}
-const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
+export function buildQueryTransaction(
+ query: DataQuery,
+ rowIndex: number,
+ resultType: ResultType,
+ queryOptions: QueryOptions,
+ range: RawTimeRange,
+ queryIntervals: QueryIntervals,
+ scanning: boolean
+): QueryTransaction {
+ const { interval, intervalMs } = queryIntervals;
+
+ const configuredQueries = [
+ {
+ ...query,
+ ...queryOptions,
+ },
+ ];
+
+ // Clone range for query request
+ // const queryRange: RawTimeRange = { ...range };
+ // const { from, to, raw } = this.timeSrv.timeRange();
+ // Most datasource is using `panelId + query.refId` for cancellation logic.
+ // Using `format` here because it relates to the view panel that the request is for.
+ // However, some datasources don't use `panelId + query.refId`, but only `panelId`.
+ // Therefore panel id has to be unique.
+ const panelId = `${queryOptions.format}-${query.key}`;
+
+ const options = {
+ interval,
+ intervalMs,
+ panelId,
+ targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
+ range: {
+ from: dateMath.parse(range.from, false),
+ to: dateMath.parse(range.to, true),
+ raw: range,
+ },
+ rangeRaw: range,
+ scopedVars: {
+ __interval: { text: interval, value: interval },
+ __interval_ms: { text: intervalMs, value: intervalMs },
+ },
+ };
+
+ return {
+ options,
+ query,
+ resultType,
+ rowIndex,
+ scanning,
+ id: generateKey(), // reusing for unique ID
+ done: false,
+ latency: 0,
+ };
+}
+
+export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
export function parseUrlState(initial: string | undefined): ExploreUrlState {
if (initial) {
@@ -103,12 +171,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
return { datasource: null, queries: [], range: DEFAULT_RANGE };
}
-export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
- const urlState: ExploreUrlState = {
- datasource: state.initialDatasource,
- queries: state.initialQueries.map(clearQueryKeys),
- range: state.range,
- };
+export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
if (compact) {
return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
}
@@ -123,7 +186,7 @@ export function generateRefId(index = 0): string {
return `${index + 1}`;
}
-export function generateQueryKeys(index = 0): { refId: string; key: string } {
+export function generateEmptyQuery(index = 0): { refId: string; key: string } {
return { refId: generateRefId(index), key: generateKey(index) };
}
@@ -132,20 +195,23 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } {
*/
export function ensureQueries(queries?: DataQuery[]): DataQuery[] {
if (queries && typeof queries === 'object' && queries.length > 0) {
- return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) }));
+ return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) }));
}
- return [{ ...generateQueryKeys() }];
+ return [{ ...generateEmptyQuery() }];
}
/**
* A target is non-empty when it has keys (with non-empty values) other than refId and key.
*/
-export function hasNonEmptyQuery(queries: DataQuery[]): boolean {
- return queries.some(
- query =>
- Object.keys(query)
- .map(k => query[k])
- .filter(v => v).length > 2
+export function hasNonEmptyQuery(queries: TQuery[]): boolean {
+ return (
+ queries &&
+ queries.some(
+ query =>
+ Object.keys(query)
+ .map(k => query[k])
+ .filter(v => v).length > 2
+ )
);
}
@@ -180,8 +246,8 @@ export function calculateResultsFromQueryTransactions(
};
}
-export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues {
- if (!datasource || !resolution) {
+export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues {
+ if (!resolution) {
return { interval: '1s', intervalMs: 1000 };
}
@@ -190,7 +256,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res
to: parseDate(range.to, true),
};
- return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
+ return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
}
export function makeTimeSeriesList(dataList) {
@@ -214,7 +280,11 @@ export function makeTimeSeriesList(dataList) {
/**
* Update the query history. Side-effect: store history in local storage
*/
-export function updateHistory(history: HistoryItem[], datasourceId: string, queries: DataQuery[]): HistoryItem[] {
+export function updateHistory(
+ history: Array>,
+ datasourceId: string,
+ queries: T[]
+): Array> {
const ts = Date.now();
queries.forEach(query => {
history = [{ query, ts }, ...history];
diff --git a/public/app/core/utils/factors.ts b/public/app/core/utils/factors.ts
new file mode 100644
index 00000000000..e9ce327a631
--- /dev/null
+++ b/public/app/core/utils/factors.ts
@@ -0,0 +1,5 @@
+// Returns the factors of a number
+// Example getFactors(12) -> [1, 2, 3, 4, 6, 12]
+export default function getFactors(num: number): number[] {
+ return Array.from(new Array(num + 1), (_, i) => i).filter(i => num % i === 0);
+}
diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts
index d32844c44ed..a3a96f8afc3 100644
--- a/public/app/core/utils/kbn.ts
+++ b/public/app/core/utils/kbn.ts
@@ -1,5 +1,5 @@
import _ from 'lodash';
-import moment from 'moment';
+import { getValueFormat, getValueFormatterIndex, getValueFormats } from '@grafana/ui';
const kbn: any = {};
@@ -280,942 +280,33 @@ kbn.roundValue = (num, decimals) => {
return Math.round(parseFloat(formatted)) / n;
};
-///// FORMAT FUNCTION CONSTRUCTORS /////
-
-kbn.formatBuilders = {};
-
-// Formatter which always appends a fixed unit string to the value. No
-// scaling of the value is performed.
-kbn.formatBuilders.fixedUnit = unit => {
- return (size, decimals) => {
- if (size === null) {
- return '';
- }
- return kbn.toFixed(size, decimals) + ' ' + unit;
- };
-};
-
-// Formatter which scales the unit string geometrically according to the given
-// numeric factor. Repeatedly scales the value down by the factor until it is
-// less than the factor in magnitude, or the end of the array is reached.
-kbn.formatBuilders.scaledUnits = (factor, extArray) => {
- return (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- let steps = 0;
- const limit = extArray.length;
-
- while (Math.abs(size) >= factor) {
- steps++;
- size /= factor;
-
- if (steps >= limit) {
- return 'NA';
- }
- }
-
- if (steps > 0 && scaledDecimals !== null) {
- decimals = scaledDecimals + 3 * steps;
- }
-
- return kbn.toFixed(size, decimals) + extArray[steps];
- };
-};
-
-// Extension of the scaledUnits builder which uses SI decimal prefixes. If an
-// offset is given, it adjusts the starting units at the given prefix; a value
-// of 0 starts at no scale; -3 drops to nano, +2 starts at mega, etc.
-kbn.formatBuilders.decimalSIPrefix = (unit, offset) => {
- let prefixes = ['n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
- prefixes = prefixes.slice(3 + (offset || 0));
- const units = prefixes.map(p => {
- return ' ' + p + unit;
- });
- return kbn.formatBuilders.scaledUnits(1000, units);
-};
-
-// Extension of the scaledUnits builder which uses SI binary prefixes. If
-// offset is given, it starts the units at the given prefix; otherwise, the
-// offset defaults to zero and the initial unit is not prefixed.
-kbn.formatBuilders.binarySIPrefix = (unit, offset) => {
- const prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'].slice(offset);
- const units = prefixes.map(p => {
- return ' ' + p + unit;
- });
- return kbn.formatBuilders.scaledUnits(1024, units);
-};
-
-// Currency formatter for prefixing a symbol onto a number. Supports scaling
-// up to the trillions.
-kbn.formatBuilders.currency = symbol => {
- const units = ['', 'K', 'M', 'B', 'T'];
- const scaler = kbn.formatBuilders.scaledUnits(1000, units);
- return (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
- const scaled = scaler(size, decimals, scaledDecimals);
- return symbol + scaled;
- };
-};
-
-kbn.formatBuilders.simpleCountUnit = symbol => {
- const units = ['', 'K', 'M', 'B', 'T'];
- const scaler = kbn.formatBuilders.scaledUnits(1000, units);
- return (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
- const scaled = scaler(size, decimals, scaledDecimals);
- return scaled + ' ' + symbol;
- };
-};
-
-///// VALUE FORMATS /////
-
-// Dimensionless Units
-kbn.valueFormats.none = kbn.toFixed;
-kbn.valueFormats.short = kbn.formatBuilders.scaledUnits(1000, [
- '',
- ' K',
- ' Mil',
- ' Bil',
- ' Tri',
- ' Quadr',
- ' Quint',
- ' Sext',
- ' Sept',
-]);
-kbn.valueFormats.dB = kbn.formatBuilders.fixedUnit('dB');
-
-kbn.valueFormats.percent = (size, decimals) => {
- if (size === null) {
- return '';
- }
- return kbn.toFixed(size, decimals) + '%';
-};
-
-kbn.valueFormats.percentunit = (size, decimals) => {
- if (size === null) {
- return '';
- }
- return kbn.toFixed(100 * size, decimals) + '%';
-};
-
-/* Formats the value to hex. Uses float if specified decimals are not 0.
- * There are two submenu
- * , one with 0x, and one without */
-
-kbn.valueFormats.hex = (value, decimals) => {
- if (value == null) {
- return '';
- }
- return parseFloat(kbn.toFixed(value, decimals))
- .toString(16)
- .toUpperCase();
-};
-
-kbn.valueFormats.hex0x = (value, decimals) => {
- if (value == null) {
- return '';
- }
- const hexString = kbn.valueFormats.hex(value, decimals);
- if (hexString.substring(0, 1) === '-') {
- return '-0x' + hexString.substring(1);
- }
- return '0x' + hexString;
-};
-
-kbn.valueFormats.sci = (value, decimals) => {
- if (value == null) {
- return '';
- }
- return value.toExponential(decimals);
-};
-
-kbn.valueFormats.locale = (value, decimals) => {
- if (value == null) {
- return '';
- }
- return value.toLocaleString(undefined, { maximumFractionDigits: decimals });
-};
-
-// Currencies
-kbn.valueFormats.currencyUSD = kbn.formatBuilders.currency('$');
-kbn.valueFormats.currencyGBP = kbn.formatBuilders.currency('£');
-kbn.valueFormats.currencyEUR = kbn.formatBuilders.currency('€');
-kbn.valueFormats.currencyJPY = kbn.formatBuilders.currency('¥');
-kbn.valueFormats.currencyRUB = kbn.formatBuilders.currency('₽');
-kbn.valueFormats.currencyUAH = kbn.formatBuilders.currency('₴');
-kbn.valueFormats.currencyBRL = kbn.formatBuilders.currency('R$');
-kbn.valueFormats.currencyDKK = kbn.formatBuilders.currency('kr');
-kbn.valueFormats.currencyISK = kbn.formatBuilders.currency('kr');
-kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr');
-kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr');
-kbn.valueFormats.currencyCZK = kbn.formatBuilders.currency('czk');
-kbn.valueFormats.currencyCHF = kbn.formatBuilders.currency('CHF');
-kbn.valueFormats.currencyPLN = kbn.formatBuilders.currency('zł');
-kbn.valueFormats.currencyBTC = kbn.formatBuilders.currency('฿');
-
-// Data (Binary)
-kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b');
-kbn.valueFormats.bytes = kbn.formatBuilders.binarySIPrefix('B');
-kbn.valueFormats.kbytes = kbn.formatBuilders.binarySIPrefix('B', 1);
-kbn.valueFormats.mbytes = kbn.formatBuilders.binarySIPrefix('B', 2);
-kbn.valueFormats.gbytes = kbn.formatBuilders.binarySIPrefix('B', 3);
-
-// Data (Decimal)
-kbn.valueFormats.decbits = kbn.formatBuilders.decimalSIPrefix('b');
-kbn.valueFormats.decbytes = kbn.formatBuilders.decimalSIPrefix('B');
-kbn.valueFormats.deckbytes = kbn.formatBuilders.decimalSIPrefix('B', 1);
-kbn.valueFormats.decmbytes = kbn.formatBuilders.decimalSIPrefix('B', 2);
-kbn.valueFormats.decgbytes = kbn.formatBuilders.decimalSIPrefix('B', 3);
-
-// Data Rate
-kbn.valueFormats.pps = kbn.formatBuilders.decimalSIPrefix('pps');
-kbn.valueFormats.bps = kbn.formatBuilders.decimalSIPrefix('bps');
-kbn.valueFormats.Bps = kbn.formatBuilders.decimalSIPrefix('B/s');
-kbn.valueFormats.KBs = kbn.formatBuilders.decimalSIPrefix('Bs', 1);
-kbn.valueFormats.Kbits = kbn.formatBuilders.decimalSIPrefix('bps', 1);
-kbn.valueFormats.MBs = kbn.formatBuilders.decimalSIPrefix('Bs', 2);
-kbn.valueFormats.Mbits = kbn.formatBuilders.decimalSIPrefix('bps', 2);
-kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
-kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
-
-// Floating Point Operations per Second
-kbn.valueFormats.flops = kbn.formatBuilders.decimalSIPrefix('FLOP/s');
-kbn.valueFormats.mflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 2);
-kbn.valueFormats.gflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 3);
-kbn.valueFormats.tflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 4);
-kbn.valueFormats.pflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 5);
-kbn.valueFormats.eflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 6);
-
-// Hash Rate
-kbn.valueFormats.Hs = kbn.formatBuilders.decimalSIPrefix('H/s');
-kbn.valueFormats.KHs = kbn.formatBuilders.decimalSIPrefix('H/s', 1);
-kbn.valueFormats.MHs = kbn.formatBuilders.decimalSIPrefix('H/s', 2);
-kbn.valueFormats.GHs = kbn.formatBuilders.decimalSIPrefix('H/s', 3);
-kbn.valueFormats.THs = kbn.formatBuilders.decimalSIPrefix('H/s', 4);
-kbn.valueFormats.PHs = kbn.formatBuilders.decimalSIPrefix('H/s', 5);
-kbn.valueFormats.EHs = kbn.formatBuilders.decimalSIPrefix('H/s', 6);
-
-// Throughput
-kbn.valueFormats.ops = kbn.formatBuilders.simpleCountUnit('ops');
-kbn.valueFormats.reqps = kbn.formatBuilders.simpleCountUnit('reqps');
-kbn.valueFormats.rps = kbn.formatBuilders.simpleCountUnit('rps');
-kbn.valueFormats.wps = kbn.formatBuilders.simpleCountUnit('wps');
-kbn.valueFormats.iops = kbn.formatBuilders.simpleCountUnit('iops');
-kbn.valueFormats.opm = kbn.formatBuilders.simpleCountUnit('opm');
-kbn.valueFormats.rpm = kbn.formatBuilders.simpleCountUnit('rpm');
-kbn.valueFormats.wpm = kbn.formatBuilders.simpleCountUnit('wpm');
-
-// Energy
-kbn.valueFormats.watt = kbn.formatBuilders.decimalSIPrefix('W');
-kbn.valueFormats.kwatt = kbn.formatBuilders.decimalSIPrefix('W', 1);
-kbn.valueFormats.mwatt = kbn.formatBuilders.decimalSIPrefix('W', -1);
-kbn.valueFormats.kwattm = kbn.formatBuilders.decimalSIPrefix('W/Min', 1);
-kbn.valueFormats.Wm2 = kbn.formatBuilders.fixedUnit('W/m²');
-kbn.valueFormats.voltamp = kbn.formatBuilders.decimalSIPrefix('VA');
-kbn.valueFormats.kvoltamp = kbn.formatBuilders.decimalSIPrefix('VA', 1);
-kbn.valueFormats.voltampreact = kbn.formatBuilders.decimalSIPrefix('var');
-kbn.valueFormats.kvoltampreact = kbn.formatBuilders.decimalSIPrefix('var', 1);
-kbn.valueFormats.watth = kbn.formatBuilders.decimalSIPrefix('Wh');
-kbn.valueFormats.kwatth = kbn.formatBuilders.decimalSIPrefix('Wh', 1);
-kbn.valueFormats.joule = kbn.formatBuilders.decimalSIPrefix('J');
-kbn.valueFormats.ev = kbn.formatBuilders.decimalSIPrefix('eV');
-kbn.valueFormats.amp = kbn.formatBuilders.decimalSIPrefix('A');
-kbn.valueFormats.kamp = kbn.formatBuilders.decimalSIPrefix('A', 1);
-kbn.valueFormats.mamp = kbn.formatBuilders.decimalSIPrefix('A', -1);
-kbn.valueFormats.volt = kbn.formatBuilders.decimalSIPrefix('V');
-kbn.valueFormats.kvolt = kbn.formatBuilders.decimalSIPrefix('V', 1);
-kbn.valueFormats.mvolt = kbn.formatBuilders.decimalSIPrefix('V', -1);
-kbn.valueFormats.dBm = kbn.formatBuilders.decimalSIPrefix('dBm');
-kbn.valueFormats.ohm = kbn.formatBuilders.decimalSIPrefix('Ω');
-kbn.valueFormats.lumens = kbn.formatBuilders.decimalSIPrefix('Lm');
-
-// Temperature
-kbn.valueFormats.celsius = kbn.formatBuilders.fixedUnit('°C');
-kbn.valueFormats.farenheit = kbn.formatBuilders.fixedUnit('°F');
-kbn.valueFormats.kelvin = kbn.formatBuilders.fixedUnit('K');
-kbn.valueFormats.humidity = kbn.formatBuilders.fixedUnit('%H');
-
-// Pressure
-kbn.valueFormats.pressurebar = kbn.formatBuilders.decimalSIPrefix('bar');
-kbn.valueFormats.pressurembar = kbn.formatBuilders.decimalSIPrefix('bar', -1);
-kbn.valueFormats.pressurekbar = kbn.formatBuilders.decimalSIPrefix('bar', 1);
-kbn.valueFormats.pressurehpa = kbn.formatBuilders.fixedUnit('hPa');
-kbn.valueFormats.pressurekpa = kbn.formatBuilders.fixedUnit('kPa');
-kbn.valueFormats.pressurehg = kbn.formatBuilders.fixedUnit('"Hg');
-kbn.valueFormats.pressurepsi = kbn.formatBuilders.scaledUnits(1000, [' psi', ' ksi', ' Mpsi']);
-
-// Force
-kbn.valueFormats.forceNm = kbn.formatBuilders.decimalSIPrefix('Nm');
-kbn.valueFormats.forcekNm = kbn.formatBuilders.decimalSIPrefix('Nm', 1);
-kbn.valueFormats.forceN = kbn.formatBuilders.decimalSIPrefix('N');
-kbn.valueFormats.forcekN = kbn.formatBuilders.decimalSIPrefix('N', 1);
-
-// Length
-kbn.valueFormats.lengthm = kbn.formatBuilders.decimalSIPrefix('m');
-kbn.valueFormats.lengthmm = kbn.formatBuilders.decimalSIPrefix('m', -1);
-kbn.valueFormats.lengthkm = kbn.formatBuilders.decimalSIPrefix('m', 1);
-kbn.valueFormats.lengthmi = kbn.formatBuilders.fixedUnit('mi');
-kbn.valueFormats.lengthft = kbn.formatBuilders.fixedUnit('ft');
-
-// Area
-kbn.valueFormats.areaM2 = kbn.formatBuilders.fixedUnit('m²');
-kbn.valueFormats.areaF2 = kbn.formatBuilders.fixedUnit('ft²');
-kbn.valueFormats.areaMI2 = kbn.formatBuilders.fixedUnit('mi²');
-
-// Mass
-kbn.valueFormats.massmg = kbn.formatBuilders.decimalSIPrefix('g', -1);
-kbn.valueFormats.massg = kbn.formatBuilders.decimalSIPrefix('g');
-kbn.valueFormats.masskg = kbn.formatBuilders.decimalSIPrefix('g', 1);
-kbn.valueFormats.masst = kbn.formatBuilders.fixedUnit('t');
-
-// Velocity
-kbn.valueFormats.velocityms = kbn.formatBuilders.fixedUnit('m/s');
-kbn.valueFormats.velocitykmh = kbn.formatBuilders.fixedUnit('km/h');
-kbn.valueFormats.velocitymph = kbn.formatBuilders.fixedUnit('mph');
-kbn.valueFormats.velocityknot = kbn.formatBuilders.fixedUnit('kn');
-
-// Acceleration
-kbn.valueFormats.accMS2 = kbn.formatBuilders.fixedUnit('m/sec²');
-kbn.valueFormats.accFS2 = kbn.formatBuilders.fixedUnit('f/sec²');
-kbn.valueFormats.accG = kbn.formatBuilders.fixedUnit('g');
-
-// Volume
-kbn.valueFormats.litre = kbn.formatBuilders.decimalSIPrefix('L');
-kbn.valueFormats.mlitre = kbn.formatBuilders.decimalSIPrefix('L', -1);
-kbn.valueFormats.m3 = kbn.formatBuilders.fixedUnit('m³');
-kbn.valueFormats.Nm3 = kbn.formatBuilders.fixedUnit('Nm³');
-kbn.valueFormats.dm3 = kbn.formatBuilders.fixedUnit('dm³');
-kbn.valueFormats.gallons = kbn.formatBuilders.fixedUnit('gal');
-
-// Flow
-kbn.valueFormats.flowgpm = kbn.formatBuilders.fixedUnit('gpm');
-kbn.valueFormats.flowcms = kbn.formatBuilders.fixedUnit('cms');
-kbn.valueFormats.flowcfs = kbn.formatBuilders.fixedUnit('cfs');
-kbn.valueFormats.flowcfm = kbn.formatBuilders.fixedUnit('cfm');
-kbn.valueFormats.litreh = kbn.formatBuilders.fixedUnit('l/h');
-kbn.valueFormats.flowlpm = kbn.formatBuilders.fixedUnit('l/min');
-kbn.valueFormats.flowmlpm = kbn.formatBuilders.fixedUnit('mL/min');
-
-// Angle
-kbn.valueFormats.degree = kbn.formatBuilders.fixedUnit('°');
-kbn.valueFormats.radian = kbn.formatBuilders.fixedUnit('rad');
-kbn.valueFormats.grad = kbn.formatBuilders.fixedUnit('grad');
-
-// Radiation
-kbn.valueFormats.radbq = kbn.formatBuilders.decimalSIPrefix('Bq');
-kbn.valueFormats.radci = kbn.formatBuilders.decimalSIPrefix('Ci');
-kbn.valueFormats.radgy = kbn.formatBuilders.decimalSIPrefix('Gy');
-kbn.valueFormats.radrad = kbn.formatBuilders.decimalSIPrefix('rad');
-kbn.valueFormats.radsv = kbn.formatBuilders.decimalSIPrefix('Sv');
-kbn.valueFormats.radrem = kbn.formatBuilders.decimalSIPrefix('rem');
-kbn.valueFormats.radexpckg = kbn.formatBuilders.decimalSIPrefix('C/kg');
-kbn.valueFormats.radr = kbn.formatBuilders.decimalSIPrefix('R');
-kbn.valueFormats.radsvh = kbn.formatBuilders.decimalSIPrefix('Sv/h');
-
-// Concentration
-kbn.valueFormats.ppm = kbn.formatBuilders.fixedUnit('ppm');
-kbn.valueFormats.conppb = kbn.formatBuilders.fixedUnit('ppb');
-kbn.valueFormats.conngm3 = kbn.formatBuilders.fixedUnit('ng/m³');
-kbn.valueFormats.conngNm3 = kbn.formatBuilders.fixedUnit('ng/Nm³');
-kbn.valueFormats.conμgm3 = kbn.formatBuilders.fixedUnit('μg/m³');
-kbn.valueFormats.conμgNm3 = kbn.formatBuilders.fixedUnit('μg/Nm³');
-kbn.valueFormats.conmgm3 = kbn.formatBuilders.fixedUnit('mg/m³');
-kbn.valueFormats.conmgNm3 = kbn.formatBuilders.fixedUnit('mg/Nm³');
-kbn.valueFormats.congm3 = kbn.formatBuilders.fixedUnit('g/m³');
-kbn.valueFormats.congNm3 = kbn.formatBuilders.fixedUnit('g/Nm³');
-kbn.valueFormats.conmgdL = kbn.formatBuilders.fixedUnit('mg/dL');
-kbn.valueFormats.conmmolL = kbn.formatBuilders.fixedUnit('mmol/L');
-
-// Time
-kbn.valueFormats.hertz = kbn.formatBuilders.decimalSIPrefix('Hz');
-
-kbn.valueFormats.ms = (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- if (Math.abs(size) < 1000) {
- return kbn.toFixed(size, decimals) + ' ms';
- } else if (Math.abs(size) < 60000) {
- // Less than 1 min
- return kbn.toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' s');
- } else if (Math.abs(size) < 3600000) {
- // Less than 1 hour, divide in minutes
- return kbn.toFixedScaled(size / 60000, decimals, scaledDecimals, 5, ' min');
- } else if (Math.abs(size) < 86400000) {
- // Less than one day, divide in hours
- return kbn.toFixedScaled(size / 3600000, decimals, scaledDecimals, 7, ' hour');
- } else if (Math.abs(size) < 31536000000) {
- // Less than one year, divide in days
- return kbn.toFixedScaled(size / 86400000, decimals, scaledDecimals, 8, ' day');
- }
-
- return kbn.toFixedScaled(size / 31536000000, decimals, scaledDecimals, 10, ' year');
-};
-
-kbn.valueFormats.s = (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- // Less than 1 µs, divide in ns
- if (Math.abs(size) < 0.000001) {
- return kbn.toFixedScaled(size * 1e9, decimals, scaledDecimals - decimals, -9, ' ns');
- }
- // Less than 1 ms, divide in µs
- if (Math.abs(size) < 0.001) {
- return kbn.toFixedScaled(size * 1e6, decimals, scaledDecimals - decimals, -6, ' µs');
- }
- // Less than 1 second, divide in ms
- if (Math.abs(size) < 1) {
- return kbn.toFixedScaled(size * 1e3, decimals, scaledDecimals - decimals, -3, ' ms');
- }
-
- if (Math.abs(size) < 60) {
- return kbn.toFixed(size, decimals) + ' s';
- } else if (Math.abs(size) < 3600) {
- // Less than 1 hour, divide in minutes
- return kbn.toFixedScaled(size / 60, decimals, scaledDecimals, 1, ' min');
- } else if (Math.abs(size) < 86400) {
- // Less than one day, divide in hours
- return kbn.toFixedScaled(size / 3600, decimals, scaledDecimals, 4, ' hour');
- } else if (Math.abs(size) < 604800) {
- // Less than one week, divide in days
- return kbn.toFixedScaled(size / 86400, decimals, scaledDecimals, 5, ' day');
- } else if (Math.abs(size) < 31536000) {
- // Less than one year, divide in week
- return kbn.toFixedScaled(size / 604800, decimals, scaledDecimals, 6, ' week');
- }
-
- return kbn.toFixedScaled(size / 3.15569e7, decimals, scaledDecimals, 7, ' year');
-};
-
-kbn.valueFormats['µs'] = (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- if (Math.abs(size) < 1000) {
- return kbn.toFixed(size, decimals) + ' µs';
- } else if (Math.abs(size) < 1000000) {
- return kbn.toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' ms');
- } else {
- return kbn.toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' s');
- }
-};
-
-kbn.valueFormats.ns = (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- if (Math.abs(size) < 1000) {
- return kbn.toFixed(size, decimals) + ' ns';
- } else if (Math.abs(size) < 1000000) {
- return kbn.toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' µs');
- } else if (Math.abs(size) < 1000000000) {
- return kbn.toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' ms');
- } else if (Math.abs(size) < 60000000000) {
- return kbn.toFixedScaled(size / 1000000000, decimals, scaledDecimals, 9, ' s');
- } else {
- return kbn.toFixedScaled(size / 60000000000, decimals, scaledDecimals, 12, ' min');
- }
-};
-
-kbn.valueFormats.m = (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- if (Math.abs(size) < 60) {
- return kbn.toFixed(size, decimals) + ' min';
- } else if (Math.abs(size) < 1440) {
- return kbn.toFixedScaled(size / 60, decimals, scaledDecimals, 2, ' hour');
- } else if (Math.abs(size) < 10080) {
- return kbn.toFixedScaled(size / 1440, decimals, scaledDecimals, 3, ' day');
- } else if (Math.abs(size) < 604800) {
- return kbn.toFixedScaled(size / 10080, decimals, scaledDecimals, 4, ' week');
- } else {
- return kbn.toFixedScaled(size / 5.25948e5, decimals, scaledDecimals, 5, ' year');
- }
-};
-
-kbn.valueFormats.h = (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- if (Math.abs(size) < 24) {
- return kbn.toFixed(size, decimals) + ' hour';
- } else if (Math.abs(size) < 168) {
- return kbn.toFixedScaled(size / 24, decimals, scaledDecimals, 2, ' day');
- } else if (Math.abs(size) < 8760) {
- return kbn.toFixedScaled(size / 168, decimals, scaledDecimals, 3, ' week');
- } else {
- return kbn.toFixedScaled(size / 8760, decimals, scaledDecimals, 4, ' year');
- }
-};
-
-kbn.valueFormats.d = (size, decimals, scaledDecimals) => {
- if (size === null) {
- return '';
- }
-
- if (Math.abs(size) < 7) {
- return kbn.toFixed(size, decimals) + ' day';
- } else if (Math.abs(size) < 365) {
- return kbn.toFixedScaled(size / 7, decimals, scaledDecimals, 2, ' week');
- } else {
- return kbn.toFixedScaled(size / 365, decimals, scaledDecimals, 3, ' year');
- }
-};
-
-kbn.toDuration = (size, decimals, timeScale) => {
- if (size === null) {
- return '';
- }
- if (size === 0) {
- return '0 ' + timeScale + 's';
- }
- if (size < 0) {
- return kbn.toDuration(-size, decimals, timeScale) + ' ago';
- }
-
- const units = [
- { short: 'y', long: 'year' },
- { short: 'M', long: 'month' },
- { short: 'w', long: 'week' },
- { short: 'd', long: 'day' },
- { short: 'h', long: 'hour' },
- { short: 'm', long: 'minute' },
- { short: 's', long: 'second' },
- { short: 'ms', long: 'millisecond' },
- ];
- // convert $size to milliseconds
- // intervals_in_seconds uses seconds (duh), convert them to milliseconds here to minimize floating point errors
- size *=
- kbn.intervals_in_seconds[
- units.find(e => {
- return e.long === timeScale;
- }).short
- ] * 1000;
-
- const strings = [];
- // after first value >= 1 print only $decimals more
- let decrementDecimals = false;
- for (let i = 0; i < units.length && decimals >= 0; i++) {
- const interval = kbn.intervals_in_seconds[units[i].short] * 1000;
- const value = size / interval;
- if (value >= 1 || decrementDecimals) {
- decrementDecimals = true;
- const floor = Math.floor(value);
- const unit = units[i].long + (floor !== 1 ? 's' : '');
- strings.push(floor + ' ' + unit);
- size = size % interval;
- decimals--;
- }
- }
-
- return strings.join(', ');
-};
-
-kbn.toClock = (size, decimals) => {
- if (size === null) {
- return '';
- }
-
- // < 1 second
- if (size < 1000) {
- return moment.utc(size).format('SSS\\m\\s');
- }
-
- // < 1 minute
- if (size < 60000) {
- let format = 'ss\\s:SSS\\m\\s';
- if (decimals === 0) {
- format = 'ss\\s';
- }
- return moment.utc(size).format(format);
- }
-
- // < 1 hour
- if (size < 3600000) {
- let format = 'mm\\m:ss\\s:SSS\\m\\s';
- if (decimals === 0) {
- format = 'mm\\m';
- } else if (decimals === 1) {
- format = 'mm\\m:ss\\s';
- }
- return moment.utc(size).format(format);
- }
-
- let format = 'mm\\m:ss\\s:SSS\\m\\s';
-
- const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
-
- if (decimals === 0) {
- format = '';
- } else if (decimals === 1) {
- format = 'mm\\m';
- } else if (decimals === 2) {
- format = 'mm\\m:ss\\s';
- }
-
- return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
-};
-
-kbn.valueFormats.dtdurationms = (size, decimals) => {
- return kbn.toDuration(size, decimals, 'millisecond');
-};
-
-kbn.valueFormats.dtdurations = (size, decimals) => {
- return kbn.toDuration(size, decimals, 'second');
-};
-
-kbn.valueFormats.dthms = (size, decimals) => {
- return kbn.secondsToHhmmss(size);
-};
-
-kbn.valueFormats.timeticks = (size, decimals, scaledDecimals) => {
- return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
-};
-
-kbn.valueFormats.clockms = (size, decimals) => {
- return kbn.toClock(size, decimals);
-};
-
-kbn.valueFormats.clocks = (size, decimals) => {
- return kbn.toClock(size * 1000, decimals);
-};
-
-kbn.valueFormats.dateTimeAsIso = (epoch, isUtc) => {
- const time = isUtc ? moment.utc(epoch) : moment(epoch);
-
- if (moment().isSame(epoch, 'day')) {
- return time.format('HH:mm:ss');
- }
- return time.format('YYYY-MM-DD HH:mm:ss');
-};
-
-kbn.valueFormats.dateTimeAsUS = (epoch, isUtc) => {
- const time = isUtc ? moment.utc(epoch) : moment(epoch);
-
- if (moment().isSame(epoch, 'day')) {
- return time.format('h:mm:ss a');
- }
- return time.format('MM/DD/YYYY h:mm:ss a');
-};
-
-kbn.valueFormats.dateTimeFromNow = (epoch, isUtc) => {
- const time = isUtc ? moment.utc(epoch) : moment(epoch);
- return time.fromNow();
-};
-
///// FORMAT MENU /////
kbn.getUnitFormats = () => {
- return [
- {
- text: 'none',
- submenu: [
- { text: 'none', value: 'none' },
- { text: 'short', value: 'short' },
- { text: 'percent (0-100)', value: 'percent' },
- { text: 'percent (0.0-1.0)', value: 'percentunit' },
- { text: 'Humidity (%H)', value: 'humidity' },
- { text: 'decibel', value: 'dB' },
- { text: 'hexadecimal (0x)', value: 'hex0x' },
- { text: 'hexadecimal', value: 'hex' },
- { text: 'scientific notation', value: 'sci' },
- { text: 'locale format', value: 'locale' },
- ],
- },
- {
- text: 'currency',
- submenu: [
- { text: 'Dollars ($)', value: 'currencyUSD' },
- { text: 'Pounds (£)', value: 'currencyGBP' },
- { text: 'Euro (€)', value: 'currencyEUR' },
- { text: 'Yen (¥)', value: 'currencyJPY' },
- { text: 'Rubles (₽)', value: 'currencyRUB' },
- { text: 'Hryvnias (₴)', value: 'currencyUAH' },
- { text: 'Real (R$)', value: 'currencyBRL' },
- { text: 'Danish Krone (kr)', value: 'currencyDKK' },
- { text: 'Icelandic Króna (kr)', value: 'currencyISK' },
- { text: 'Norwegian Krone (kr)', value: 'currencyNOK' },
- { text: 'Swedish Krona (kr)', value: 'currencySEK' },
- { text: 'Czech koruna (czk)', value: 'currencyCZK' },
- { text: 'Swiss franc (CHF)', value: 'currencyCHF' },
- { text: 'Polish Złoty (PLN)', value: 'currencyPLN' },
- { text: 'Bitcoin (฿)', value: 'currencyBTC' },
- ],
- },
- {
- text: 'time',
- submenu: [
- { text: 'Hertz (1/s)', value: 'hertz' },
- { text: 'nanoseconds (ns)', value: 'ns' },
- { text: 'microseconds (µs)', value: 'µs' },
- { text: 'milliseconds (ms)', value: 'ms' },
- { text: 'seconds (s)', value: 's' },
- { text: 'minutes (m)', value: 'm' },
- { text: 'hours (h)', value: 'h' },
- { text: 'days (d)', value: 'd' },
- { text: 'duration (ms)', value: 'dtdurationms' },
- { text: 'duration (s)', value: 'dtdurations' },
- { text: 'duration (hh:mm:ss)', value: 'dthms' },
- { text: 'Timeticks (s/100)', value: 'timeticks' },
- { text: 'clock (ms)', value: 'clockms' },
- { text: 'clock (s)', value: 'clocks' },
- ],
- },
- {
- text: 'date & time',
- submenu: [
- { text: 'YYYY-MM-DD HH:mm:ss', value: 'dateTimeAsIso' },
- { text: 'DD/MM/YYYY h:mm:ss a', value: 'dateTimeAsUS' },
- { text: 'From Now', value: 'dateTimeFromNow' },
- ],
- },
- {
- text: 'data (IEC)',
- submenu: [
- { text: 'bits', value: 'bits' },
- { text: 'bytes', value: 'bytes' },
- { text: 'kibibytes', value: 'kbytes' },
- { text: 'mebibytes', value: 'mbytes' },
- { text: 'gibibytes', value: 'gbytes' },
- ],
- },
- {
- text: 'data (Metric)',
- submenu: [
- { text: 'bits', value: 'decbits' },
- { text: 'bytes', value: 'decbytes' },
- { text: 'kilobytes', value: 'deckbytes' },
- { text: 'megabytes', value: 'decmbytes' },
- { text: 'gigabytes', value: 'decgbytes' },
- ],
- },
- {
- text: 'data rate',
- submenu: [
- { text: 'packets/sec', value: 'pps' },
- { text: 'bits/sec', value: 'bps' },
- { text: 'bytes/sec', value: 'Bps' },
- { text: 'kilobits/sec', value: 'Kbits' },
- { text: 'kilobytes/sec', value: 'KBs' },
- { text: 'megabits/sec', value: 'Mbits' },
- { text: 'megabytes/sec', value: 'MBs' },
- { text: 'gigabytes/sec', value: 'GBs' },
- { text: 'gigabits/sec', value: 'Gbits' },
- ],
- },
- {
- text: 'hash rate',
- submenu: [
- { text: 'hashes/sec', value: 'Hs' },
- { text: 'kilohashes/sec', value: 'KHs' },
- { text: 'megahashes/sec', value: 'MHs' },
- { text: 'gigahashes/sec', value: 'GHs' },
- { text: 'terahashes/sec', value: 'THs' },
- { text: 'petahashes/sec', value: 'PHs' },
- { text: 'exahashes/sec', value: 'EHs' },
- ],
- },
- {
- text: 'computation throughput',
- submenu: [
- { text: 'FLOP/s', value: 'flops' },
- { text: 'MFLOP/s', value: 'mflops' },
- { text: 'GFLOP/s', value: 'gflops' },
- { text: 'TFLOP/s', value: 'tflops' },
- { text: 'PFLOP/s', value: 'pflops' },
- { text: 'EFLOP/s', value: 'eflops' },
- ],
- },
- {
- text: 'throughput',
- submenu: [
- { text: 'ops/sec (ops)', value: 'ops' },
- { text: 'requests/sec (rps)', value: 'reqps' },
- { text: 'reads/sec (rps)', value: 'rps' },
- { text: 'writes/sec (wps)', value: 'wps' },
- { text: 'I/O ops/sec (iops)', value: 'iops' },
- { text: 'ops/min (opm)', value: 'opm' },
- { text: 'reads/min (rpm)', value: 'rpm' },
- { text: 'writes/min (wpm)', value: 'wpm' },
- ],
- },
- {
- text: 'length',
- submenu: [
- { text: 'millimetre (mm)', value: 'lengthmm' },
- { text: 'meter (m)', value: 'lengthm' },
- { text: 'feet (ft)', value: 'lengthft' },
- { text: 'kilometer (km)', value: 'lengthkm' },
- { text: 'mile (mi)', value: 'lengthmi' },
- ],
- },
- {
- text: 'area',
- submenu: [
- { text: 'Square Meters (m²)', value: 'areaM2' },
- { text: 'Square Feet (ft²)', value: 'areaF2' },
- { text: 'Square Miles (mi²)', value: 'areaMI2' },
- ],
- },
- {
- text: 'mass',
- submenu: [
- { text: 'milligram (mg)', value: 'massmg' },
- { text: 'gram (g)', value: 'massg' },
- { text: 'kilogram (kg)', value: 'masskg' },
- { text: 'metric ton (t)', value: 'masst' },
- ],
- },
- {
- text: 'velocity',
- submenu: [
- { text: 'metres/second (m/s)', value: 'velocityms' },
- { text: 'kilometers/hour (km/h)', value: 'velocitykmh' },
- { text: 'miles/hour (mph)', value: 'velocitymph' },
- { text: 'knot (kn)', value: 'velocityknot' },
- ],
- },
- {
- text: 'volume',
- submenu: [
- { text: 'millilitre (mL)', value: 'mlitre' },
- { text: 'litre (L)', value: 'litre' },
- { text: 'cubic metre', value: 'm3' },
- { text: 'Normal cubic metre', value: 'Nm3' },
- { text: 'cubic decimetre', value: 'dm3' },
- { text: 'gallons', value: 'gallons' },
- ],
- },
- {
- text: 'energy',
- submenu: [
- { text: 'Watt (W)', value: 'watt' },
- { text: 'Kilowatt (kW)', value: 'kwatt' },
- { text: 'Milliwatt (mW)', value: 'mwatt' },
- { text: 'Watt per square meter (W/m²)', value: 'Wm2' },
- { text: 'Volt-ampere (VA)', value: 'voltamp' },
- { text: 'Kilovolt-ampere (kVA)', value: 'kvoltamp' },
- { text: 'Volt-ampere reactive (var)', value: 'voltampreact' },
- { text: 'Kilovolt-ampere reactive (kvar)', value: 'kvoltampreact' },
- { text: 'Watt-hour (Wh)', value: 'watth' },
- { text: 'Kilowatt-hour (kWh)', value: 'kwatth' },
- { text: 'Kilowatt-min (kWm)', value: 'kwattm' },
- { text: 'Joule (J)', value: 'joule' },
- { text: 'Electron volt (eV)', value: 'ev' },
- { text: 'Ampere (A)', value: 'amp' },
- { text: 'Kiloampere (kA)', value: 'kamp' },
- { text: 'Milliampere (mA)', value: 'mamp' },
- { text: 'Volt (V)', value: 'volt' },
- { text: 'Kilovolt (kV)', value: 'kvolt' },
- { text: 'Millivolt (mV)', value: 'mvolt' },
- { text: 'Decibel-milliwatt (dBm)', value: 'dBm' },
- { text: 'Ohm (Ω)', value: 'ohm' },
- { text: 'Lumens (Lm)', value: 'lumens' },
- ],
- },
- {
- text: 'temperature',
- submenu: [
- { text: 'Celsius (°C)', value: 'celsius' },
- { text: 'Farenheit (°F)', value: 'farenheit' },
- { text: 'Kelvin (K)', value: 'kelvin' },
- ],
- },
- {
- text: 'pressure',
- submenu: [
- { text: 'Millibars', value: 'pressurembar' },
- { text: 'Bars', value: 'pressurebar' },
- { text: 'Kilobars', value: 'pressurekbar' },
- { text: 'Hectopascals', value: 'pressurehpa' },
- { text: 'Kilopascals', value: 'pressurekpa' },
- { text: 'Inches of mercury', value: 'pressurehg' },
- { text: 'PSI', value: 'pressurepsi' },
- ],
- },
- {
- text: 'force',
- submenu: [
- { text: 'Newton-meters (Nm)', value: 'forceNm' },
- { text: 'Kilonewton-meters (kNm)', value: 'forcekNm' },
- { text: 'Newtons (N)', value: 'forceN' },
- { text: 'Kilonewtons (kN)', value: 'forcekN' },
- ],
- },
- {
- text: 'flow',
- submenu: [
- { text: 'Gallons/min (gpm)', value: 'flowgpm' },
- { text: 'Cubic meters/sec (cms)', value: 'flowcms' },
- { text: 'Cubic feet/sec (cfs)', value: 'flowcfs' },
- { text: 'Cubic feet/min (cfm)', value: 'flowcfm' },
- { text: 'Litre/hour', value: 'litreh' },
- { text: 'Litre/min (l/min)', value: 'flowlpm' },
- { text: 'milliLitre/min (mL/min)', value: 'flowmlpm' },
- ],
- },
- {
- text: 'angle',
- submenu: [
- { text: 'Degrees (°)', value: 'degree' },
- { text: 'Radians', value: 'radian' },
- { text: 'Gradian', value: 'grad' },
- ],
- },
- {
- text: 'acceleration',
- submenu: [
- { text: 'Meters/sec²', value: 'accMS2' },
- { text: 'Feet/sec²', value: 'accFS2' },
- { text: 'G unit', value: 'accG' },
- ],
- },
- {
- text: 'radiation',
- submenu: [
- { text: 'Becquerel (Bq)', value: 'radbq' },
- { text: 'curie (Ci)', value: 'radci' },
- { text: 'Gray (Gy)', value: 'radgy' },
- { text: 'rad', value: 'radrad' },
- { text: 'Sievert (Sv)', value: 'radsv' },
- { text: 'rem', value: 'radrem' },
- { text: 'Exposure (C/kg)', value: 'radexpckg' },
- { text: 'roentgen (R)', value: 'radr' },
- { text: 'Sievert/hour (Sv/h)', value: 'radsvh' },
- ],
- },
- {
- text: 'concentration',
- submenu: [
- { text: 'parts-per-million (ppm)', value: 'ppm' },
- { text: 'parts-per-billion (ppb)', value: 'conppb' },
- { text: 'nanogram per cubic meter (ng/m³)', value: 'conngm3' },
- { text: 'nanogram per normal cubic meter (ng/Nm³)', value: 'conngNm3' },
- { text: 'microgram per cubic meter (μg/m³)', value: 'conμgm3' },
- { text: 'microgram per normal cubic meter (μg/Nm³)', value: 'conμgNm3' },
- { text: 'milligram per cubic meter (mg/m³)', value: 'conmgm3' },
- { text: 'milligram per normal cubic meter (mg/Nm³)', value: 'conmgNm3' },
- { text: 'gram per cubic meter (g/m³)', value: 'congm3' },
- { text: 'gram per normal cubic meter (g/Nm³)', value: 'congNm3' },
- { text: 'milligrams per decilitre (mg/dL)', value: 'conmgdL' },
- { text: 'millimoles per litre (mmol/L)', value: 'conmmolL' },
- ],
- },
- ];
+ return getValueFormats();
};
+//
+// Backward compatible layer for value formats to support old plugins
+//
+if (typeof Proxy !== "undefined") {
+ kbn.valueFormats = new Proxy(kbn.valueFormats, {
+ get(target, name, receiver) {
+ if (typeof name !== 'string') {
+ throw {message: `Value format ${String(name)} is not a string` };
+ }
+
+ const formatter = getValueFormat(name);
+ if (formatter) {
+ return formatter;
+ }
+
+ // default to look here
+ return Reflect.get(target, name, receiver);
+ }
+ });
+} else {
+ kbn.valueFormats = getValueFormatterIndex();
+}
+
export default kbn;
diff --git a/public/app/core/utils/react2angular.ts b/public/app/core/utils/react2angular.ts
index 6dc1803133a..1057f68fcda 100644
--- a/public/app/core/utils/react2angular.ts
+++ b/public/app/core/utils/react2angular.ts
@@ -1,10 +1,11 @@
import coreModule from 'app/core/core_module';
+import { provideConfig } from 'app/core/utils/ConfigProvider';
export function react2AngularDirective(name: string, component: any, options: any) {
coreModule.directive(name, [
'reactDirective',
reactDirective => {
- return reactDirective(component, options);
+ return reactDirective(provideConfig(component), options);
},
]);
}
diff --git a/public/app/core/utils/scrollbar.ts b/public/app/core/utils/scrollbar.ts
new file mode 100644
index 00000000000..b1b098156ef
--- /dev/null
+++ b/public/app/core/utils/scrollbar.ts
@@ -0,0 +1,40 @@
+// Slightly modified, but without dependancies:
+// https://raw.githubusercontent.com/malte-wessel/react-custom-scrollbars/master/src/utils/getScrollbarWidth.js
+let scrollbarWidth = null;
+
+export default function getScrollbarWidth() {
+ if (scrollbarWidth !== null) {
+ return scrollbarWidth;
+ }
+
+ if (typeof document !== 'undefined') {
+ const div = document.createElement('div');
+ const newStyles = {
+ width: '100px',
+ height: '100px',
+ position: 'absolute',
+ top: '-9999px',
+ overflow: 'scroll',
+ MsOverflowStyle: 'scrollbar'
+ };
+
+ Object.keys(newStyles).map(style => {
+ div.style[style] = newStyles[style];
+ });
+
+ document.body.appendChild(div);
+ scrollbarWidth = (div.offsetWidth - div.clientWidth);
+ document.body.removeChild(div);
+ } else {
+ scrollbarWidth = 0;
+ }
+ return scrollbarWidth || 0;
+}
+
+const hasNoOverlayScrollbars = getScrollbarWidth() > 0;
+
+export const addClassIfNoOverlayScrollbar = (classname: string, htmlElement: HTMLElement = document.body) => {
+ if (hasNoOverlayScrollbars) {
+ htmlElement.classList.add(classname);
+ }
+};
diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts
index 4e948116dba..427b0102c95 100644
--- a/public/app/core/utils/text.ts
+++ b/public/app/core/utils/text.ts
@@ -1,4 +1,5 @@
import { TextMatch } from 'app/types/explore';
+import xss from 'xss';
/**
* Adapt findMatchesInText for react-highlight-words findChunks handler.
@@ -22,7 +23,7 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
}
const matches = [];
const cleaned = cleanNeedle(needle);
- let regexp;
+ let regexp: RegExp;
try {
regexp = new RegExp(`(?:${cleaned})`, 'g');
} catch (error) {
@@ -42,3 +43,28 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
});
return matches;
}
+
+const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => {
+ acc[element] = xss.whiteList[element].concat(['class', 'style']);
+ return acc;
+}, {});
+
+const sanitizeXSS = new xss.FilterXSS({
+ whiteList: XSSWL
+});
+
+/**
+ * Returns string safe from XSS attacks.
+ *
+ * Even though we allow the style-attribute, there's still default filtering applied to it
+ * Info: https://github.com/leizongmin/js-xss#customize-css-filter
+ * Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js
+ */
+export function sanitize (unsanitizedString: string): string {
+ try {
+ return sanitizeXSS.process(unsanitizedString);
+ } catch (error) {
+ console.log('String could not be sanitized', unsanitizedString);
+ return unsanitizedString;
+ }
+}
diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts
index ab8be8ad222..824e0e4e9c9 100644
--- a/public/app/core/utils/url.ts
+++ b/public/app/core/utils/url.ts
@@ -11,6 +11,16 @@ export function renderUrl(path: string, query: UrlQueryMap | undefined): string
return path;
}
+export function encodeURIComponentAsAngularJS(val, pctEncodeSpaces) {
+ return encodeURIComponent(val).
+ replace(/%40/gi, '@').
+ replace(/%3A/gi, ':').
+ replace(/%24/g, '$').
+ replace(/%2C/gi, ',').
+ replace(/%3B/gi, ';').
+ replace(/%20/g, (pctEncodeSpaces ? '%20' : '+'));
+}
+
export function toUrlParams(a) {
const s = [];
const rbracket = /\[\]$/;
@@ -22,9 +32,9 @@ export function toUrlParams(a) {
const add = (k, v) => {
v = typeof v === 'function' ? v() : v === null ? '' : v === undefined ? '' : v;
if (typeof v !== 'boolean') {
- s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
+ s[s.length] = encodeURIComponentAsAngularJS(k, true) + '=' + encodeURIComponentAsAngularJS(v, true);
} else {
- s[s.length] = encodeURIComponent(k);
+ s[s.length] = encodeURIComponentAsAngularJS(k, true);
}
};
diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx
index 40be87ed4d3..5247e48515e 100644
--- a/public/app/features/admin/ServerStats.tsx
+++ b/public/app/features/admin/ServerStats.tsx
@@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { NavModel, StoreState } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { getServerStats, ServerStat } from './state/apis';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import Page from 'app/core/components/Page/Page';
interface Props {
navModel: NavModel;
@@ -13,21 +13,24 @@ interface Props {
interface State {
stats: ServerStat[];
+ isLoading: boolean;
}
export class ServerStats extends PureComponent {
- constructor(props) {
+ constructor(props: Props) {
super(props);
this.state = {
stats: [],
+ isLoading: false
};
}
async componentDidMount() {
try {
+ this.setState({ isLoading: true });
const stats = await this.props.getServerStats();
- this.setState({ stats });
+ this.setState({ stats, isLoading: false });
} catch (error) {
console.error(error);
}
@@ -35,12 +38,11 @@ export class ServerStats extends PureComponent {
render() {
const { navModel } = this.props;
- const { stats } = this.state;
+ const { stats, isLoading } = this.state;
return (
-
-
-
+
+
@@ -50,8 +52,8 @@ export class ServerStats extends PureComponent {
{stats.map(StatItem)}
-
-
+
+
);
}
}
diff --git a/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap b/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap
index 02e8784adc5..e6c3f0f841e 100644
--- a/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap
+++ b/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap
@@ -1,118 +1,258 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ServerStats Should render table with stats 1`] = `
-
+
-
-
-
-
- Admin
-
- subTitle
+
+
+
+
+
+
+ Admin
+
+
+ subTitle
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+ Name
+
+
+ Value
+
+
+
+
+
+
+ Total dashboards
+
+
+ 10
+
+
+
+
+ Total Users
+
+
+ 1
+
+
+
+
+
+
+
-
-
-
-
-
-
- Name
-
-
- Value
-
-
-
-
-
-
- Total dashboards
-
-
- 10
-
-
-
-
- Total Users
-
-
- 1
-
-
-
-
+
+
+
`;
diff --git a/public/app/features/alerting/AlertRuleList.test.tsx b/public/app/features/alerting/AlertRuleList.test.tsx
index 2d1cf653540..ce3b8d47142 100644
--- a/public/app/features/alerting/AlertRuleList.test.tsx
+++ b/public/app/features/alerting/AlertRuleList.test.tsx
@@ -18,6 +18,7 @@ const setup = (propOverrides?: object) => {
togglePauseAlertRule: jest.fn(),
stateFilter: '',
search: '',
+ isLoading: false
};
Object.assign(props, propOverrides);
@@ -121,7 +122,7 @@ describe('Functions', () => {
describe('State filter changed', () => {
it('should update location', () => {
const { instance } = setup();
- const mockEvent = { target: { value: 'alerting' } };
+ const mockEvent = { target: { value: 'alerting' } } as React.ChangeEvent;
instance.onStateFilterChanged(mockEvent);
@@ -146,7 +147,7 @@ describe('Functions', () => {
describe('Search query change', () => {
it('should set search query', () => {
const { instance } = setup();
- const mockEvent = { target: { value: 'dashboard' } };
+ const mockEvent = { target: { value: 'dashboard' } } as React.ChangeEvent;
instance.onSearchQueryChange(mockEvent);
diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx
index f94134f3ee1..3a16703ef54 100644
--- a/public/app/features/alerting/AlertRuleList.tsx
+++ b/public/app/features/alerting/AlertRuleList.tsx
@@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import Page from 'app/core/components/Page/Page';
import AlertRuleItem from './AlertRuleItem';
import appEvents from 'app/core/app_events';
import { updateLocation } from 'app/core/actions';
@@ -19,6 +19,7 @@ export interface Props {
togglePauseAlertRule: typeof togglePauseAlertRule;
stateFilter: string;
search: string;
+ isLoading: boolean;
}
export class AlertRuleList extends PureComponent {
@@ -54,9 +55,9 @@ export class AlertRuleList extends PureComponent {
return 'all';
}
- onStateFilterChanged = event => {
+ onStateFilterChanged = (evt: React.ChangeEvent) => {
this.props.updateLocation({
- query: { state: event.target.value },
+ query: { state: evt.target.value },
});
};
@@ -68,8 +69,8 @@ export class AlertRuleList extends PureComponent {
});
};
- onSearchQueryChange = event => {
- const { value } = event.target;
+ onSearchQueryChange = (evt: React.ChangeEvent) => {
+ const { value } = evt.target;
this.props.setSearchQuery(value);
};
@@ -77,7 +78,7 @@ export class AlertRuleList extends PureComponent {
this.props.togglePauseAlertRule(rule.id, { paused: rule.state !== 'paused' });
};
- alertStateFilterOption = ({ text, value }) => {
+ alertStateFilterOption = ({ text, value }: { text: string; value: string; }) => {
return (
{text}
@@ -86,12 +87,11 @@ export class AlertRuleList extends PureComponent {
};
render() {
- const { navModel, alertRules, search } = this.props;
+ const { navModel, alertRules, search, isLoading } = this.props;
return (
-
-
-
+
+
@@ -131,8 +131,8 @@ export class AlertRuleList extends PureComponent {
))}
-
-
+
+
);
}
}
@@ -142,6 +142,7 @@ const mapStateToProps = (state: StoreState) => ({
alertRules: getAlertRuleItems(state.alertRules),
stateFilter: state.location.query.state,
search: getSearchQuery(state.alertRules),
+ isLoading: state.alertRules.isLoading
});
const mapDispatchToProps = {
diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx
new file mode 100644
index 00000000000..6343b4ca2c9
--- /dev/null
+++ b/public/app/features/alerting/AlertTab.tsx
@@ -0,0 +1,150 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Services & Utils
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import appEvents from 'app/core/app_events';
+
+// Components
+import { EditorTabBody, EditorToolbarView } from '../dashboard/panel_editor/EditorTabBody';
+import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
+import StateHistory from './StateHistory';
+import 'app/features/alerting/AlertTabCtrl';
+
+// Types
+import { DashboardModel } from '../dashboard/state/DashboardModel';
+import { PanelModel } from '../dashboard/state/PanelModel';
+import { TestRuleResult } from './TestRuleResult';
+
+interface Props {
+ angularPanel?: AngularComponent;
+ dashboard: DashboardModel;
+ panel: PanelModel;
+}
+
+export class AlertTab extends PureComponent
{
+ element: any;
+ component: AngularComponent;
+ panelCtrl: any;
+
+ componentDidMount() {
+ if (this.shouldLoadAlertTab()) {
+ this.loadAlertTab();
+ }
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (this.shouldLoadAlertTab()) {
+ this.loadAlertTab();
+ }
+ }
+
+ shouldLoadAlertTab() {
+ return this.props.angularPanel && this.element && !this.component;
+ }
+
+ componentWillUnmount() {
+ if (this.component) {
+ this.component.destroy();
+ }
+ }
+
+ loadAlertTab() {
+ const { angularPanel } = this.props;
+
+ const scope = angularPanel.getScope();
+
+ // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
+ if (!scope.$$childHead) {
+ setTimeout(() => {
+ this.forceUpdate();
+ });
+ return;
+ }
+
+ this.panelCtrl = scope.$$childHead.ctrl;
+ const loader = getAngularLoader();
+ const template = ' ';
+
+ const scopeProps = { ctrl: this.panelCtrl };
+
+ this.component = loader.load(this.element, scopeProps, template);
+ }
+
+ stateHistory = (): EditorToolbarView => {
+ return {
+ title: 'State history',
+ render: () => {
+ return (
+
+ );
+ },
+ };
+ };
+
+ deleteAlert = (): EditorToolbarView => {
+ const { panel } = this.props;
+ return {
+ title: 'Delete',
+ btnType: 'danger',
+ onClick: () => {
+ appEvents.emit('confirm-modal', {
+ title: 'Delete Alert',
+ text: 'Are you sure you want to delete this alert rule?',
+ text2: 'You need to save dashboard for the delete to take effect',
+ icon: 'fa-trash',
+ yesText: 'Delete',
+ onConfirm: () => {
+ delete panel.alert;
+ panel.thresholds = [];
+ this.panelCtrl.alertState = null;
+ this.panelCtrl.render();
+ this.forceUpdate();
+ },
+ });
+ },
+ };
+ };
+
+ renderTestRuleResult = () => {
+ const { panel, dashboard } = this.props;
+ return ;
+ };
+
+ testRule = (): EditorToolbarView => ({
+ title: 'Test Rule',
+ render: () => this.renderTestRuleResult(),
+ });
+
+ onAddAlert = () => {
+ this.panelCtrl._enableAlert();
+ this.component.digest();
+ this.forceUpdate();
+ };
+
+ render() {
+ const { alert } = this.props.panel;
+
+ const toolbarItems = alert ? [this.stateHistory(), this.testRule(), this.deleteAlert()] : [];
+
+ const model = {
+ title: 'Panel has no alert rule defined',
+ icon: 'icon-gf icon-gf-alert',
+ onClick: this.onAddAlert,
+ buttonTitle: 'Create Alert',
+ };
+
+ return (
+
+ <>
+ (this.element = element)} />
+ {!alert &&
}
+ >
+
+ );
+ }
+}
diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts
index 6d87c159d02..12943805c2c 100644
--- a/public/app/features/alerting/AlertTabCtrl.ts
+++ b/public/app/features/alerting/AlertTabCtrl.ts
@@ -9,8 +9,6 @@ import appEvents from 'app/core/app_events';
export class AlertTabCtrl {
panel: any;
panelCtrl: any;
- testing: boolean;
- testResult: any;
subTabIndex: number;
conditionTypes: any;
alert: any;
@@ -45,6 +43,7 @@ export class AlertTabCtrl {
this.noDataModes = alertDef.noDataModes;
this.executionErrorModes = alertDef.executionErrorModes;
this.appSubUrl = config.appSubUrl;
+ this.panelCtrl._enableAlert = this.enable;
}
$onInit() {
@@ -114,7 +113,7 @@ export class AlertTabCtrl {
}
getNotifications() {
- return Promise.resolve(
+ return this.$q.when(
this.notifications.map(item => {
return this.uiSegmentSrv.newSegment(item.name);
})
@@ -141,17 +140,25 @@ export class AlertTabCtrl {
name: model.name,
iconClass: this.getNotificationIcon(model.type),
isDefault: false,
+ uid: model.uid
});
- this.alert.notifications.push({ id: model.id });
+
+ // avoid duplicates using both id and uid to be backwards compatible.
+ if (!_.find(this.alert.notifications, n => n.id === model.id || n.uid === model.uid)) {
+ this.alert.notifications.push({ uid: model.uid });
+ }
// reset plus button
this.addNotificationSegment.value = this.uiSegmentSrv.newPlusButton().value;
this.addNotificationSegment.html = this.uiSegmentSrv.newPlusButton().html;
+ this.addNotificationSegment.fake = true;
}
- removeNotification(index) {
- this.alert.notifications.splice(index, 1);
- this.alertNotifications.splice(index, 1);
+ removeNotification(an) {
+ // remove notifiers refeered to by id and uid to support notifiers added
+ // before and after we added support for uid
+ _.remove(this.alert.notifications, n => n.uid === an.uid || n.id === an.id);
+ _.remove(this.alertNotifications, n => n.uid === an.uid || n.id === an.id);
}
initModel() {
@@ -187,7 +194,14 @@ export class AlertTabCtrl {
ThresholdMapper.alertToGraphThresholds(this.panel);
for (const addedNotification of alert.notifications) {
- const model = _.find(this.notifications, { id: addedNotification.id });
+ // lookup notifier type by uid
+ let model = _.find(this.notifications, { uid: addedNotification.uid });
+
+ // fallback to using id if uid is missing
+ if (!model) {
+ model = _.find(this.notifications, { id: addedNotification.id });
+ }
+
if (model && model.isDefault === false) {
model.iconClass = this.getNotificationIcon(model.type);
this.alertNotifications.push(model);
@@ -353,11 +367,11 @@ export class AlertTabCtrl {
});
}
- enable() {
+ enable = () => {
this.panel.alert = {};
this.initModel();
this.panel.alert.for = '5m'; //default value for new alerts. for existing alerts we use 0m to avoid breaking changes
- }
+ };
evaluatorParamsChanged() {
ThresholdMapper.alertToGraphThresholds(this.panel);
@@ -404,21 +418,6 @@ export class AlertTabCtrl {
},
});
}
-
- test() {
- this.testing = true;
- this.testResult = false;
-
- const payload = {
- dashboard: this.dashboardSrv.getCurrent().getSaveModelClone(),
- panelId: this.panelCtrl.panel.id,
- };
-
- return this.backendSrv.post('/api/alerts/test', payload).then(res => {
- this.testResult = res;
- this.testing = false;
- });
- }
}
/** @ngInject */
diff --git a/public/app/features/alerting/StateHistory.tsx b/public/app/features/alerting/StateHistory.tsx
new file mode 100644
index 00000000000..be34552e50d
--- /dev/null
+++ b/public/app/features/alerting/StateHistory.tsx
@@ -0,0 +1,110 @@
+import React, { PureComponent } from 'react';
+import alertDef from './state/alertDef';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { DashboardModel } from '../dashboard/state/DashboardModel';
+import appEvents from '../../core/app_events';
+
+interface Props {
+ dashboard: DashboardModel;
+ panelId: number;
+ onRefresh: () => void;
+}
+
+interface State {
+ stateHistoryItems: any[];
+}
+
+class StateHistory extends PureComponent
{
+ state = {
+ stateHistoryItems: [],
+ };
+
+ componentDidMount(): void {
+ const { dashboard, panelId } = this.props;
+
+ getBackendSrv()
+ .get(`/api/annotations?dashboardId=${dashboard.id}&panelId=${panelId}&limit=50&type=alert`)
+ .then(res => {
+ const items = res.map(item => {
+ return {
+ stateModel: alertDef.getStateDisplayModel(item.newState),
+ time: dashboard.formatDate(item.time, 'MMM D, YYYY HH:mm:ss'),
+ info: alertDef.getAlertAnnotationInfo(item),
+ };
+ });
+
+ this.setState({
+ stateHistoryItems: items,
+ });
+ });
+ }
+
+ clearHistory = () => {
+ const { dashboard, onRefresh, panelId } = this.props;
+
+ appEvents.emit('confirm-modal', {
+ title: 'Delete Alert History',
+ text: 'Are you sure you want to remove all history & annotations for this alert?',
+ icon: 'fa-trash',
+ yesText: 'Yes',
+ onConfirm: () => {
+ getBackendSrv()
+ .post('/api/annotations/mass-delete', {
+ dashboardId: dashboard.id,
+ panelId: panelId,
+ })
+ .then(() => {
+ onRefresh();
+ });
+
+ this.setState({
+ stateHistoryItems: [],
+ });
+ },
+ });
+ };
+
+ render() {
+ const { stateHistoryItems } = this.state;
+
+ return (
+
+ {stateHistoryItems.length > 0 && (
+
+ Last 50 state changes
+
+ {` Clear history`}
+
+
+ )}
+
+ {stateHistoryItems.length > 0 ? (
+ stateHistoryItems.map((item, index) => {
+ return (
+
+
+
+
+
+
+
{item.alertName}
+
+ {item.stateModel.text}
+
+
+ {item.info}
+
+ {item.time}
+
+ );
+ })
+ ) : (
+ No state changes recorded
+ )}
+
+
+ );
+ }
+}
+
+export default StateHistory;
diff --git a/public/app/features/alerting/TestRuleResult.test.tsx b/public/app/features/alerting/TestRuleResult.test.tsx
new file mode 100644
index 00000000000..ff8422ccf4b
--- /dev/null
+++ b/public/app/features/alerting/TestRuleResult.test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { DashboardModel } from '../dashboard/state/DashboardModel';
+import { Props, TestRuleResult } from './TestRuleResult';
+
+jest.mock('app/core/services/backend_srv', () => ({
+ getBackendSrv: () => ({
+ post: jest.fn(),
+ }),
+}));
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ panelId: 1,
+ dashboard: new DashboardModel({ panels: [{ id: 1 }] }),
+ };
+
+ Object.assign(props, propOverrides);
+
+ const wrapper = shallow( );
+
+ return { wrapper, instance: wrapper.instance() as TestRuleResult };
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const { wrapper } = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
+
+describe('Life cycle', () => {
+ describe('component did mount', () => {
+ it('should call testRule', () => {
+ const { instance } = setup();
+ instance.testRule = jest.fn();
+ instance.componentDidMount();
+
+ expect(instance.testRule).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/public/app/features/alerting/TestRuleResult.tsx b/public/app/features/alerting/TestRuleResult.tsx
new file mode 100644
index 00000000000..e8f0551d707
--- /dev/null
+++ b/public/app/features/alerting/TestRuleResult.tsx
@@ -0,0 +1,45 @@
+import React, { PureComponent } from 'react';
+import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { DashboardModel } from '../dashboard/state/DashboardModel';
+import { LoadingPlaceholder } from '@grafana/ui/src';
+
+export interface Props {
+ panelId: number;
+ dashboard: DashboardModel;
+}
+
+interface State {
+ isLoading: boolean;
+ testRuleResponse: {};
+}
+
+export class TestRuleResult extends PureComponent {
+ readonly state: State = {
+ isLoading: false,
+ testRuleResponse: {},
+ };
+
+ componentDidMount() {
+ this.testRule();
+ }
+
+ async testRule() {
+ const { panelId, dashboard } = this.props;
+ const payload = { dashboard: dashboard.getSaveModelClone(), panelId };
+
+ this.setState({ isLoading: true });
+ const testRuleResponse = await getBackendSrv().post(`/api/alerts/test`, payload);
+ this.setState({ isLoading: false, testRuleResponse });
+ }
+
+ render() {
+ const { testRuleResponse, isLoading } = this.state;
+
+ if (isLoading === true) {
+ return ;
+ }
+
+ return ;
+ }
+}
diff --git a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap
index b753a852e92..9270cda93e3 100644
--- a/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap
+++ b/public/app/features/alerting/__snapshots__/AlertRuleList.test.tsx.snap
@@ -1,12 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render alert rules 1`] = `
-
+
+
`;
exports[`Render should render component 1`] = `
-
+
+
`;
diff --git a/public/app/features/alerting/__snapshots__/TestRuleResult.test.tsx.snap b/public/app/features/alerting/__snapshots__/TestRuleResult.test.tsx.snap
new file mode 100644
index 00000000000..73f85f12354
--- /dev/null
+++ b/public/app/features/alerting/__snapshots__/TestRuleResult.test.tsx.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html
index 0e4e48a89a9..b99859fd847 100644
--- a/public/app/features/alerting/partials/alert_tab.html
+++ b/public/app/features/alerting/partials/alert_tab.html
@@ -1,191 +1,154 @@
-
diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts
index edd6fbb1da1..5ec84fe051d 100644
--- a/public/app/features/alerting/state/actions.ts
+++ b/public/app/features/alerting/state/actions.ts
@@ -4,11 +4,16 @@ import { ThunkAction } from 'redux-thunk';
export enum ActionTypes {
LoadAlertRules = 'LOAD_ALERT_RULES',
+ LoadedAlertRules = 'LOADED_ALERT_RULES',
SetSearchQuery = 'SET_ALERT_SEARCH_QUERY',
}
export interface LoadAlertRulesAction {
type: ActionTypes.LoadAlertRules;
+}
+
+export interface LoadedAlertRulesAction {
+ type: ActionTypes.LoadedAlertRules;
payload: AlertRuleDTO[];
}
@@ -17,8 +22,12 @@ export interface SetSearchQueryAction {
payload: string;
}
-export const loadAlertRules = (rules: AlertRuleDTO[]): LoadAlertRulesAction => ({
+export const loadAlertRules = (): LoadAlertRulesAction => ({
type: ActionTypes.LoadAlertRules,
+});
+
+export const loadedAlertRules = (rules: AlertRuleDTO[]): LoadedAlertRulesAction => ({
+ type: ActionTypes.LoadedAlertRules,
payload: rules,
});
@@ -27,14 +36,15 @@ export const setSearchQuery = (query: string): SetSearchQueryAction => ({
payload: query,
});
-export type Action = LoadAlertRulesAction | SetSearchQueryAction;
+export type Action = LoadAlertRulesAction | LoadedAlertRulesAction | SetSearchQueryAction;
type ThunkResult = ThunkAction;
export function getAlertRulesAsync(options: { state: string }): ThunkResult {
return async dispatch => {
- const rules = await getBackendSrv().get('/api/alerts', options);
- dispatch(loadAlertRules(rules));
+ dispatch(loadAlertRules());
+ const rules: AlertRuleDTO[] = await getBackendSrv().get('/api/alerts', options);
+ dispatch(loadedAlertRules(rules));
};
}
diff --git a/public/app/features/alerting/state/reducers.test.ts b/public/app/features/alerting/state/reducers.test.ts
index 4f079a090cf..a176acc66d2 100644
--- a/public/app/features/alerting/state/reducers.test.ts
+++ b/public/app/features/alerting/state/reducers.test.ts
@@ -80,7 +80,7 @@ describe('Alert rules', () => {
it('should set alert rules', () => {
const action: Action = {
- type: ActionTypes.LoadAlertRules,
+ type: ActionTypes.LoadedAlertRules,
payload: payload,
};
diff --git a/public/app/features/alerting/state/reducers.ts b/public/app/features/alerting/state/reducers.ts
index c525885bc9c..f956dc34005 100644
--- a/public/app/features/alerting/state/reducers.ts
+++ b/public/app/features/alerting/state/reducers.ts
@@ -3,7 +3,7 @@ import { AlertRuleDTO, AlertRule, AlertRulesState } from 'app/types';
import { Action, ActionTypes } from './actions';
import alertDef from './alertDef';
-export const initialState: AlertRulesState = { items: [], searchQuery: '' };
+export const initialState: AlertRulesState = { items: [], searchQuery: '', isLoading: false };
function convertToAlertRule(rule, state): AlertRule {
const stateModel = alertDef.getStateDisplayModel(state);
@@ -29,17 +29,21 @@ function convertToAlertRule(rule, state): AlertRule {
export const alertRulesReducer = (state = initialState, action: Action): AlertRulesState => {
switch (action.type) {
case ActionTypes.LoadAlertRules: {
+ return { ...state, isLoading: true };
+ }
+
+ case ActionTypes.LoadedAlertRules: {
const alertRules: AlertRuleDTO[] = action.payload;
const alertRulesViewModel: AlertRule[] = alertRules.map(rule => {
return convertToAlertRule(rule, rule.state);
});
- return { items: alertRulesViewModel, searchQuery: state.searchQuery };
+ return { ...state, items: alertRulesViewModel, isLoading: false };
}
case ActionTypes.SetSearchQuery:
- return { items: state.items, searchQuery: action.payload };
+ return { ...state, searchQuery: action.payload };
}
return state;
diff --git a/public/app/features/all.ts b/public/app/features/all.ts
index b1283fdfd3e..83146596ea0 100644
--- a/public/app/features/all.ts
+++ b/public/app/features/all.ts
@@ -1,7 +1,7 @@
import './annotations/all';
import './templating/all';
import './plugins/all';
-import './dashboard/all';
+import './dashboard';
import './playlist/all';
import './panel/all';
import './org/all';
@@ -11,3 +11,4 @@ import './alerting/NotificationsListCtrl';
import './manage-dashboards';
import './teams/CreateTeamCtrl';
import './profile/all';
+import './datasources/settings/HttpSettingsCtrl';
diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts
index 4fe6e10b2cf..d728adfca2e 100644
--- a/public/app/features/annotations/annotations_srv.ts
+++ b/public/app/features/annotations/annotations_srv.ts
@@ -10,7 +10,7 @@ import coreModule from 'app/core/core_module';
import { makeRegions, dedupAnnotations } from './events_processing';
// Types
-import { DashboardModel } from '../dashboard/dashboard_model';
+import { DashboardModel } from '../dashboard/state/DashboardModel';
export class AnnotationsSrv {
globalAnnotationsPromise: any;
@@ -21,12 +21,16 @@ export class AnnotationsSrv {
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {}
init(dashboard: DashboardModel) {
+ // always clearPromiseCaches when loading new dashboard
+ this.clearPromiseCaches();
// clear promises on refresh events
- dashboard.on('refresh', () => {
- this.globalAnnotationsPromise = null;
- this.alertStatesPromise = null;
- this.datasourcePromises = null;
- });
+ dashboard.on('refresh', this.clearPromiseCaches.bind(this));
+ }
+
+ clearPromiseCaches() {
+ this.globalAnnotationsPromise = null;
+ this.alertStatesPromise = null;
+ this.datasourcePromises = null;
}
getAnnotations(options) {
diff --git a/public/app/features/annotations/event_manager.ts b/public/app/features/annotations/event_manager.ts
index db748e639a1..6966d3cdc82 100644
--- a/public/app/features/annotations/event_manager.ts
+++ b/public/app/features/annotations/event_manager.ts
@@ -1,8 +1,6 @@
import _ from 'lodash';
import moment from 'moment';
import tinycolor from 'tinycolor2';
-import { MetricsPanelCtrl } from 'app/plugins/sdk';
-import { AnnotationEvent } from './event';
import {
OK_COLOR,
ALERTING_COLOR,
@@ -10,7 +8,10 @@ import {
PENDING_COLOR,
DEFAULT_ANNOTATION_COLOR,
REGION_FILL_ALPHA,
-} from 'app/core/utils/colors';
+} from '@grafana/ui';
+
+import { MetricsPanelCtrl } from 'app/plugins/sdk';
+import { AnnotationEvent } from './event';
export class EventManager {
event: AnnotationEvent;
diff --git a/public/app/features/annotations/specs/annotations_srv.test.ts b/public/app/features/annotations/specs/annotations_srv.test.ts
index a00fc9b841d..f304c722b74 100644
--- a/public/app/features/annotations/specs/annotations_srv.test.ts
+++ b/public/app/features/annotations/specs/annotations_srv.test.ts
@@ -1,5 +1,3 @@
-import '../annotations_srv';
-import 'app/features/dashboard/time_srv';
import { AnnotationsSrv } from '../annotations_srv';
describe('AnnotationsSrv', () => {
diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx
index 54200234ddc..cd640b5a357 100644
--- a/public/app/features/api-keys/ApiKeysPage.test.tsx
+++ b/public/app/features/api-keys/ApiKeysPage.test.tsx
@@ -6,7 +6,14 @@ import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock';
const setup = (propOverrides?: object) => {
const props: Props = {
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Api Keys'
+ }
+ } as NavModel,
apiKeys: [] as ApiKey[],
searchQuery: '',
hasFetched: false,
diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx
index e14873fa9f6..41b9b0c8a55 100644
--- a/public/app/features/api-keys/ApiKeysPage.tsx
+++ b/public/app/features/api-keys/ApiKeysPage.tsx
@@ -6,8 +6,7 @@ import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { getApiKeys, getApiKeysCount } from './state/selectors';
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
+import Page from 'app/core/components/Page/Page';
import SlideDown from 'app/core/components/Animations/SlideDown';
import ApiKeysAddedModal from './ApiKeysAddedModal';
import config from 'app/core/config';
@@ -240,18 +239,17 @@ export class ApiKeysPage extends PureComponent {
const { hasFetched, navModel, apiKeysCount } = this.props;
return (
-
-
- {hasFetched ? (
- apiKeysCount > 0 ? (
- this.renderApiKeyList()
- ) : (
- this.renderEmptyList()
- )
- ) : (
-
- )}
-
+
+
+ {hasFetched && (
+ apiKeysCount > 0 ? (
+ this.renderApiKeyList()
+ ) : (
+ this.renderEmptyList()
+ )
+ )}
+
+
);
}
}
diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
index 7ede9618250..f40894426ae 100644
--- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
+++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
@@ -1,132 +1,152 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render API keys table if there are any keys 1`] = `
-
+
`;
exports[`Render should render CTA if there are no API keys 1`] = `
-
-
-
+
-
-
-
+
-
-
-
-
- Add API Key
-
-
-
-
-
-
-
+
+
+
+
+
+
`;
diff --git a/public/app/features/dashboard/alerting_srv.ts b/public/app/features/dashboard/alerting_srv.ts
deleted file mode 100644
index 446c3218f79..00000000000
--- a/public/app/features/dashboard/alerting_srv.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import coreModule from 'app/core/core_module';
-
-export class AlertingSrv {
- dashboard: any;
- alerts: any[];
-
- init(dashboard, alerts) {
- this.dashboard = dashboard;
- this.alerts = alerts || [];
- }
-}
-
-coreModule.service('alertingSrv', AlertingSrv);
diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts
deleted file mode 100644
index 5ec4e5e3929..00000000000
--- a/public/app/features/dashboard/all.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import './dashboard_ctrl';
-import './alerting_srv';
-import './history/history';
-import './dashboard_loader_srv';
-import './dashnav/dashnav';
-import './submenu/submenu';
-import './save_as_modal';
-import './save_modal';
-import './save_provisioned_modal';
-import './shareModalCtrl';
-import './share_snapshot_ctrl';
-import './dashboard_srv';
-import './view_state_srv';
-import './validation_srv';
-import './time_srv';
-import './unsaved_changes_srv';
-import './unsaved_changes_modal';
-import './timepicker/timepicker';
-import './upload';
-import './export/export_modal';
-import './export_data/export_data_modal';
-import './ad_hoc_filters';
-import './repeat_option/repeat_option';
-import './dashgrid/DashboardGridDirective';
-import './dashgrid/RowOptions';
-import './folder_picker/folder_picker';
-import './move_to_folder_modal/move_to_folder';
-import './settings/settings';
-import './panellinks/module';
-import './dashlinks/module';
-
-// angular wrappers
-import { react2AngularDirective } from 'app/core/utils/react2angular';
-import DashboardPermissions from './permissions/DashboardPermissions';
-
-react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);
-
-import coreModule from 'app/core/core_module';
-import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
-import { DashboardImportCtrl } from './dashboard_import_ctrl';
-import { CreateFolderCtrl } from './create_folder_ctrl';
-
-coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
-coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
-coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);
diff --git a/public/app/features/dashboard/ad_hoc_filters.ts b/public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/ad_hoc_filters.ts
rename to public/app/features/dashboard/components/AdHocFilters/AdHocFiltersCtrl.ts
diff --git a/public/app/features/dashboard/components/AdHocFilters/index.ts b/public/app/features/dashboard/components/AdHocFilters/index.ts
new file mode 100644
index 00000000000..522b564d004
--- /dev/null
+++ b/public/app/features/dashboard/components/AdHocFilters/index.ts
@@ -0,0 +1 @@
+export { AdHocFiltersCtrl } from './AdHocFiltersCtrl';
diff --git a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
similarity index 86%
rename from public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
rename to public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
index 95d03152b14..8c1ab93cec1 100644
--- a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
+++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
@@ -1,23 +1,23 @@
import React from 'react';
import _ from 'lodash';
import config from 'app/core/config';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import { PanelModel } from '../../state/PanelModel';
+import { DashboardModel } from '../../state/DashboardModel';
import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { updateLocation } from 'app/core/actions';
import { store as reduxStore } from 'app/store/store';
-export interface AddPanelPanelProps {
+export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
}
-export interface AddPanelPanelState {
+export interface State {
copiedPanelPlugins: any[];
}
-export class AddPanelPanel extends React.Component {
+export class AddPanelWidget extends React.Component {
constructor(props) {
super(props);
this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
@@ -133,15 +133,15 @@ export class AddPanelPanel extends React.Component
-
-
+
+
+
-
+
-
+
Edit Panel
diff --git a/public/sass/components/_panel_add_panel.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
similarity index 81%
rename from public/sass/components/_panel_add_panel.scss
rename to public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
index 86921fb43f3..5a1cbee4b44 100644
--- a/public/sass/components/_panel_add_panel.scss
+++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss
@@ -1,12 +1,12 @@
-.add-panel-container {
+.add-panel-widget-container {
height: 100%;
}
-.add-panel {
+.add-panel-widget {
height: 100%;
}
-.add-panel__header {
+.add-panel-widget__header {
top: 0;
position: absolute;
padding: 0 15px;
@@ -26,7 +26,7 @@
}
}
-.add-panel__close {
+.add-panel-widget__close {
margin-left: auto;
background-color: transparent;
border: 0;
@@ -34,7 +34,7 @@
margin-right: -10px;
}
-.add-panel-btn-container {
+.add-panel-widget__btn-container {
display: flex;
justify-content: center;
align-items: center;
diff --git a/public/app/features/dashboard/components/AddPanelWidget/index.ts b/public/app/features/dashboard/components/AddPanelWidget/index.ts
new file mode 100644
index 00000000000..b96948ab1c0
--- /dev/null
+++ b/public/app/features/dashboard/components/AddPanelWidget/index.ts
@@ -0,0 +1 @@
+export { AddPanelWidget } from './AddPanelWidget';
diff --git a/public/app/features/dashboard/export/export_modal.ts b/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts
similarity index 92%
rename from public/app/features/dashboard/export/export_modal.ts
rename to public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts
index 8136c77cd8f..7769bdf114a 100644
--- a/public/app/features/dashboard/export/export_modal.ts
+++ b/public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts
@@ -2,7 +2,7 @@ import angular from 'angular';
import { saveAs } from 'file-saver';
import coreModule from 'app/core/core_module';
-import { DashboardExporter } from './exporter';
+import { DashboardExporter } from './DashboardExporter';
export class DashExportCtrl {
dash: any;
@@ -66,7 +66,7 @@ export class DashExportCtrl {
export function dashExportDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/export/export_modal.html',
+ templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html',
controller: DashExportCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/specs/exporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
similarity index 98%
rename from public/app/features/dashboard/specs/exporter.test.ts
rename to public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
index eac6b0b272a..ac1b5f08632 100644
--- a/public/app/features/dashboard/specs/exporter.test.ts
+++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
@@ -6,8 +6,8 @@ jest.mock('app/core/store', () => {
import _ from 'lodash';
import config from 'app/core/config';
-import { DashboardExporter } from '../export/exporter';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardExporter } from './DashboardExporter';
+import { DashboardModel } from '../../state/DashboardModel';
describe('given dashboard with repeated panels', () => {
let dash, exported;
diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
similarity index 98%
rename from public/app/features/dashboard/export/exporter.ts
rename to public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
index 7aecb5c384f..6cf14f81c86 100644
--- a/public/app/features/dashboard/export/exporter.ts
+++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
@@ -1,6 +1,6 @@
import config from 'app/core/config';
import _ from 'lodash';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../state/DashboardModel';
export class DashboardExporter {
constructor(private datasourceSrv) {}
diff --git a/public/app/features/dashboard/components/DashExportModal/index.ts b/public/app/features/dashboard/components/DashExportModal/index.ts
new file mode 100644
index 00000000000..6529cf07ad9
--- /dev/null
+++ b/public/app/features/dashboard/components/DashExportModal/index.ts
@@ -0,0 +1,2 @@
+export { DashboardExporter } from './DashboardExporter';
+export { DashExportCtrl } from './DashExportCtrl';
diff --git a/public/app/features/dashboard/export/export_modal.html b/public/app/features/dashboard/components/DashExportModal/template.html
similarity index 100%
rename from public/app/features/dashboard/export/export_modal.html
rename to public/app/features/dashboard/components/DashExportModal/template.html
diff --git a/public/app/features/dashboard/dashlinks/module.ts b/public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts
similarity index 99%
rename from public/app/features/dashboard/dashlinks/module.ts
rename to public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts
index c951538d45d..a08e438a46c 100644
--- a/public/app/features/dashboard/dashlinks/module.ts
+++ b/public/app/features/dashboard/components/DashLinks/DashLinksContainerCtrl.ts
@@ -1,6 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
-import { iconMap } from './editor';
+import { iconMap } from './DashLinksEditorCtrl';
function dashLinksContainer() {
return {
diff --git a/public/app/features/dashboard/dashlinks/editor.ts b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
similarity index 90%
rename from public/app/features/dashboard/dashlinks/editor.ts
rename to public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
index 482052469db..398ad757bf3 100644
--- a/public/app/features/dashboard/dashlinks/editor.ts
+++ b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts
@@ -11,7 +11,7 @@ export let iconMap = {
cloud: 'fa-cloud',
};
-export class DashLinkEditorCtrl {
+export class DashLinksEditorCtrl {
dashboard: any;
iconMap: any;
mode: any;
@@ -65,8 +65,8 @@ export class DashLinkEditorCtrl {
function dashLinksEditor() {
return {
restrict: 'E',
- controller: DashLinkEditorCtrl,
- templateUrl: 'public/app/features/dashboard/dashlinks/editor.html',
+ controller: DashLinksEditorCtrl,
+ templateUrl: 'public/app/features/dashboard/components/DashLinks/editor.html',
bindToController: true,
controllerAs: 'ctrl',
scope: {
diff --git a/public/app/features/dashboard/dashlinks/editor.html b/public/app/features/dashboard/components/DashLinks/editor.html
similarity index 100%
rename from public/app/features/dashboard/dashlinks/editor.html
rename to public/app/features/dashboard/components/DashLinks/editor.html
diff --git a/public/app/features/dashboard/components/DashLinks/index.ts b/public/app/features/dashboard/components/DashLinks/index.ts
new file mode 100644
index 00000000000..ef118d4a84c
--- /dev/null
+++ b/public/app/features/dashboard/components/DashLinks/index.ts
@@ -0,0 +1,2 @@
+export { DashLinksContainerCtrl } from './DashLinksContainerCtrl';
+export { DashLinksEditorCtrl } from './DashLinksEditorCtrl';
diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
similarity index 92%
rename from public/app/features/dashboard/dashnav/dashnav.ts
rename to public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
index 1c83b2d0bdb..e75c1468a1f 100644
--- a/public/app/features/dashboard/dashnav/dashnav.ts
+++ b/public/app/features/dashboard/components/DashNav/DashNavCtrl.ts
@@ -1,7 +1,7 @@
import moment from 'moment';
import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../state/DashboardModel';
export class DashNavCtrl {
dashboard: DashboardModel;
@@ -60,7 +60,7 @@ export class DashNavCtrl {
modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', {
- src: 'public/app/features/dashboard/partials/shareModal.html',
+ src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
}
@@ -107,7 +107,7 @@ export class DashNavCtrl {
export function dashNavDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/dashnav/dashnav.html',
+ templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/components/DashNav/index.ts b/public/app/features/dashboard/components/DashNav/index.ts
new file mode 100644
index 00000000000..854e32b24d2
--- /dev/null
+++ b/public/app/features/dashboard/components/DashNav/index.ts
@@ -0,0 +1 @@
+export { DashNavCtrl } from './DashNavCtrl';
diff --git a/public/app/features/dashboard/dashnav/dashnav.html b/public/app/features/dashboard/components/DashNav/template.html
similarity index 95%
rename from public/app/features/dashboard/dashnav/dashnav.html
rename to public/app/features/dashboard/components/DashNav/template.html
index 74e00fea798..e50a8cd0bff 100644
--- a/public/app/features/dashboard/dashnav/dashnav.html
+++ b/public/app/features/dashboard/components/DashNav/template.html
@@ -37,7 +37,7 @@
-
+
diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx
similarity index 89%
rename from public/app/features/dashboard/permissions/DashboardPermissions.tsx
rename to public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx
index c07bef42930..ce6a866ce57 100644
--- a/public/app/features/dashboard/permissions/DashboardPermissions.tsx
+++ b/public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx
@@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
-import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import { Tooltip } from '@grafana/ui';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { StoreState, FolderInfo } from 'app/types';
import { DashboardAcl, PermissionLevel, NewDashboardAclItem } from 'app/types/acl';
@@ -8,11 +8,11 @@ import {
addDashboardPermission,
removeDashboardPermission,
updateDashboardPermission,
-} from '../state/actions';
+} from '../../state/actions';
import PermissionList from 'app/core/components/PermissionList/PermissionList';
import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
-import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
+import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
export interface Props {
dashboardId: number;
@@ -70,8 +70,10 @@ export class DashboardPermissions extends PureComponent
{
Permissions
-
-
+ }>
+
+
+
diff --git a/public/app/features/dashboard/specs/DashboardRow.test.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
similarity index 93%
rename from public/app/features/dashboard/specs/DashboardRow.test.tsx
rename to public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
index 77c6cb39d9d..9ac6a6b74e1 100644
--- a/public/app/features/dashboard/specs/DashboardRow.test.tsx
+++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { DashboardRow } from '../dashgrid/DashboardRow';
-import { PanelModel } from '../panel_model';
+import { DashboardRow } from './DashboardRow';
+import { PanelModel } from '../../state/PanelModel';
describe('DashboardRow', () => {
let wrapper, panel, dashboardMock;
diff --git a/public/app/features/dashboard/dashgrid/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
similarity index 96%
rename from public/app/features/dashboard/dashgrid/DashboardRow.tsx
rename to public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
index 5b8ced9b2b1..f9a56718c5e 100644
--- a/public/app/features/dashboard/dashgrid/DashboardRow.tsx
+++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import classNames from 'classnames';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import { PanelModel } from '../../state/PanelModel';
+import { DashboardModel } from '../../state/DashboardModel';
import templateSrv from 'app/features/templating/template_srv';
import appEvents from 'app/core/app_events';
diff --git a/public/app/features/dashboard/components/DashboardRow/index.ts b/public/app/features/dashboard/components/DashboardRow/index.ts
new file mode 100644
index 00000000000..3f71e03c80c
--- /dev/null
+++ b/public/app/features/dashboard/components/DashboardRow/index.ts
@@ -0,0 +1 @@
+export { DashboardRow } from './DashboardRow';
diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
similarity index 97%
rename from public/app/features/dashboard/settings/settings.ts
rename to public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
index 1e8d96a54cb..e5cfac97d5f 100755
--- a/public/app/features/dashboard/settings/settings.ts
+++ b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts
@@ -1,5 +1,5 @@
import { coreModule, appEvents, contextSrv } from 'app/core/core';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../state/DashboardModel';
import $ from 'jquery';
import _ from 'lodash';
import angular from 'angular';
@@ -230,7 +230,7 @@ export class SettingsCtrl {
export function dashboardSettings() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/settings/settings.html',
+ templateUrl: 'public/app/features/dashboard/components/DashboardSettings/template.html',
controller: SettingsCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/components/DashboardSettings/index.ts b/public/app/features/dashboard/components/DashboardSettings/index.ts
new file mode 100644
index 00000000000..f81b8cdbc67
--- /dev/null
+++ b/public/app/features/dashboard/components/DashboardSettings/index.ts
@@ -0,0 +1 @@
+export { SettingsCtrl } from './SettingsCtrl';
diff --git a/public/app/features/dashboard/settings/settings.html b/public/app/features/dashboard/components/DashboardSettings/template.html
similarity index 98%
rename from public/app/features/dashboard/settings/settings.html
rename to public/app/features/dashboard/components/DashboardSettings/template.html
index 46d84a7a2fd..97002f7bf92 100644
--- a/public/app/features/dashboard/settings/settings.html
+++ b/public/app/features/dashboard/components/DashboardSettings/template.html
@@ -51,7 +51,8 @@
on-change="ctrl.onFolderChange($folder)"
enable-create-new="true"
is-valid-selection="true"
- label-class="width-7">
+ label-class="width-7"
+ dashboard-id="ctrl.dashboard.id">
diff --git a/public/app/features/dashboard/export_data/export_data_modal.ts b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts
similarity index 92%
rename from public/app/features/dashboard/export_data/export_data_modal.ts
rename to public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts
index 460f80079d9..f87daa94ee7 100644
--- a/public/app/features/dashboard/export_data/export_data_modal.ts
+++ b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts
@@ -31,7 +31,7 @@ export class ExportDataModalCtrl {
export function exportDataModal() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/export_data/export_data_modal.html',
+ templateUrl: 'public/app/features/dashboard/components/ExportDataModal/template.html',
controller: ExportDataModalCtrl,
controllerAs: 'ctrl',
scope: {
diff --git a/public/app/features/dashboard/components/ExportDataModal/index.ts b/public/app/features/dashboard/components/ExportDataModal/index.ts
new file mode 100644
index 00000000000..6df4fd00434
--- /dev/null
+++ b/public/app/features/dashboard/components/ExportDataModal/index.ts
@@ -0,0 +1 @@
+export { ExportDataModalCtrl } from './ExportDataModalCtrl';
diff --git a/public/app/features/dashboard/export_data/export_data_modal.html b/public/app/features/dashboard/components/ExportDataModal/template.html
similarity index 100%
rename from public/app/features/dashboard/export_data/export_data_modal.html
rename to public/app/features/dashboard/components/ExportDataModal/template.html
diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts
similarity index 90%
rename from public/app/features/dashboard/folder_picker/folder_picker.ts
rename to public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts
index 80651fecb7e..93d43d36038 100644
--- a/public/app/features/dashboard/folder_picker/folder_picker.ts
+++ b/public/app/features/dashboard/components/FolderPicker/FolderPickerCtrl.ts
@@ -21,6 +21,7 @@ export class FolderPickerCtrl {
hasValidationError: boolean;
validationError: any;
isEditor: boolean;
+ dashboardId?: number;
/** @ngInject */
constructor(private backendSrv, private validationSrv, private contextSrv) {
@@ -144,7 +145,13 @@ export class FolderPickerCtrl {
if (this.isEditor) {
folder = rootFolder;
} else {
- folder = result.length > 0 ? result[0] : resetFolder;
+ // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard
+ const isPersistedDashBoard = this.dashboardId ? true : false;
+ if (isPersistedDashBoard) {
+ folder = resetFolder;
+ } else {
+ folder = result.length > 0 ? result[0] : resetFolder;
+ }
}
}
@@ -161,7 +168,7 @@ export class FolderPickerCtrl {
export function folderPicker() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/folder_picker/folder_picker.html',
+ templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html',
controller: FolderPickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@@ -176,6 +183,7 @@ export function folderPicker() {
exitFolderCreation: '&',
enableCreateNew: '@',
enableReset: '@',
+ dashboardId: '',
},
};
}
diff --git a/public/app/features/dashboard/components/FolderPicker/index.ts b/public/app/features/dashboard/components/FolderPicker/index.ts
new file mode 100644
index 00000000000..7550f7fd573
--- /dev/null
+++ b/public/app/features/dashboard/components/FolderPicker/index.ts
@@ -0,0 +1 @@
+export { FolderPickerCtrl } from './FolderPickerCtrl';
diff --git a/public/app/features/dashboard/folder_picker/folder_picker.html b/public/app/features/dashboard/components/FolderPicker/template.html
similarity index 100%
rename from public/app/features/dashboard/folder_picker/folder_picker.html
rename to public/app/features/dashboard/components/FolderPicker/template.html
diff --git a/public/app/features/dashboard/dashgrid/RowOptions.ts b/public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts
similarity index 89%
rename from public/app/features/dashboard/dashgrid/RowOptions.ts
rename to public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts
index a855791f1ea..d2526c92cd0 100644
--- a/public/app/features/dashboard/dashgrid/RowOptions.ts
+++ b/public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts
@@ -24,7 +24,7 @@ export class RowOptionsCtrl {
export function rowOptionsDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/partials/row_options.html',
+ templateUrl: 'public/app/features/dashboard/components/RowOptions/template.html',
controller: RowOptionsCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/components/RowOptions/index.ts b/public/app/features/dashboard/components/RowOptions/index.ts
new file mode 100644
index 00000000000..626e4cd65b3
--- /dev/null
+++ b/public/app/features/dashboard/components/RowOptions/index.ts
@@ -0,0 +1 @@
+export { RowOptionsCtrl } from './RowOptionsCtrl';
diff --git a/public/app/features/dashboard/partials/row_options.html b/public/app/features/dashboard/components/RowOptions/template.html
similarity index 100%
rename from public/app/features/dashboard/partials/row_options.html
rename to public/app/features/dashboard/components/RowOptions/template.html
diff --git a/public/app/features/dashboard/specs/save_as_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.test.ts
similarity index 95%
rename from public/app/features/dashboard/specs/save_as_modal.test.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.test.ts
index ceb7e49c550..91b9097b626 100644
--- a/public/app/features/dashboard/specs/save_as_modal.test.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.test.ts
@@ -1,4 +1,4 @@
-import { SaveDashboardAsModalCtrl } from '../save_as_modal';
+import { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
import { describe, it, expect } from 'test/lib/common';
describe('saving dashboard as', () => {
diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
similarity index 96%
rename from public/app/features/dashboard/save_as_modal.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
index 4649bc18f9f..6a470785fdb 100644
--- a/public/app/features/dashboard/save_as_modal.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
@@ -25,7 +25,8 @@ const template = `
enter-folder-creation="ctrl.onEnterFolderCreation()"
exit-folder-creation="ctrl.onExitFolderCreation()"
enable-create-new="true"
- label-class="width-7">
+ label-class="width-7"
+ dashboard-id="ctrl.clone.id">
diff --git a/public/app/features/dashboard/specs/save_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts
similarity index 97%
rename from public/app/features/dashboard/specs/save_modal.test.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts
index 669ae43a0ff..f973c1b8e63 100644
--- a/public/app/features/dashboard/specs/save_modal.test.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.test.ts
@@ -1,4 +1,4 @@
-import { SaveDashboardModalCtrl } from '../save_modal';
+import { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
const setup = (timeChanged, variableValuesChanged, cb) => {
const dash = {
diff --git a/public/app/features/dashboard/save_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/save_modal.ts
rename to public/app/features/dashboard/components/SaveModals/SaveDashboardModalCtrl.ts
diff --git a/public/app/features/dashboard/specs/save_provisioned_modal.test.ts b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts
similarity index 87%
rename from public/app/features/dashboard/specs/save_provisioned_modal.test.ts
rename to public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts
index a3ab27a984f..86048e861bd 100644
--- a/public/app/features/dashboard/specs/save_provisioned_modal.test.ts
+++ b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.test.ts
@@ -1,4 +1,4 @@
-import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal';
+import { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';
describe('SaveProvisionedDashboardModalCtrl', () => {
const json = {
diff --git a/public/app/features/dashboard/save_provisioned_modal.ts b/public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/save_provisioned_modal.ts
rename to public/app/features/dashboard/components/SaveModals/SaveProvisionedDashboardModalCtrl.ts
diff --git a/public/app/features/dashboard/components/SaveModals/index.ts b/public/app/features/dashboard/components/SaveModals/index.ts
new file mode 100644
index 00000000000..afab0796d28
--- /dev/null
+++ b/public/app/features/dashboard/components/SaveModals/index.ts
@@ -0,0 +1,2 @@
+export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
+export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
diff --git a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
similarity index 96%
rename from public/app/features/dashboard/specs/share_modal_ctrl.test.ts
rename to public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
index 70d301ed5ff..3181231cb53 100644
--- a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts
+++ b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
@@ -1,7 +1,6 @@
-import '../shareModalCtrl';
-import { ShareModalCtrl } from '../shareModalCtrl';
import config from 'app/core/config';
-import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
+import { ShareModalCtrl } from './ShareModalCtrl';
describe('ShareModalCtrl', () => {
const ctx = {
diff --git a/public/app/features/dashboard/shareModalCtrl.ts b/public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/shareModalCtrl.ts
rename to public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts
diff --git a/public/app/features/dashboard/share_snapshot_ctrl.ts b/public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/share_snapshot_ctrl.ts
rename to public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts
diff --git a/public/app/features/dashboard/components/ShareModal/index.ts b/public/app/features/dashboard/components/ShareModal/index.ts
new file mode 100644
index 00000000000..3f27d5a1ba3
--- /dev/null
+++ b/public/app/features/dashboard/components/ShareModal/index.ts
@@ -0,0 +1,2 @@
+export { ShareModalCtrl } from './ShareModalCtrl';
+export { ShareSnapshotCtrl } from './ShareSnapshotCtrl';
diff --git a/public/app/features/dashboard/partials/shareModal.html b/public/app/features/dashboard/components/ShareModal/template.html
similarity index 100%
rename from public/app/features/dashboard/partials/shareModal.html
rename to public/app/features/dashboard/components/ShareModal/template.html
diff --git a/public/app/features/dashboard/submenu/submenu.ts b/public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts
similarity index 86%
rename from public/app/features/dashboard/submenu/submenu.ts
rename to public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts
index 184d29facee..502e467ad2b 100644
--- a/public/app/features/dashboard/submenu/submenu.ts
+++ b/public/app/features/dashboard/components/SubMenu/SubMenuCtrl.ts
@@ -1,7 +1,7 @@
import angular from 'angular';
import _ from 'lodash';
-export class SubmenuCtrl {
+export class SubMenuCtrl {
annotations: any;
variables: any;
dashboard: any;
@@ -29,8 +29,8 @@ export class SubmenuCtrl {
export function submenuDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/submenu/submenu.html',
- controller: SubmenuCtrl,
+ templateUrl: 'public/app/features/dashboard/components/SubMenu/template.html',
+ controller: SubMenuCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
diff --git a/public/app/features/dashboard/components/SubMenu/index.ts b/public/app/features/dashboard/components/SubMenu/index.ts
new file mode 100644
index 00000000000..1790aa66782
--- /dev/null
+++ b/public/app/features/dashboard/components/SubMenu/index.ts
@@ -0,0 +1 @@
+export { SubMenuCtrl } from './SubMenuCtrl';
diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/components/SubMenu/template.html
similarity index 100%
rename from public/app/features/dashboard/submenu/submenu.html
rename to public/app/features/dashboard/components/SubMenu/template.html
diff --git a/public/app/features/dashboard/timepicker/timepicker.ts b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts
similarity index 95%
rename from public/app/features/dashboard/timepicker/timepicker.ts
rename to public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts
index c89e49b54b3..0c388c27f8d 100644
--- a/public/app/features/dashboard/timepicker/timepicker.ts
+++ b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts
@@ -159,7 +159,7 @@ export class TimePickerCtrl {
export function settingsDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/timepicker/settings.html',
+ templateUrl: 'public/app/features/dashboard/components/TimePicker/settings.html',
controller: TimePickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@@ -172,7 +172,7 @@ export function settingsDirective() {
export function timePickerDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/timepicker/timepicker.html',
+ templateUrl: 'public/app/features/dashboard/components/TimePicker/template.html',
controller: TimePickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@@ -185,5 +185,5 @@ export function timePickerDirective() {
angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective);
angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
-import { inputDateDirective } from './input_date';
+import { inputDateDirective } from './validation';
angular.module('grafana.directives').directive('inputDatetime', inputDateDirective);
diff --git a/public/app/features/dashboard/components/TimePicker/index.ts b/public/app/features/dashboard/components/TimePicker/index.ts
new file mode 100644
index 00000000000..ca6e2792c43
--- /dev/null
+++ b/public/app/features/dashboard/components/TimePicker/index.ts
@@ -0,0 +1 @@
+export { TimePickerCtrl } from './TimePickerCtrl';
diff --git a/public/app/features/dashboard/timepicker/settings.html b/public/app/features/dashboard/components/TimePicker/settings.html
similarity index 100%
rename from public/app/features/dashboard/timepicker/settings.html
rename to public/app/features/dashboard/components/TimePicker/settings.html
diff --git a/public/app/features/dashboard/timepicker/timepicker.html b/public/app/features/dashboard/components/TimePicker/template.html
similarity index 100%
rename from public/app/features/dashboard/timepicker/timepicker.html
rename to public/app/features/dashboard/components/TimePicker/template.html
diff --git a/public/app/features/dashboard/timepicker/input_date.ts b/public/app/features/dashboard/components/TimePicker/validation.ts
similarity index 100%
rename from public/app/features/dashboard/timepicker/input_date.ts
rename to public/app/features/dashboard/components/TimePicker/validation.ts
diff --git a/public/app/features/dashboard/unsaved_changes_modal.ts b/public/app/features/dashboard/components/UnsavedChangesModal/UnsavedChangesModalCtrl.ts
similarity index 100%
rename from public/app/features/dashboard/unsaved_changes_modal.ts
rename to public/app/features/dashboard/components/UnsavedChangesModal/UnsavedChangesModalCtrl.ts
diff --git a/public/app/features/dashboard/components/UnsavedChangesModal/index.ts b/public/app/features/dashboard/components/UnsavedChangesModal/index.ts
new file mode 100644
index 00000000000..43943f06694
--- /dev/null
+++ b/public/app/features/dashboard/components/UnsavedChangesModal/index.ts
@@ -0,0 +1 @@
+export { UnsavedChangesModalCtrl } from './UnsavedChangesModalCtrl';
diff --git a/public/app/features/dashboard/specs/history_ctrl.test.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
similarity index 98%
rename from public/app/features/dashboard/specs/history_ctrl.test.ts
rename to public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
index 632f3489dae..2b257e148f5 100644
--- a/public/app/features/dashboard/specs/history_ctrl.test.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.test.ts
@@ -1,6 +1,6 @@
import _ from 'lodash';
-import { HistoryListCtrl } from 'app/features/dashboard/history/history';
-import { versions, compare, restore } from './history_mocks';
+import { HistoryListCtrl } from './HistoryListCtrl';
+import { versions, compare, restore } from './__mocks__/history';
import $q from 'q';
describe('HistoryListCtrl', () => {
diff --git a/public/app/features/dashboard/history/history.ts b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
similarity index 96%
rename from public/app/features/dashboard/history/history.ts
rename to public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
index 3563ccc7766..19795ffc564 100644
--- a/public/app/features/dashboard/history/history.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistoryListCtrl.ts
@@ -1,12 +1,10 @@
-import './history_srv';
-
import _ from 'lodash';
import angular from 'angular';
import moment from 'moment';
import locationUtil from 'app/core/utils/location_util';
-import { DashboardModel } from '../dashboard_model';
-import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
+import { DashboardModel } from '../../state/DashboardModel';
+import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './HistorySrv';
export class HistoryListCtrl {
appending: boolean;
@@ -200,7 +198,7 @@ export class HistoryListCtrl {
export function dashboardHistoryDirective() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/history/history.html',
+ templateUrl: 'public/app/features/dashboard/components/VersionHistory/template.html',
controller: HistoryListCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/dashboard/specs/history_srv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
similarity index 90%
rename from public/app/features/dashboard/specs/history_srv.test.ts
rename to public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
index 1e2bd57a221..04f0eff1cb8 100644
--- a/public/app/features/dashboard/specs/history_srv.test.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts
@@ -1,7 +1,6 @@
-import '../history/history_srv';
-import { versions, restore } from './history_mocks';
-import { HistorySrv } from '../history/history_srv';
-import { DashboardModel } from '../dashboard_model';
+import { versions, restore } from './__mocks__/history';
+import { HistorySrv } from './HistorySrv';
+import { DashboardModel } from '../../state/DashboardModel';
jest.mock('app/core/store');
describe('historySrv', () => {
diff --git a/public/app/features/dashboard/history/history_srv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
similarity index 95%
rename from public/app/features/dashboard/history/history_srv.ts
rename to public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
index 7f7dc950de3..a06212f9a7a 100644
--- a/public/app/features/dashboard/history/history_srv.ts
+++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
@@ -1,6 +1,6 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../../state/DashboardModel';
export interface HistoryListOpts {
limit: number;
diff --git a/public/app/features/dashboard/specs/history_mocks.ts b/public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts
similarity index 100%
rename from public/app/features/dashboard/specs/history_mocks.ts
rename to public/app/features/dashboard/components/VersionHistory/__mocks__/history.ts
diff --git a/public/app/features/dashboard/components/VersionHistory/index.ts b/public/app/features/dashboard/components/VersionHistory/index.ts
new file mode 100644
index 00000000000..138de434bf3
--- /dev/null
+++ b/public/app/features/dashboard/components/VersionHistory/index.ts
@@ -0,0 +1,2 @@
+export { HistoryListCtrl } from './HistoryListCtrl';
+export { HistorySrv } from './HistorySrv';
diff --git a/public/app/features/dashboard/history/history.html b/public/app/features/dashboard/components/VersionHistory/template.html
similarity index 100%
rename from public/app/features/dashboard/history/history.html
rename to public/app/features/dashboard/components/VersionHistory/template.html
diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/containers/DashboardCtrl.ts
similarity index 95%
rename from public/app/features/dashboard/dashboard_ctrl.ts
rename to public/app/features/dashboard/containers/DashboardCtrl.ts
index 6611a728803..74795315504 100644
--- a/public/app/features/dashboard/dashboard_ctrl.ts
+++ b/public/app/features/dashboard/containers/DashboardCtrl.ts
@@ -5,10 +5,10 @@ import coreModule from 'app/core/core_module';
import { removePanel } from 'app/features/dashboard/utils/panel';
// Services
-import { AnnotationsSrv } from '../annotations/annotations_srv';
+import { AnnotationsSrv } from '../../annotations/annotations_srv';
// Types
-import { DashboardModel } from './dashboard_model';
+import { DashboardModel } from '../state/DashboardModel';
export class DashboardCtrl {
dashboard: DashboardModel;
@@ -22,7 +22,6 @@ export class DashboardCtrl {
private keybindingSrv,
private timeSrv,
private variableSrv,
- private alertingSrv,
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
@@ -54,7 +53,6 @@ export class DashboardCtrl {
// init services
this.timeSrv.init(dashboard);
- this.alertingSrv.init(dashboard, data.alerts);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
diff --git a/public/app/features/dashboard/dashgrid/AlertTab.tsx b/public/app/features/dashboard/dashgrid/AlertTab.tsx
deleted file mode 100644
index 7df7864c758..00000000000
--- a/public/app/features/dashboard/dashgrid/AlertTab.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React, { PureComponent } from 'react';
-
-import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
-import { EditorTabBody } from './EditorTabBody';
-import 'app/features/alerting/AlertTabCtrl';
-
-interface Props {
- angularPanel?: AngularComponent;
-}
-
-export class AlertTab extends PureComponent {
- element: any;
- component: AngularComponent;
-
- constructor(props) {
- super(props);
- }
-
- componentDidMount() {
- if (this.shouldLoadAlertTab()) {
- this.loadAlertTab();
- }
- }
-
- componentDidUpdate(prevProps: Props) {
- if (this.shouldLoadAlertTab()) {
- this.loadAlertTab();
- }
- }
-
- shouldLoadAlertTab() {
- return this.props.angularPanel && this.element;
- }
-
- componentWillUnmount() {
- if (this.component) {
- this.component.destroy();
- }
- }
-
- loadAlertTab() {
- const { angularPanel } = this.props;
-
- const scope = angularPanel.getScope();
-
- // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
- if (!scope.$$childHead) {
- setTimeout(() => {
- this.forceUpdate();
- });
- return;
- }
-
- const panelCtrl = scope.$$childHead.ctrl;
- const loader = getAngularLoader();
- const template = ' ';
-
- const scopeProps = {
- ctrl: panelCtrl,
- };
-
- this.component = loader.load(this.element, scopeProps, template);
- }
-
- render() {
- return (
-
- (this.element = element)} />
-
- );
- }
-}
diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
index a401505b787..658bfad3816 100644
--- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx
@@ -1,16 +1,30 @@
import React from 'react';
import { hot } from 'react-hot-loader';
-import ReactGridLayout from 'react-grid-layout';
+import ReactGridLayout, { ItemCallback } from 'react-grid-layout';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel';
-import { DashboardModel } from '../dashboard_model';
-import { PanelModel } from '../panel_model';
+import { DashboardModel, PanelModel } from '../state';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
let lastGridWidth = 1200;
let ignoreNextWidthChange = false;
+interface GridWrapperProps {
+ size: { width: number; };
+ layout: ReactGridLayout.Layout[];
+ onLayoutChange: (layout: ReactGridLayout.Layout[]) => void;
+ children: JSX.Element | JSX.Element[];
+ onDragStop: ItemCallback;
+ onResize: ItemCallback;
+ onResizeStop: ItemCallback;
+ onWidthChange: () => void;
+ className: string;
+ isResizable?: boolean;
+ isDraggable?: boolean;
+ isFullscreen?: boolean;
+}
+
function GridWrapper({
size,
layout,
@@ -24,7 +38,7 @@ function GridWrapper({
isResizable,
isDraggable,
isFullscreen,
-}) {
+}: GridWrapperProps) {
const width = size.width > 0 ? size.width : lastGridWidth;
// logic to ignore width changes (optimization)
@@ -43,7 +57,6 @@ function GridWrapper({
className={className}
isDraggable={isDraggable}
isResizable={isResizable}
- measureBeforeMount={false}
containerPadding={[0, 0]}
useCSSTransforms={false}
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
@@ -71,22 +84,17 @@ export class DashboardGrid extends React.Component
{
gridToPanelMap: any;
panelMap: { [id: string]: PanelModel };
- constructor(props) {
+ constructor(props: DashboardGridProps) {
super(props);
- this.onLayoutChange = this.onLayoutChange.bind(this);
- this.onResize = this.onResize.bind(this);
- this.onResizeStop = this.onResizeStop.bind(this);
- this.onDragStop = this.onDragStop.bind(this);
- this.onWidthChange = this.onWidthChange.bind(this);
// subscribe to dashboard events
const dashboard = this.props.dashboard;
- dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
- dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
- dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
- dashboard.on('view-mode-changed', this.onViewModeChanged.bind(this));
- dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
- dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
+ dashboard.on('panel-added', this.triggerForceUpdate);
+ dashboard.on('panel-removed', this.triggerForceUpdate);
+ dashboard.on('repeats-processed', this.triggerForceUpdate);
+ dashboard.on('view-mode-changed', this.onViewModeChanged);
+ dashboard.on('row-collapsed', this.triggerForceUpdate);
+ dashboard.on('row-expanded', this.triggerForceUpdate);
}
buildLayout() {
@@ -123,7 +131,7 @@ export class DashboardGrid extends React.Component {
return layout;
}
- onLayoutChange(newLayout) {
+ onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => {
for (const newPos of newLayout) {
this.panelMap[newPos.i].updateGridPos(newPos);
}
@@ -131,22 +139,22 @@ export class DashboardGrid extends React.Component {
this.props.dashboard.sortPanelsByGridPos();
}
- triggerForceUpdate() {
+ triggerForceUpdate = () => {
this.forceUpdate();
}
- onWidthChange() {
+ onWidthChange = () => {
for (const panel of this.props.dashboard.panels) {
panel.resizeDone();
}
}
- onViewModeChanged(payload) {
+ onViewModeChanged = () => {
ignoreNextWidthChange = true;
this.forceUpdate();
}
- updateGridPos(item, layout) {
+ updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => {
this.panelMap[item.i].updateGridPos(item);
// react-grid-layout has a bug (#670), and onLayoutChange() is only called when the component is mounted.
@@ -154,16 +162,17 @@ export class DashboardGrid extends React.Component {
this.onLayoutChange(layout);
}
- onResize(layout, oldItem, newItem) {
+ onResize: ItemCallback = (layout, oldItem, newItem) => {
+ console.log();
this.panelMap[newItem.i].updateGridPos(newItem);
}
- onResizeStop(layout, oldItem, newItem) {
+ onResizeStop: ItemCallback = (layout, oldItem, newItem) => {
this.updateGridPos(newItem, layout);
this.panelMap[newItem.i].resizeDone();
}
- onDragStop(layout, oldItem, newItem) {
+ onDragStop: ItemCallback = (layout, oldItem, newItem) => {
this.updateGridPos(newItem, layout);
}
diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
index 9de298e0799..2d794bec4d4 100644
--- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
@@ -5,14 +5,13 @@ import classNames from 'classnames';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { importPluginModule } from 'app/features/plugins/plugin_loader';
-import { AddPanelPanel } from './AddPanelPanel';
+import { AddPanelWidget } from '../components/AddPanelWidget';
import { getPanelPluginNotFound } from './PanelPluginNotFound';
-import { DashboardRow } from './DashboardRow';
+import { DashboardRow } from '../components/DashboardRow';
import { PanelChrome } from './PanelChrome';
-import { PanelEditor } from './PanelEditor';
+import { PanelEditor } from '../panel_editor/PanelEditor';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import { PanelModel, DashboardModel } from '../state';
import { PanelPlugin } from 'app/types';
import { PanelResizer } from './PanelResizer';
@@ -53,7 +52,7 @@ export class DashboardPanel extends PureComponent {
}
renderAddPanel() {
- return ;
+ return ;
}
onPluginTypeChanged = (plugin: PanelPlugin) => {
diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx
index 30a939b50aa..353c474bcd9 100644
--- a/public/app/features/dashboard/dashgrid/DataPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx
@@ -1,19 +1,28 @@
// Library
import React, { Component } from 'react';
+import { Tooltip } from '@grafana/ui';
+import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary';
// Services
-import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource_srv';
-
+import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource_srv';
// Utils
import kbn from 'app/core/utils/kbn';
-
// Types
-import { DataQueryOptions, DataQueryResponse } from 'app/types';
-import { TimeRange, TimeSeries, LoadingState } from '@grafana/ui';
+import {
+ DataQueryOptions,
+ DataQueryResponse,
+ LoadingState,
+ PanelData,
+ TableData,
+ TimeRange,
+ TimeSeries,
+} from '@grafana/ui';
+
+const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
interface RenderProps {
loading: LoadingState;
- timeSeries: TimeSeries[];
+ panelData: PanelData;
}
export interface Props {
@@ -28,11 +37,13 @@ export interface Props {
minInterval?: string;
maxDataPoints?: number;
children: (r: RenderProps) => JSX.Element;
+ onDataResponse?: (data: DataQueryResponse) => void;
}
export interface State {
isFirstLoad: boolean;
loading: LoadingState;
+ errorMessage: string;
response: DataQueryResponse;
}
@@ -51,6 +62,7 @@ export class DataPanel extends Component {
this.state = {
loading: LoadingState.NotStarted,
+ errorMessage: '',
response: {
data: [],
},
@@ -79,7 +91,17 @@ export class DataPanel extends Component {
}
private issueQueries = async () => {
- const { isVisible, queries, datasource, panelId, dashboardId, timeRange, widthPixels, maxDataPoints } = this.props;
+ const {
+ isVisible,
+ queries,
+ datasource,
+ panelId,
+ dashboardId,
+ timeRange,
+ widthPixels,
+ maxDataPoints,
+ onDataResponse,
+ } = this.props;
if (!isVisible) {
return;
@@ -90,7 +112,7 @@ export class DataPanel extends Component {
return;
}
- this.setState({ loading: LoadingState.Loading });
+ this.setState({ loading: LoadingState.Loading, errorMessage: '' });
try {
const ds = await this.dataSourceSrv.get(datasource);
@@ -115,12 +137,17 @@ export class DataPanel extends Component {
console.log('Issuing DataPanel query', queryOptions);
const resp = await ds.query(queryOptions);
+
console.log('Issuing DataPanel query Resp', resp);
if (this.isUnmounted) {
return;
}
+ if (onDataResponse) {
+ onDataResponse(resp);
+ }
+
this.setState({
loading: LoadingState.Done,
response: resp,
@@ -128,18 +155,44 @@ export class DataPanel extends Component {
});
} catch (err) {
console.log('Loading error', err);
- this.setState({ loading: LoadingState.Error, isFirstLoad: false });
+ this.onError('Request Error');
}
};
+ onError = (errorMessage: string) => {
+ if (this.state.loading !== LoadingState.Error || this.state.errorMessage !== errorMessage) {
+ this.setState({
+ loading: LoadingState.Error,
+ isFirstLoad: false,
+ errorMessage: errorMessage,
+ });
+ }
+ };
+
+ getPanelData = () => {
+ const { response } = this.state;
+
+ if (response.data.length > 0 && (response.data[0] as TableData).type === 'table') {
+ return {
+ tableData: response.data[0] as TableData,
+ timeSeries: null,
+ };
+ }
+
+ return {
+ timeSeries: response.data as TimeSeries[],
+ tableData: null,
+ };
+ };
+
render() {
const { queries } = this.props;
- const { response, loading, isFirstLoad } = this.state;
+ const { loading, isFirstLoad } = this.state;
- const timeSeries = response.data;
+ const panelData = this.getPanelData();
if (isFirstLoad && loading === LoadingState.Loading) {
- return this.renderLoadingSpinner();
+ return this.renderLoadingStates();
}
if (!queries.length) {
@@ -152,24 +205,44 @@ export class DataPanel extends Component {
return (
<>
- {this.renderLoadingSpinner()}
- {this.props.children({
- timeSeries,
- loading,
- })}
+ {this.renderLoadingStates()}
+
+ {({ error, errorInfo }) => {
+ if (errorInfo) {
+ this.onError(error.message || DEFAULT_PLUGIN_ERROR);
+ return null;
+ }
+ return (
+ <>
+ {this.props.children({
+ loading,
+ panelData,
+ })}
+ >
+ );
+ }}
+
>
);
}
- private renderLoadingSpinner(): JSX.Element {
- const { loading } = this.state;
-
+ private renderLoadingStates(): JSX.Element {
+ const { loading, errorMessage } = this.state;
if (loading === LoadingState.Loading) {
return (
);
+ } else if (loading === LoadingState.Error) {
+ return (
+
+
+
+
+
+
+ );
}
return null;
diff --git a/public/app/features/dashboard/dashgrid/KeyboardNavigation.tsx b/public/app/features/dashboard/dashgrid/KeyboardNavigation.tsx
deleted file mode 100644
index dab8371c925..00000000000
--- a/public/app/features/dashboard/dashgrid/KeyboardNavigation.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import React, { KeyboardEvent, Component } from 'react';
-
-interface State {
- selected: number;
-}
-
-export interface KeyboardNavigationProps {
- onKeyDown: (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: () => void) => void;
- onMouseEnter: (select: number) => void;
- selected: number;
-}
-
-interface Props {
- render: (injectProps: any) => void;
-}
-
-class KeyboardNavigation extends Component {
- constructor(props) {
- super(props);
-
- this.state = {
- selected: 0,
- };
- }
-
- goToNext = (maxSelectedIndex: number) => {
- const nextIndex = this.state.selected >= maxSelectedIndex ? 0 : this.state.selected + 1;
- this.setState({
- selected: nextIndex,
- });
- };
-
- goToPrev = (maxSelectedIndex: number) => {
- const nextIndex = this.state.selected <= 0 ? maxSelectedIndex : this.state.selected - 1;
- this.setState({
- selected: nextIndex,
- });
- };
-
- onKeyDown = (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: any) => {
- if (evt.key === 'ArrowDown') {
- evt.preventDefault();
- this.goToNext(maxSelectedIndex);
- }
- if (evt.key === 'ArrowUp') {
- evt.preventDefault();
- this.goToPrev(maxSelectedIndex);
- }
- if (evt.key === 'Enter' && onEnterAction) {
- onEnterAction();
- }
- };
-
- onMouseEnter = (mouseEnterIndex: number) => {
- this.setState({
- selected: mouseEnterIndex,
- });
- };
-
- render() {
- const injectProps = {
- onKeyDown: this.onKeyDown,
- onMouseEnter: this.onMouseEnter,
- selected: this.state.selected,
- };
-
- return <>{this.props.render({ ...injectProps })}>;
- }
-}
-
-export default KeyboardNavigation;
diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx
index 94719dfe6e0..68b53714504 100644
--- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx
@@ -3,7 +3,7 @@ import React, { PureComponent } from 'react';
import { AutoSizer } from 'react-virtualized';
// Services
-import { getTimeSrv, TimeSrv } from '../time_srv';
+import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
// Components
import { PanelHeader } from './PanelHeader/PanelHeader';
@@ -14,11 +14,14 @@ import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
// Types
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel, PanelModel } from '../state';
import { PanelPlugin } from 'app/types';
import { TimeRange } from '@grafana/ui';
+import variables from 'sass/_variables.scss';
+import templateSrv from 'app/features/templating/template_srv';
+import { DataQueryResponse } from '@grafana/ui/src';
+
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
@@ -76,18 +79,47 @@ export class PanelChrome extends PureComponent {
});
};
+ onInterpolate = (value: string, format?: string) => {
+ return templateSrv.replace(value, this.props.panel.scopedVars, format);
+ };
+
+ onDataResponse = (dataQueryResponse: DataQueryResponse) => {
+ if (this.props.dashboard.isSnapshot()) {
+ this.props.panel.snapshotData = dataQueryResponse.data;
+ }
+ };
+
get isVisible() {
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
}
+ renderPanel(loading, timeSeries, width, height): JSX.Element {
+ const { panel, plugin } = this.props;
+ const { timeRange, renderCounter } = this.state;
+ const PanelComponent = plugin.exports.Panel;
+
+ return (
+
+ );
+ }
+
render() {
- const { panel, dashboard, plugin } = this.props;
- const { refreshCounter, timeRange, timeInfo, renderCounter } = this.state;
+ const { panel, dashboard } = this.props;
+ const { refreshCounter, timeRange, timeInfo } = this.state;
const { datasource, targets, transparent } = panel;
- const PanelComponent = plugin.exports.Panel;
const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
-
return (
{({ width, height }) => {
@@ -106,31 +138,23 @@ export class PanelChrome extends PureComponent {
scopedVars={panel.scopedVars}
links={panel.links}
/>
-
-
- {({ loading, timeSeries }) => {
- return (
-
- );
- }}
-
+ {panel.snapshotData ? (
+ this.renderPanel(false, panel.snapshotData, width, height)
+ ) : (
+
+ {({ loading, panelData }) => {
+ return this.renderPanel(loading, panelData.timeSeries, width, height);
+ }}
+
+ )}
);
}}
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
index 8b7afd7d09e..ebc89673387 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
@@ -1,11 +1,13 @@
import React, { Component } from 'react';
import classNames from 'classnames';
+import { isEqual } from 'lodash';
import PanelHeaderCorner from './PanelHeaderCorner';
import { PanelHeaderMenu } from './PanelHeaderMenu';
+import templateSrv from 'app/features/templating/template_srv';
-import { DashboardModel } from 'app/features/dashboard/dashboard_model';
-import { PanelModel } from 'app/features/dashboard/panel_model';
+import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
+import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper';
export interface Props {
@@ -18,21 +20,45 @@ export interface Props {
links?: [];
}
+interface ClickCoordinates {
+ x: number;
+ y: number;
+}
+
interface State {
panelMenuOpen: boolean;
}
export class PanelHeader extends Component {
+ clickCoordinates: ClickCoordinates = {x: 0, y: 0};
state = {
panelMenuOpen: false,
+ clickCoordinates: {x: 0, y: 0}
};
- onMenuToggle = event => {
- event.stopPropagation();
+ eventToClickCoordinates = (event: React.MouseEvent) => {
+ return {
+ x: event.clientX,
+ y: event.clientY
+ };
+ }
- this.setState(prevState => ({
- panelMenuOpen: !prevState.panelMenuOpen,
- }));
+ onMouseDown = (event: React.MouseEvent) => {
+ this.clickCoordinates = this.eventToClickCoordinates(event);
+ };
+
+ isClick = (clickCoordinates: ClickCoordinates) => {
+ return isEqual(clickCoordinates, this.clickCoordinates);
+ }
+
+ onMenuToggle = (event: React.MouseEvent) => {
+ if (this.isClick(this.eventToClickCoordinates(event))) {
+ event.stopPropagation();
+
+ this.setState(prevState => ({
+ panelMenuOpen: !prevState.panelMenuOpen,
+ }));
+ }
};
closeMenu = () => {
@@ -45,7 +71,9 @@ export class PanelHeader extends Component {
const isFullscreen = false;
const isLoading = false;
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
- const { panel, dashboard, timeInfo } = this.props;
+ const { panel, dashboard, timeInfo, scopedVars } = this.props;
+ const title = templateSrv.replaceWithText(panel.title, scopedVars);
+
return (
<>
{
)}
-
+
- {panel.title}
+ {title}
{this.state.panelMenuOpen && (
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
index 331e469a60d..159c9d92914 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
@@ -1,10 +1,10 @@
import React, { Component } from 'react';
-import { PanelModel } from 'app/features/dashboard/panel_model';
-import Tooltip from 'app/core/components/Tooltip/Tooltip';
-import templateSrv from 'app/features/templating/template_srv';
-import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
-import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
import Remarkable from 'remarkable';
+import { Tooltip } from '@grafana/ui';
+import { PanelModel } from 'app/features/dashboard/state/PanelModel';
+import templateSrv from 'app/features/templating/template_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
+import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
enum InfoModes {
Error = 'Error',
@@ -77,13 +77,15 @@ export class PanelHeaderCorner extends Component
{
<>
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
-
-
+
+
+
+
) : null}
>
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx
index 1d17ec6cefc..5a0b6bbecb6 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx
@@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
-import { DashboardModel } from 'app/features/dashboard/dashboard_model';
-import { PanelModel } from 'app/features/dashboard/panel_model';
+import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
+import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
import { PanelMenuItem } from '@grafana/ui';
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
index d42b48fe1d6..66a942f0afc 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
@@ -1,11 +1,11 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { PanelMenuItem } from '@grafana/ui';
interface Props {
children: any;
}
-export const PanelHeaderMenuItem: SFC = props => {
+export const PanelHeaderMenuItem: FC = props => {
const isSubMenu = props.type === 'submenu';
const isDivider = props.type === 'divider';
return isDivider ? (
diff --git a/public/app/features/dashboard/dashgrid/PanelLoader.ts b/public/app/features/dashboard/dashgrid/PanelLoader.ts
deleted file mode 100644
index c654b756085..00000000000
--- a/public/app/features/dashboard/dashgrid/PanelLoader.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import angular from 'angular';
-import coreModule from 'app/core/core_module';
-
-export interface AttachedPanel {
- destroy();
-}
-
-export class PanelLoader {
- /** @ngInject */
- constructor(private $compile, private $rootScope) {}
-
- load(elem, panel, dashboard): AttachedPanel {
- const template = ' ';
- const panelScope = this.$rootScope.$new();
- panelScope.panel = panel;
- panelScope.dashboard = dashboard;
-
- const compiledElem = this.$compile(template)(panelScope);
- const rootNode = angular.element(elem);
- rootNode.append(compiledElem);
-
- return {
- destroy: () => {
- panelScope.$destroy();
- compiledElem.remove();
- },
- };
- }
-}
-
-coreModule.service('panelLoader', PanelLoader);
diff --git a/public/app/features/dashboard/dashgrid/PanelResizer.tsx b/public/app/features/dashboard/dashgrid/PanelResizer.tsx
index 2a4bf8379a6..4571b5bcbf5 100644
--- a/public/app/features/dashboard/dashgrid/PanelResizer.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelResizer.tsx
@@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
import { throttle } from 'lodash';
-import Draggable from 'react-draggable';
+import Draggable, { DraggableEventHandler } from 'react-draggable';
-import { PanelModel } from '../panel_model';
+import { PanelModel } from '../state/PanelModel';
interface Props {
isEditing: boolean;
@@ -15,7 +15,7 @@ interface State {
}
export class PanelResizer extends PureComponent {
- initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.4);
+ initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.3);
prevEditorHeight: number;
throttledChangeHeight: (height: number) => void;
throttledResizeDone: () => void;
@@ -42,7 +42,7 @@ export class PanelResizer extends PureComponent {
return 100;
}
- changeHeight = height => {
+ changeHeight = (height: number) => {
const sh = this.smallestHeight;
const lh = this.largestHeight;
height = height < sh ? sh : height;
@@ -54,7 +54,7 @@ export class PanelResizer extends PureComponent {
});
};
- onDrag = (evt, data) => {
+ onDrag: DraggableEventHandler = (evt, data) => {
const newHeight = this.state.editorHeight + data.y;
this.throttledChangeHeight(newHeight);
this.throttledResizeDone();
diff --git a/public/app/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx
deleted file mode 100644
index 9ad0bb3cadd..00000000000
--- a/public/app/features/dashboard/dashgrid/QueriesTab.tsx
+++ /dev/null
@@ -1,252 +0,0 @@
-// Libraries
-import React, { SFC, PureComponent } from 'react';
-import _ from 'lodash';
-
-// Components
-import './../../panel/metrics_tab';
-import { EditorTabBody } from './EditorTabBody';
-import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
-import { QueryInspector } from './QueryInspector';
-import { QueryOptions } from './QueryOptions';
-import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
-import { PanelOptionSection } from './PanelOptionSection';
-
-// Services
-import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
-import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
-import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
-import config from 'app/core/config';
-
-// Types
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
-import { DataSourceSelectItem, DataQuery } from 'app/types';
-import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
-
-interface Props {
- panel: PanelModel;
- dashboard: DashboardModel;
-}
-
-interface State {
- currentDS: DataSourceSelectItem;
- helpContent: JSX.Element;
- isLoadingHelp: boolean;
- isPickerOpen: boolean;
- isAddingMixed: boolean;
-}
-
-interface LoadingPlaceholderProps {
- text: string;
-}
-
-const LoadingPlaceholder: SFC = ({ text }) => {text} ;
-
-export class QueriesTab extends PureComponent {
- element: HTMLElement;
- component: AngularComponent;
- datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
- backendSrv: BackendSrv = getBackendSrv();
-
- constructor(props) {
- super(props);
- const { panel } = props;
-
- this.state = {
- currentDS: this.datasources.find(datasource => datasource.value === panel.datasource),
- isLoadingHelp: false,
- helpContent: null,
- isPickerOpen: false,
- isAddingMixed: false,
- };
- }
-
- getAngularQueryComponentScope(): AngularQueryComponentScope {
- const { panel, dashboard } = this.props;
-
- return {
- panel: panel,
- dashboard: dashboard,
- refresh: () => panel.refresh(),
- render: () => panel.render,
- addQuery: this.onAddQuery,
- moveQuery: this.onMoveQuery,
- removeQuery: this.onRemoveQuery,
- events: panel.events,
- };
- }
-
- componentDidMount() {
- if (!this.element) {
- return;
- }
-
- const loader = getAngularLoader();
- const template = ' ';
- const scopeProps = {
- ctrl: this.getAngularQueryComponentScope(),
- };
-
- this.component = loader.load(this.element, scopeProps, template);
- }
-
- componentWillUnmount() {
- if (this.component) {
- this.component.destroy();
- }
- }
-
- onChangeDataSource = datasource => {
- const { panel } = this.props;
- const { currentDS } = this.state;
-
- // switching to mixed
- if (datasource.meta.mixed) {
- panel.targets.forEach(target => {
- target.datasource = panel.datasource;
- if (!target.datasource) {
- target.datasource = config.defaultDatasource;
- }
- });
- } else if (currentDS) {
- // if switching from mixed
- if (currentDS.meta.mixed) {
- for (const target of panel.targets) {
- delete target.datasource;
- }
- } else if (currentDS.meta.id !== datasource.meta.id) {
- // we are changing data source type, clear queries
- panel.targets = [{ refId: 'A' }];
- }
- }
-
- panel.datasource = datasource.value;
- panel.refresh();
-
- this.setState({
- currentDS: datasource,
- });
- };
-
- renderQueryInspector = () => {
- const { panel } = this.props;
- return ;
- };
-
- renderHelp = () => {
- return ;
- };
-
- onAddQuery = (query?: Partial) => {
- this.props.panel.addQuery(query);
- this.forceUpdate();
- };
-
- onAddQueryClick = () => {
- if (this.state.currentDS.meta.mixed) {
- this.setState({ isAddingMixed: true });
- return;
- }
-
- this.props.panel.addQuery();
- this.component.digest();
- this.forceUpdate();
- };
-
- onRemoveQuery = (query: DataQuery) => {
- const { panel } = this.props;
-
- const index = _.indexOf(panel.targets, query);
- panel.targets.splice(index, 1);
- panel.refresh();
-
- this.forceUpdate();
- };
-
- onMoveQuery = (query: DataQuery, direction: number) => {
- const { panel } = this.props;
-
- const index = _.indexOf(panel.targets, query);
- _.move(panel.targets, index, index + direction);
-
- this.forceUpdate();
- };
-
- renderToolbar = () => {
- const { currentDS } = this.state;
-
- return ;
- };
-
- renderMixedPicker = () => {
- return (
-
- );
- };
-
- onAddMixedQuery = datasource => {
- this.onAddQuery({ datasource: datasource.name });
- this.component.digest();
- this.setState({ isAddingMixed: false });
- };
-
- onMixedPickerBlur = () => {
- this.setState({ isAddingMixed: false });
- };
-
- render() {
- const { panel } = this.props;
- const { currentDS, isAddingMixed } = this.state;
-
- const queryInspector = {
- title: 'Query Inspector',
- render: this.renderQueryInspector,
- };
-
- const dsHelp = {
- heading: 'Help',
- icon: 'fa fa-question',
- render: this.renderHelp,
- };
-
- return (
-
- <>
-
-
-
(this.element = element)} />
-
-
-
-
-
-
- {' '}
- {panel.getNextQueryLetter()}
-
-
-
- {!isAddingMixed && (
-
- Add Query
-
- )}
- {isAddingMixed && this.renderMixedPicker()}
-
-
-
-
-
-
-
- >
-
- );
- }
-}
diff --git a/public/app/features/dashboard/folder_permissions_ctrl.ts b/public/app/features/dashboard/folder_permissions_ctrl.ts
deleted file mode 100644
index 4ab91acb3d9..00000000000
--- a/public/app/features/dashboard/folder_permissions_ctrl.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { FolderPageLoader } from './folder_page_loader';
-
-export class FolderPermissionsCtrl {
- navModel: any;
- folderId: number;
- uid: string;
- dashboard: any;
- meta: any;
-
- /** @ngInject */
- constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
- if (this.$routeParams.uid) {
- this.uid = $routeParams.uid;
-
- new FolderPageLoader(this.backendSrv).load(this, this.uid, 'manage-folder-permissions').then(folder => {
- if ($location.path() !== folder.meta.url) {
- $location.path(`${folder.meta.url}/permissions`).replace();
- }
-
- this.dashboard = folder.dashboard;
- this.meta = folder.meta;
- });
- }
- }
-}
diff --git a/public/app/features/dashboard/index.ts b/public/app/features/dashboard/index.ts
new file mode 100644
index 00000000000..9f2935660ef
--- /dev/null
+++ b/public/app/features/dashboard/index.ts
@@ -0,0 +1,32 @@
+import './containers/DashboardCtrl';
+import './dashgrid/DashboardGridDirective';
+
+// Services
+import './services/DashboardViewStateSrv';
+import './services/UnsavedChangesSrv';
+import './services/DashboardLoaderSrv';
+import './services/DashboardSrv';
+
+// Components
+import './components/DashLinks';
+import './components/DashExportModal';
+import './components/DashNav';
+import './components/ExportDataModal';
+import './components/FolderPicker';
+import './components/VersionHistory';
+import './components/DashboardSettings';
+import './components/SubMenu';
+import './components/TimePicker';
+import './components/UnsavedChangesModal';
+import './components/SaveModals';
+import './components/ShareModal';
+import './components/AdHocFilters';
+import './components/RowOptions';
+
+import DashboardPermissions from './components/DashboardPermissions/DashboardPermissions';
+
+// angular wrappers
+import { react2AngularDirective } from 'app/core/utils/react2angular';
+
+react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);
+
diff --git a/public/app/features/dashboard/dashgrid/DataSourceOption.tsx b/public/app/features/dashboard/panel_editor/DataSourceOption.tsx
similarity index 55%
rename from public/app/features/dashboard/dashgrid/DataSourceOption.tsx
rename to public/app/features/dashboard/panel_editor/DataSourceOption.tsx
index 0adfc4abe16..08285960805 100644
--- a/public/app/features/dashboard/dashgrid/DataSourceOption.tsx
+++ b/public/app/features/dashboard/panel_editor/DataSourceOption.tsx
@@ -1,5 +1,5 @@
-import React, { SFC } from 'react';
-import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import React, { FC } from 'react';
+import { FormLabel } from '@grafana/ui';
interface Props {
label: string;
@@ -10,10 +10,10 @@ interface Props {
tooltipInfo?: any;
}
-export const DataSourceOptions: SFC
= ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
- const dsOption = (
+export const DataSourceOptions: FC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
+ return (
- {label}
+ {label}
= ({ label, placeholder, name, value,
/>
);
-
- return tooltipInfo ? {dsOption} : dsOption;
};
export default DataSourceOptions;
diff --git a/public/app/features/dashboard/dashgrid/EditorTabBody.tsx b/public/app/features/dashboard/panel_editor/EditorTabBody.tsx
similarity index 65%
rename from public/app/features/dashboard/dashgrid/EditorTabBody.tsx
rename to public/app/features/dashboard/panel_editor/EditorTabBody.tsx
index 7606d327405..0413cae8a7b 100644
--- a/public/app/features/dashboard/dashgrid/EditorTabBody.tsx
+++ b/public/app/features/dashboard/panel_editor/EditorTabBody.tsx
@@ -2,29 +2,31 @@
import React, { PureComponent } from 'react';
// Components
-import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
+import { CustomScrollbar, PanelOptionsGroup } from '@grafana/ui';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
-import { PanelOptionSection } from './PanelOptionSection';
interface Props {
children: JSX.Element;
heading: string;
renderToolbar?: () => JSX.Element;
- toolbarItems?: EditorToolBarView[];
+ toolbarItems?: EditorToolbarView[];
+ scrollTop?: number;
+ setScrollTop?: (value: React.MouseEvent) => void;
}
-export interface EditorToolBarView {
+export interface EditorToolbarView {
title?: string;
heading?: string;
- imgSrc?: string;
icon?: string;
disabled?: boolean;
onClick?: () => void;
- render: (closeFunction?: any) => JSX.Element | JSX.Element[];
+ render?: () => JSX.Element;
+ action?: () => void;
+ btnType?: 'danger';
}
interface State {
- openView?: EditorToolBarView;
+ openView?: EditorToolbarView;
isOpen: boolean;
fadeIn: boolean;
}
@@ -48,10 +50,10 @@ export class EditorTabBody extends PureComponent {
this.setState({ fadeIn: true });
}
- onToggleToolBarView = (item: EditorToolBarView) => {
+ onToggleToolBarView = (item: EditorToolbarView) => {
this.setState({
openView: item,
- isOpen: !this.state.isOpen,
+ isOpen: this.state.openView !== item || !this.state.isOpen,
});
};
@@ -74,12 +76,15 @@ export class EditorTabBody extends PureComponent {
return state;
}
- renderButton(view: EditorToolBarView) {
+ renderButton(view: EditorToolbarView) {
const onClick = () => {
if (view.onClick) {
view.onClick();
}
- this.onToggleToolBarView(view);
+
+ if (view.render) {
+ this.onToggleToolBarView(view);
+ }
};
return (
@@ -91,32 +96,29 @@ export class EditorTabBody extends PureComponent {
);
}
- renderOpenView(view: EditorToolBarView) {
+ renderOpenView(view: EditorToolbarView) {
return (
-
+
{view.render()}
-
+
);
}
render() {
- const { children, renderToolbar, heading, toolbarItems } = this.props;
+ const { children, renderToolbar, heading, toolbarItems, scrollTop, setScrollTop } = this.props;
const { openView, fadeIn, isOpen } = this.state;
return (
<>
-
{heading}
- {renderToolbar && renderToolbar()}
- {toolbarItems.length > 0 && (
- <>
-
- {toolbarItems.map(item => this.renderButton(item))}
- >
- )}
+
+
{heading}
+ {renderToolbar && renderToolbar()}
+
+ {toolbarItems.map(item => this.renderButton(item))}
-
+
{openView && this.renderOpenView(openView)}
diff --git a/public/app/features/dashboard/dashgrid/GeneralTab.tsx b/public/app/features/dashboard/panel_editor/GeneralTab.tsx
similarity index 95%
rename from public/app/features/dashboard/dashgrid/GeneralTab.tsx
rename to public/app/features/dashboard/panel_editor/GeneralTab.tsx
index 91e236c8b31..d91737195f1 100644
--- a/public/app/features/dashboard/dashgrid/GeneralTab.tsx
+++ b/public/app/features/dashboard/panel_editor/GeneralTab.tsx
@@ -3,7 +3,7 @@ import React, { PureComponent } from 'react';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { EditorTabBody } from './EditorTabBody';
-import { PanelModel } from '../panel_model';
+import { PanelModel } from '../state/PanelModel';
import './../../panel/GeneralTabCtrl';
interface Props {
diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx
similarity index 91%
rename from public/app/features/dashboard/dashgrid/PanelEditor.tsx
rename to public/app/features/dashboard/panel_editor/PanelEditor.tsx
index a746d6c4b91..7b8097b9f65 100644
--- a/public/app/features/dashboard/dashgrid/PanelEditor.tsx
+++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx
@@ -4,18 +4,18 @@ import classNames from 'classnames';
import { QueriesTab } from './QueriesTab';
import { VisualizationTab } from './VisualizationTab';
import { GeneralTab } from './GeneralTab';
-import { AlertTab } from './AlertTab';
+import { AlertTab } from '../../alerting/AlertTab';
import config from 'app/core/config';
import { store } from 'app/store/store';
import { updateLocation } from 'app/core/actions';
import { AngularComponent } from 'app/core/services/AngularLoader';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import { PanelModel } from '../state/PanelModel';
+import { DashboardModel } from '../state/DashboardModel';
import { PanelPlugin } from 'app/types/plugins';
-import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import { Tooltip } from '@grafana/ui';
interface PanelEditorProps {
panel: PanelModel;
@@ -54,7 +54,7 @@ export class PanelEditor extends PureComponent {
case 'queries':
return ;
case 'alert':
- return ;
+ return ;
case 'visualization':
return (
onClick(tab)}>
-
+
diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx
new file mode 100644
index 00000000000..140bb4b0fd7
--- /dev/null
+++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx
@@ -0,0 +1,218 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+
+// Components
+import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
+import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
+import { QueryInspector } from './QueryInspector';
+import { QueryOptions } from './QueryOptions';
+import { PanelOptionsGroup } from '@grafana/ui';
+import { QueryEditorRow } from './QueryEditorRow';
+
+// Services
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
+import config from 'app/core/config';
+
+// Types
+import { PanelModel } from '../state/PanelModel';
+import { DashboardModel } from '../state/DashboardModel';
+import { DataQuery, DataSourceSelectItem } from '@grafana/ui/src/types';
+import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
+
+interface Props {
+ panel: PanelModel;
+ dashboard: DashboardModel;
+}
+
+interface State {
+ currentDS: DataSourceSelectItem;
+ helpContent: JSX.Element;
+ isLoadingHelp: boolean;
+ isPickerOpen: boolean;
+ isAddingMixed: boolean;
+ scrollTop: number;
+}
+
+export class QueriesTab extends PureComponent {
+ datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
+ backendSrv: BackendSrv = getBackendSrv();
+
+ state: State = {
+ isLoadingHelp: false,
+ currentDS: this.findCurrentDataSource(),
+ helpContent: null,
+ isPickerOpen: false,
+ isAddingMixed: false,
+ scrollTop: 0,
+ };
+
+ findCurrentDataSource(): DataSourceSelectItem {
+ const { panel } = this.props;
+ return this.datasources.find(datasource => datasource.value === panel.datasource) || this.datasources[0];
+ }
+
+ onChangeDataSource = datasource => {
+ const { panel } = this.props;
+ const { currentDS } = this.state;
+
+ // switching to mixed
+ if (datasource.meta.mixed) {
+ panel.targets.forEach(target => {
+ target.datasource = panel.datasource;
+ if (!target.datasource) {
+ target.datasource = config.defaultDatasource;
+ }
+ });
+ } else if (currentDS) {
+ // if switching from mixed
+ if (currentDS.meta.mixed) {
+ for (const target of panel.targets) {
+ delete target.datasource;
+ }
+ } else if (currentDS.meta.id !== datasource.meta.id) {
+ // we are changing data source type, clear queries
+ panel.targets = [{ refId: 'A' }];
+ }
+ }
+
+ panel.datasource = datasource.value;
+ panel.refresh();
+
+ this.setState({
+ currentDS: datasource,
+ });
+ };
+
+ renderQueryInspector = () => {
+ const { panel } = this.props;
+ return ;
+ };
+
+ renderHelp = () => {
+ return ;
+ };
+
+ onAddQuery = (query?: Partial) => {
+ this.props.panel.addQuery(query);
+ this.setState({ scrollTop: this.state.scrollTop + 100000 });
+ };
+
+ onAddQueryClick = () => {
+ if (this.state.currentDS.meta.mixed) {
+ this.setState({ isAddingMixed: true });
+ return;
+ }
+
+ this.onAddQuery();
+ };
+
+ onRemoveQuery = (query: DataQuery) => {
+ const { panel } = this.props;
+
+ const index = _.indexOf(panel.targets, query);
+ panel.targets.splice(index, 1);
+ panel.refresh();
+
+ this.forceUpdate();
+ };
+
+ onMoveQuery = (query: DataQuery, direction: number) => {
+ const { panel } = this.props;
+
+ const index = _.indexOf(panel.targets, query);
+ _.move(panel.targets, index, index + direction);
+
+ this.forceUpdate();
+ };
+
+ renderToolbar = () => {
+ const { currentDS, isAddingMixed } = this.state;
+
+ return (
+ <>
+
+
+ {!isAddingMixed && (
+
+ Add Query
+
+ )}
+ {isAddingMixed && this.renderMixedPicker()}
+ >
+ );
+ };
+
+ renderMixedPicker = () => {
+ return (
+
+ );
+ };
+
+ onAddMixedQuery = datasource => {
+ this.onAddQuery({ datasource: datasource.name });
+ this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 });
+ };
+
+ onMixedPickerBlur = () => {
+ this.setState({ isAddingMixed: false });
+ };
+
+ setScrollTop = (event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ this.setState({ scrollTop: target.scrollTop });
+ };
+
+ render() {
+ const { panel } = this.props;
+ const { currentDS, scrollTop } = this.state;
+
+ const queryInspector: EditorToolbarView = {
+ title: 'Query Inspector',
+ render: this.renderQueryInspector,
+ };
+
+ const dsHelp: EditorToolbarView = {
+ heading: 'Help',
+ icon: 'fa fa-question',
+ render: this.renderHelp,
+ };
+
+ return (
+
+ <>
+
+ {panel.targets.map((query, index) => (
+
+ ))}
+
+
+
+
+ >
+
+ );
+ }
+}
diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
new file mode 100644
index 00000000000..b07b4be6f56
--- /dev/null
+++ b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
@@ -0,0 +1,257 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import _ from 'lodash';
+
+// Utils & Services
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import { Emitter } from 'app/core/utils/emitter';
+
+// Types
+import { PanelModel } from '../state/PanelModel';
+import { DataQuery, DataSourceApi } from '@grafana/ui';
+
+interface Props {
+ panel: PanelModel;
+ query: DataQuery;
+ onAddQuery: (query?: DataQuery) => void;
+ onRemoveQuery: (query: DataQuery) => void;
+ onMoveQuery: (query: DataQuery, direction: number) => void;
+ dataSourceValue: string | null;
+ inMixedMode: boolean;
+}
+
+interface State {
+ loadedDataSourceValue: string | null | undefined;
+ datasource: DataSourceApi | null;
+ isCollapsed: boolean;
+ angularScope: AngularQueryComponentScope | null;
+}
+
+export class QueryEditorRow extends PureComponent {
+ element: HTMLElement | null = null;
+ angularQueryEditor: AngularComponent | null = null;
+
+ state: State = {
+ datasource: null,
+ isCollapsed: false,
+ angularScope: null,
+ loadedDataSourceValue: undefined,
+ };
+
+ componentDidMount() {
+ this.loadDatasource();
+ }
+
+ getAngularQueryComponentScope(): AngularQueryComponentScope {
+ const { panel, query } = this.props;
+ const { datasource } = this.state;
+
+ return {
+ datasource: datasource,
+ target: query,
+ panel: panel,
+ refresh: () => panel.refresh(),
+ render: () => panel.render(),
+ events: panel.events,
+ };
+ }
+
+ async loadDatasource() {
+ const { query, panel } = this.props;
+ const dataSourceSrv = getDatasourceSrv();
+ const datasource = await dataSourceSrv.get(query.datasource || panel.datasource);
+
+ this.setState({ datasource, loadedDataSourceValue: this.props.dataSourceValue });
+ }
+
+ componentDidUpdate() {
+ const { loadedDataSourceValue } = this.state;
+
+ // check if we need to load another datasource
+ if (loadedDataSourceValue !== this.props.dataSourceValue) {
+ if (this.angularQueryEditor) {
+ this.angularQueryEditor.destroy();
+ this.angularQueryEditor = null;
+ }
+ this.loadDatasource();
+ return;
+ }
+
+ if (!this.element || this.angularQueryEditor) {
+ return;
+ }
+
+ const loader = getAngularLoader();
+ const template = ' ';
+ const scopeProps = { ctrl: this.getAngularQueryComponentScope() };
+
+ this.angularQueryEditor = loader.load(this.element, scopeProps, template);
+
+ // give angular time to compile
+ setTimeout(() => {
+ this.setState({ angularScope: scopeProps.ctrl });
+ }, 10);
+ }
+
+ componentWillUnmount() {
+ if (this.angularQueryEditor) {
+ this.angularQueryEditor.destroy();
+ }
+ }
+
+ onToggleCollapse = () => {
+ this.setState({ isCollapsed: !this.state.isCollapsed });
+ };
+
+ onQueryChange = (query: DataQuery) => {
+ Object.assign(this.props.query, query);
+ this.onExecuteQuery();
+ };
+
+ onExecuteQuery = () => {
+ this.props.panel.refresh();
+ };
+
+ renderPluginEditor() {
+ const { query } = this.props;
+ const { datasource } = this.state;
+
+ if (datasource.pluginExports.QueryCtrl) {
+ return (this.element = element)} />;
+ }
+
+ if (datasource.pluginExports.QueryEditor) {
+ const QueryEditor = datasource.pluginExports.QueryEditor;
+ return (
+
+ );
+ }
+
+ return
Data source plugin does not export any Query Editor component
;
+ }
+
+ onToggleEditMode = () => {
+ const { angularScope } = this.state;
+
+ if (angularScope && angularScope.toggleEditorMode) {
+ angularScope.toggleEditorMode();
+ this.angularQueryEditor.digest();
+ }
+
+ if (this.state.isCollapsed) {
+ this.setState({ isCollapsed: false });
+ }
+ };
+
+ get hasTextEditMode() {
+ const { angularScope } = this.state;
+ return angularScope && angularScope.toggleEditorMode;
+ }
+
+ onRemoveQuery = () => {
+ this.props.onRemoveQuery(this.props.query);
+ };
+
+ onCopyQuery = () => {
+ const copy = _.cloneDeep(this.props.query);
+ this.props.onAddQuery(copy);
+ };
+
+ onDisableQuery = () => {
+ this.props.query.hide = !this.props.query.hide;
+ this.onExecuteQuery();
+ this.forceUpdate();
+ };
+
+ renderCollapsedText(): string | null {
+ const { angularScope } = this.state;
+
+ if (angularScope && angularScope.getCollapsedText) {
+ return angularScope.getCollapsedText();
+ }
+
+ return null;
+ }
+
+ render() {
+ const { query, inMixedMode } = this.props;
+ const { datasource, isCollapsed } = this.state;
+ const isDisabled = query.hide;
+
+ const bodyClasses = classNames('query-editor-row__body gf-form-query', {
+ 'query-editor-row__body--collapsed': isCollapsed,
+ });
+
+ const rowClasses = classNames('query-editor-row', {
+ 'query-editor-row--disabled': isDisabled,
+ 'gf-form-disabled': isDisabled,
+ });
+
+ if (!datasource) {
+ return null;
+ }
+
+ return (
+
+
+
+ {isCollapsed && }
+ {!isCollapsed && }
+ {query.refId}
+ {inMixedMode && ({datasource.name}) }
+ {isDisabled && Disabled }
+
+
+ {isCollapsed &&
{this.renderCollapsedText()}
}
+
+
+ {this.hasTextEditMode && (
+
+
+
+ )}
+ this.props.onMoveQuery(query, 1)}>
+
+
+ this.props.onMoveQuery(query, -1)}>
+
+
+
+
+
+
+ {isDisabled && }
+ {!isDisabled && }
+
+
+
+
+
+
+
{this.renderPluginEditor()}
+
+ );
+ }
+}
+
+export interface AngularQueryComponentScope {
+ target: DataQuery;
+ panel: PanelModel;
+ events: Emitter;
+ refresh: () => void;
+ render: () => void;
+ datasource: DataSourceApi;
+ toggleEditorMode?: () => void;
+ getCollapsedText?: () => string;
+}
diff --git a/public/app/features/dashboard/dashgrid/QueryInspector.tsx b/public/app/features/dashboard/panel_editor/QueryInspector.tsx
similarity index 88%
rename from public/app/features/dashboard/dashgrid/QueryInspector.tsx
rename to public/app/features/dashboard/panel_editor/QueryInspector.tsx
index 090bc220bc0..25c3c68e21e 100644
--- a/public/app/features/dashboard/dashgrid/QueryInspector.tsx
+++ b/public/app/features/dashboard/panel_editor/QueryInspector.tsx
@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
import appEvents from 'app/core/app_events';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
+import { LoadingPlaceholder } from '@grafana/ui';
interface DsQuery {
isLoading: boolean;
@@ -10,7 +11,6 @@ interface DsQuery {
interface Props {
panel: any;
- LoadingPlaceholder: any;
}
interface State {
@@ -177,8 +177,6 @@ export class QueryInspector extends PureComponent
{
render() {
const { response, isLoading } = this.state.dsQuery;
- const { LoadingPlaceholder } = this.props;
- const { isMocking } = this.state;
const openNodes = this.getNrOfOpenNodes();
if (isLoading) {
@@ -200,20 +198,7 @@ export class QueryInspector extends PureComponent {
- {!isMocking && }
- {isMocking && (
-
- )}
+
>
);
}
diff --git a/public/app/features/dashboard/dashgrid/QueryOptions.tsx b/public/app/features/dashboard/panel_editor/QueryOptions.tsx
similarity index 94%
rename from public/app/features/dashboard/dashgrid/QueryOptions.tsx
rename to public/app/features/dashboard/panel_editor/QueryOptions.tsx
index fad70d92990..d203f3bc25f 100644
--- a/public/app/features/dashboard/dashgrid/QueryOptions.tsx
+++ b/public/app/features/dashboard/panel_editor/QueryOptions.tsx
@@ -5,16 +5,17 @@ import React, { PureComponent } from 'react';
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
// Components
-import { Switch } from 'app/core/components/Switch/Switch';
+import { Switch } from '@grafana/ui';
import { Input } from 'app/core/components/Form';
import { EventsWithValidation } from 'app/core/components/Form/Input';
import { InputStatus } from 'app/core/components/Form/Input';
import DataSourceOption from './DataSourceOption';
-import { GfFormLabel } from '@grafana/ui';
+import { FormLabel } from '@grafana/ui';
// Types
-import { PanelModel } from '../panel_model';
-import { ValidationEvents, DataSourceSelectItem } from 'app/types';
+import { PanelModel } from '../state/PanelModel';
+import { DataSourceSelectItem } from '@grafana/ui/src/types';
+import { ValidationEvents } from 'app/types';
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
@@ -164,7 +165,7 @@ export class QueryOptions extends PureComponent {
{this.renderOptions()}
-
Relative time
+
Relative time
{
@@ -40,6 +40,7 @@ export class VisualizationTab extends PureComponent
{
this.state = {
isVizPickerOpen: false,
searchQuery: '',
+ scrollTop: 0,
};
}
@@ -62,13 +63,13 @@ export class VisualizationTab extends PureComponent {
}
return (
-
+ <>
{PanelOptions ? (
) : (
Visualization has no options
)}
-
+ >
);
}
@@ -112,9 +113,9 @@ export class VisualizationTab extends PureComponent {
for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
template +=
`
- ` +
- (i > 0 ? `` : '') +
- `
+
` +
+ (i > 0 ? `` : '') +
+ `
@@ -144,7 +145,7 @@ export class VisualizationTab extends PureComponent
{
};
onOpenVizPicker = () => {
- this.setState({ isVizPickerOpen: true });
+ this.setState({ isVizPickerOpen: true, scrollTop: 0 });
};
onCloseVizPicker = () => {
@@ -202,18 +203,24 @@ export class VisualizationTab extends PureComponent {
renderHelp = () => ;
+ setScrollTop = (event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ this.setState({ scrollTop: target.scrollTop });
+ };
+
render() {
const { plugin } = this.props;
- const { isVizPickerOpen, searchQuery } = this.state;
+ const { isVizPickerOpen, searchQuery, scrollTop } = this.state;
- const pluginHelp = {
+ const pluginHelp: EditorToolbarView = {
heading: 'Help',
icon: 'fa fa-question',
render: this.renderHelp,
};
return (
-
+
<>
-
-
-
-
diff --git a/public/app/features/dashboard/partials/folder_settings.html b/public/app/features/dashboard/partials/folder_settings.html
deleted file mode 100644
index 8e819be5fe8..00000000000
--- a/public/app/features/dashboard/partials/folder_settings.html
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
diff --git a/public/app/features/dashboard/partials/inspector.html b/public/app/features/dashboard/partials/inspector.html
deleted file mode 100644
index b30bce3c5fe..00000000000
--- a/public/app/features/dashboard/partials/inspector.html
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
-
-
Request details
-
-
-
Request parameters
-
-
-
- {{param.key}}
-
-
- {{param.value}}
-
-
-
-
-
-
-
{{message}}
-
-{{response}}
-
-
-
-
-
-
Message:
-
-{{message}}
-
-
-
Stack trace:
-
-{{stack_trace}}
-
-
-
-
-
-
-
diff --git a/public/app/features/dashboard/specs/change_tracker.test.ts b/public/app/features/dashboard/services/ChangeTracker.test.ts
similarity index 93%
rename from public/app/features/dashboard/specs/change_tracker.test.ts
rename to public/app/features/dashboard/services/ChangeTracker.test.ts
index e7f8ce977b1..31e5f8f5052 100644
--- a/public/app/features/dashboard/specs/change_tracker.test.ts
+++ b/public/app/features/dashboard/services/ChangeTracker.test.ts
@@ -1,7 +1,7 @@
-import { ChangeTracker } from 'app/features/dashboard/change_tracker';
+import { ChangeTracker } from './ChangeTracker';
import { contextSrv } from 'app/core/services/context_srv';
-import { DashboardModel } from '../dashboard_model';
-import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../state/DashboardModel';
+import { PanelModel } from '../state/PanelModel';
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
diff --git a/public/app/features/dashboard/change_tracker.ts b/public/app/features/dashboard/services/ChangeTracker.ts
similarity index 98%
rename from public/app/features/dashboard/change_tracker.ts
rename to public/app/features/dashboard/services/ChangeTracker.ts
index aa71ac2e306..77434525085 100644
--- a/public/app/features/dashboard/change_tracker.ts
+++ b/public/app/features/dashboard/services/ChangeTracker.ts
@@ -1,6 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
-import { DashboardModel } from './dashboard_model';
+import { DashboardModel } from '../state/DashboardModel';
export class ChangeTracker {
current: any;
diff --git a/public/app/features/dashboard/dashboard_loader_srv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts
similarity index 100%
rename from public/app/features/dashboard/dashboard_loader_srv.ts
rename to public/app/features/dashboard/services/DashboardLoaderSrv.ts
diff --git a/public/app/features/dashboard/dashboard_srv.ts b/public/app/features/dashboard/services/DashboardSrv.ts
similarity index 98%
rename from public/app/features/dashboard/dashboard_srv.ts
rename to public/app/features/dashboard/services/DashboardSrv.ts
index d5695a577c5..03aeb34ed36 100644
--- a/public/app/features/dashboard/dashboard_srv.ts
+++ b/public/app/features/dashboard/services/DashboardSrv.ts
@@ -1,5 +1,5 @@
import coreModule from 'app/core/core_module';
-import { DashboardModel } from './dashboard_model';
+import { DashboardModel } from '../state/DashboardModel';
import locationUtil from 'app/core/utils/location_util';
export class DashboardSrv {
diff --git a/public/app/features/dashboard/specs/viewstate_srv.test.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
similarity index 76%
rename from public/app/features/dashboard/specs/viewstate_srv.test.ts
rename to public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
index f9963afbf85..12bb11b7a08 100644
--- a/public/app/features/dashboard/specs/viewstate_srv.test.ts
+++ b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
@@ -1,8 +1,6 @@
-//import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
-import 'app/features/dashboard/view_state_srv';
import config from 'app/core/config';
-import { DashboardViewState } from '../view_state_srv';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardViewStateSrv } from './DashboardViewStateSrv';
+import { DashboardModel } from '../state/DashboardModel';
describe('when updating view state', () => {
const location = {
@@ -33,7 +31,7 @@ describe('when updating view state', () => {
location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 };
});
- viewState = new DashboardViewState($scope, location, {});
+ viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should update querystring and view state', () => {
@@ -55,12 +53,11 @@ describe('when updating view state', () => {
describe('to fullscreen false', () => {
beforeEach(() => {
- viewState = new DashboardViewState($scope, location, {});
+ viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true });
viewState.update({ fullscreen: false });
- expect(viewState.dashboard.meta.fullscreen).toBe(false);
expect(viewState.state.fullscreen).toBe(null);
});
});
diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/services/DashboardViewStateSrv.ts
similarity index 91%
rename from public/app/features/dashboard/view_state_srv.ts
rename to public/app/features/dashboard/services/DashboardViewStateSrv.ts
index ff12d26233d..fc38c3b241f 100644
--- a/public/app/features/dashboard/view_state_srv.ts
+++ b/public/app/features/dashboard/services/DashboardViewStateSrv.ts
@@ -2,11 +2,11 @@ import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
-import { DashboardModel } from './dashboard_model';
+import { DashboardModel } from '../state/DashboardModel';
// represents the transient view state
// like fullscreen panel & edit
-export class DashboardViewState {
+export class DashboardViewStateSrv {
state: any;
panelScopes: any;
$scope: any;
@@ -72,7 +72,6 @@ export class DashboardViewState {
}
_.extend(this.state, state);
- this.dashboard.meta.fullscreen = this.state.fullscreen;
if (!this.state.fullscreen) {
this.state.fullscreen = null;
@@ -117,10 +116,20 @@ export class DashboardViewState {
}
syncState() {
- if (this.dashboard.meta.fullscreen) {
+ if (this.state.fullscreen) {
const panel = this.dashboard.getPanelById(this.state.panelId);
if (!panel) {
+ this.state.fullscreen = null;
+ this.state.panelId = null;
+ this.state.edit = null;
+
+ this.update(this.state);
+
+ setTimeout(() => {
+ appEvents.emit('alert-error', ['Error', 'Panel not found']);
+ }, 100);
+
return;
}
@@ -168,7 +177,7 @@ export class DashboardViewState {
export function dashboardViewStateSrv($location, $timeout) {
return {
create: $scope => {
- return new DashboardViewState($scope, $location, $timeout);
+ return new DashboardViewStateSrv($scope, $location, $timeout);
},
};
}
diff --git a/public/app/features/dashboard/specs/time_srv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts
similarity index 98%
rename from public/app/features/dashboard/specs/time_srv.test.ts
rename to public/app/features/dashboard/services/TimeSrv.test.ts
index db0d11f2ebe..e5b4c240785 100644
--- a/public/app/features/dashboard/specs/time_srv.test.ts
+++ b/public/app/features/dashboard/services/TimeSrv.test.ts
@@ -1,5 +1,4 @@
-import { TimeSrv } from '../time_srv';
-import '../time_srv';
+import { TimeSrv } from './TimeSrv';
import moment from 'moment';
describe('timeSrv', () => {
diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/services/TimeSrv.ts
similarity index 99%
rename from public/app/features/dashboard/time_srv.ts
rename to public/app/features/dashboard/services/TimeSrv.ts
index b4d18e0279a..1f7bdae6b04 100644
--- a/public/app/features/dashboard/time_srv.ts
+++ b/public/app/features/dashboard/services/TimeSrv.ts
@@ -147,7 +147,7 @@ export class TimeSrv {
}
refreshDashboard() {
- this.dashboard.timeRangeUpdated();
+ this.dashboard.timeRangeUpdated(this.timeRange());
}
private startNextRefreshTimer(afterMs) {
diff --git a/public/app/features/dashboard/unsaved_changes_srv.ts b/public/app/features/dashboard/services/UnsavedChangesSrv.ts
similarity index 89%
rename from public/app/features/dashboard/unsaved_changes_srv.ts
rename to public/app/features/dashboard/services/UnsavedChangesSrv.ts
index f0a8bf40501..2691cc6ebf8 100644
--- a/public/app/features/dashboard/unsaved_changes_srv.ts
+++ b/public/app/features/dashboard/services/UnsavedChangesSrv.ts
@@ -1,5 +1,5 @@
import angular from 'angular';
-import { ChangeTracker } from './change_tracker';
+import { ChangeTracker } from './ChangeTracker';
/** @ngInject */
export function unsavedChangesSrv(this: any, $rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
diff --git a/public/app/features/dashboard/specs/dashboard_migration.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts
similarity index 97%
rename from public/app/features/dashboard/specs/dashboard_migration.test.ts
rename to public/app/features/dashboard/state/DashboardMigrator.test.ts
index 5f693c9f6d9..fdb309b5db5 100644
--- a/public/app/features/dashboard/specs/dashboard_migration.test.ts
+++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts
@@ -1,6 +1,6 @@
import _ from 'lodash';
-import { DashboardModel } from '../dashboard_model';
-import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../state/DashboardModel';
+import { PanelModel } from '../state/PanelModel';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { expect } from 'test/lib/common';
@@ -127,7 +127,7 @@ describe('DashboardModel', () => {
});
it('dashboard schema version should be set to latest', () => {
- expect(model.schemaVersion).toBe(16);
+ expect(model.schemaVersion).toBe(17);
});
it('graph thresholds should be migrated', () => {
@@ -364,14 +364,6 @@ describe('DashboardModel', () => {
expect(dashboard.panels.length).toBe(2);
});
- it('minSpan should be twice', () => {
- model.rows = [createRow({ height: 8 }, [[6]])];
- model.rows[0].panels[0] = { minSpan: 12 };
-
- const dashboard = new DashboardModel(model);
- expect(dashboard.panels[0].minSpan).toBe(24);
- });
-
it('should assign id', () => {
model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
model.rows[0].panels[0] = {};
@@ -380,6 +372,16 @@ describe('DashboardModel', () => {
expect(dashboard.panels[0].id).toBe(1);
});
});
+
+ describe('when migrating from minSpan to maxPerRow', () => {
+ it('maxPerRow should be correct', () => {
+ const model = {
+ panels: [{ minSpan: 8 }],
+ };
+ const dashboard = new DashboardModel(model);
+ expect(dashboard.panels[0].maxPerRow).toBe(3);
+ });
+ });
});
function createRow(options, panelDescriptions: any[]) {
diff --git a/public/app/features/dashboard/dashboard_migration.ts b/public/app/features/dashboard/state/DashboardMigrator.ts
similarity index 95%
rename from public/app/features/dashboard/dashboard_migration.ts
rename to public/app/features/dashboard/state/DashboardMigrator.ts
index abd12ab4b13..ba631102b81 100644
--- a/public/app/features/dashboard/dashboard_migration.ts
+++ b/public/app/features/dashboard/state/DashboardMigrator.ts
@@ -7,8 +7,9 @@ import {
MIN_PANEL_HEIGHT,
DEFAULT_PANEL_SPAN,
} from 'app/core/constants';
-import { PanelModel } from './panel_model';
-import { DashboardModel } from './dashboard_model';
+import { PanelModel } from './PanelModel';
+import { DashboardModel } from './DashboardModel';
+import getFactors from 'app/core/utils/factors';
export class DashboardMigrator {
dashboard: DashboardModel;
@@ -21,7 +22,7 @@ export class DashboardMigrator {
let i, j, k, n;
const oldVersion = this.dashboard.schemaVersion;
const panelUpgrades = [];
- this.dashboard.schemaVersion = 16;
+ this.dashboard.schemaVersion = 17;
if (oldVersion === this.dashboard.schemaVersion) {
return;
@@ -368,6 +369,24 @@ export class DashboardMigrator {
this.upgradeToGridLayout(old);
}
+ if (oldVersion < 17) {
+ panelUpgrades.push(panel => {
+ if (panel.minSpan) {
+ const max = GRID_COLUMN_COUNT / panel.minSpan;
+ const factors = getFactors(GRID_COLUMN_COUNT);
+ // find the best match compared to factors
+ // (ie. [1,2,3,4,6,12,24] for 24 columns)
+ panel.maxPerRow =
+ factors[
+ _.findIndex(factors, o => {
+ return o > max;
+ }) - 1
+ ];
+ }
+ delete panel.minSpan;
+ });
+ }
+
if (panelUpgrades.length === 0) {
return;
}
diff --git a/public/app/features/dashboard/specs/repeat.test.ts b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts
similarity index 99%
rename from public/app/features/dashboard/specs/repeat.test.ts
rename to public/app/features/dashboard/state/DashboardModel.repeat.test.ts
index 49fb4ea9ee7..723cf1f9d16 100644
--- a/public/app/features/dashboard/specs/repeat.test.ts
+++ b/public/app/features/dashboard/state/DashboardModel.repeat.test.ts
@@ -1,5 +1,5 @@
import _ from 'lodash';
-import { DashboardModel } from '../dashboard_model';
+import { DashboardModel } from '../state/DashboardModel';
import { expect } from 'test/lib/common';
jest.mock('app/core/services/context_srv', () => ({}));
diff --git a/public/app/features/dashboard/specs/dashboard_model.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts
similarity index 99%
rename from public/app/features/dashboard/specs/dashboard_model.test.ts
rename to public/app/features/dashboard/state/DashboardModel.test.ts
index e59d52f2410..cd30fc2ecdc 100644
--- a/public/app/features/dashboard/specs/dashboard_model.test.ts
+++ b/public/app/features/dashboard/state/DashboardModel.test.ts
@@ -1,6 +1,6 @@
import _ from 'lodash';
-import { DashboardModel } from '../dashboard_model';
-import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../state/DashboardModel';
+import { PanelModel } from '../state/PanelModel';
jest.mock('app/core/services/context_srv', () => ({}));
diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/state/DashboardModel.ts
similarity index 98%
rename from public/app/features/dashboard/dashboard_model.ts
rename to public/app/features/dashboard/state/DashboardModel.ts
index 6f98bc5a17a..06dc9450425 100644
--- a/public/app/features/dashboard/dashboard_model.ts
+++ b/public/app/features/dashboard/state/DashboardModel.ts
@@ -1,14 +1,15 @@
import moment from 'moment';
import _ from 'lodash';
+import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
-import { DEFAULT_ANNOTATION_COLOR } from 'app/core/utils/colors';
import { Emitter } from 'app/core/utils/emitter';
import { contextSrv } from 'app/core/services/context_srv';
import sortByKeys from 'app/core/utils/sort_by_keys';
-import { PanelModel } from './panel_model';
-import { DashboardMigrator } from './dashboard_migration';
+import { PanelModel } from './PanelModel';
+import { DashboardMigrator } from './DashboardMigrator';
+import { TimeRange } from '@grafana/ui/src';
export class DashboardModel {
id: any;
@@ -200,8 +201,8 @@ export class DashboardModel {
this.events.emit('view-mode-changed', panel);
}
- timeRangeUpdated() {
- this.events.emit('time-range-updated');
+ timeRangeUpdated(timeRange: TimeRange) {
+ this.events.emit('time-range-updated', timeRange);
}
startRefresh() {
@@ -442,7 +443,7 @@ export class DashboardModel {
}
const selectedOptions = this.getSelectedVariableOptions(variable);
- const minWidth = panel.minSpan || 6;
+ const maxPerRow = panel.maxPerRow || 4;
let xPos = 0;
let yPos = panel.gridPos.y;
@@ -462,7 +463,7 @@ export class DashboardModel {
} else {
// set width based on how many are selected
// assumed the repeated panels should take up full row width
- copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, minWidth);
+ copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, GRID_COLUMN_COUNT / maxPerRow);
copy.gridPos.x = xPos;
copy.gridPos.y = yPos;
@@ -810,6 +811,10 @@ export class DashboardModel {
return this.getTimezone() === 'utc';
}
+ isSnapshot() {
+ return this.snapshot !== undefined;
+ }
+
getTimezone() {
return this.timezone ? this.timezone : contextSrv.user.timezone;
}
diff --git a/public/app/features/dashboard/specs/panel_model.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts
similarity index 86%
rename from public/app/features/dashboard/specs/panel_model.test.ts
rename to public/app/features/dashboard/state/PanelModel.test.ts
index 36bb8d6297e..b751caaa8f1 100644
--- a/public/app/features/dashboard/specs/panel_model.test.ts
+++ b/public/app/features/dashboard/state/PanelModel.test.ts
@@ -1,5 +1,5 @@
import _ from 'lodash';
-import { PanelModel } from '../panel_model';
+import { PanelModel } from '../state/PanelModel';
describe('PanelModel', () => {
describe('when creating new panel model', () => {
@@ -9,6 +9,10 @@ describe('PanelModel', () => {
model = new PanelModel({
type: 'table',
showColumns: true,
+ targets: [
+ {refId: 'A'},
+ {noRefId: true}
+ ]
});
});
@@ -20,6 +24,10 @@ describe('PanelModel', () => {
expect(model.showColumns).toBe(true);
});
+ it('should add missing refIds', () => {
+ expect(model.targets[1].refId).toBe('B');
+ });
+
it('getSaveModel should remove defaults', () => {
const saveModel = model.getSaveModel();
expect(saveModel.gridPos).toBe(undefined);
diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/state/PanelModel.ts
similarity index 91%
rename from public/app/features/dashboard/panel_model.ts
rename to public/app/features/dashboard/state/PanelModel.ts
index 2d5a70b47dd..0e5e48cec01 100644
--- a/public/app/features/dashboard/panel_model.ts
+++ b/public/app/features/dashboard/state/PanelModel.ts
@@ -1,7 +1,11 @@
-import { Emitter } from 'app/core/utils/emitter';
+// Libraries
import _ from 'lodash';
+
+// Types
+import { Emitter } from 'app/core/utils/emitter';
import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
-import { DataQuery } from 'app/types';
+import { DataQuery, TimeSeries } from '@grafana/ui';
+import { TableData } from '@grafana/ui/src';
export interface GridPos {
x: number;
@@ -52,7 +56,6 @@ const mustKeepProps: { [str: string]: boolean } = {
hasRefreshed: true,
events: true,
cacheTimeout: true,
- nullPointMode: true,
cachedPluginOptions: true,
transparent: true,
};
@@ -60,7 +63,7 @@ const mustKeepProps: { [str: string]: boolean } = {
const defaults: any = {
gridPos: { x: 0, y: 0, h: 3, w: 6 },
datasource: null,
- targets: [{}],
+ targets: [{ refId: 'A' }],
cachedPluginOptions: {},
transparent: false,
};
@@ -77,15 +80,15 @@ export class PanelModel {
repeatPanelId?: number;
repeatDirection?: string;
repeatedByRow?: boolean;
- minSpan?: number;
+ maxPerRow?: number;
collapsed?: boolean;
panels?: any;
soloMode?: boolean;
- targets: any[];
+ targets: DataQuery[];
datasource: string;
thresholds?: any;
- snapshotData?: any;
+ snapshotData?: TimeSeries[] | [TableData];
timeFrom?: any;
timeShift?: any;
hideTimeOverride?: any;
@@ -116,6 +119,18 @@ export class PanelModel {
// defaults
_.defaultsDeep(this, _.cloneDeep(defaults));
+ // queries must have refId
+ this.ensureQueryIds();
+ }
+
+ ensureQueryIds() {
+ if (this.targets) {
+ for (const query of this.targets) {
+ if (!query.refId) {
+ query.refId = this.getNextQueryLetter();
+ }
+ }
+ }
}
getOptions(panelDefaults) {
@@ -241,9 +256,7 @@ export class PanelModel {
addQuery(query?: Partial) {
query = query || { refId: 'A' };
query.refId = this.getNextQueryLetter();
- query.isNew = true;
-
- this.targets.push(query);
+ this.targets.push(query as DataQuery);
}
getNextQueryLetter(): string {
diff --git a/public/app/features/dashboard/state/index.ts b/public/app/features/dashboard/state/index.ts
new file mode 100644
index 00000000000..253d4aa7ac3
--- /dev/null
+++ b/public/app/features/dashboard/state/index.ts
@@ -0,0 +1,2 @@
+export { DashboardModel } from './DashboardModel';
+export { PanelModel } from './PanelModel';
diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts
index 190451671ad..568e9ba4f7f 100644
--- a/public/app/features/dashboard/utils/getPanelMenu.ts
+++ b/public/app/features/dashboard/utils/getPanelMenu.ts
@@ -2,8 +2,8 @@ import { updateLocation } from 'app/core/actions';
import { store } from 'app/store/store';
import { removePanel, duplicatePanel, copyPanel, editPanelJson, sharePanel } from 'app/features/dashboard/utils/panel';
-import { PanelModel } from 'app/features/dashboard/panel_model';
-import { DashboardModel } from 'app/features/dashboard/dashboard_model';
+import { PanelModel } from 'app/features/dashboard/state/PanelModel';
+import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelMenuItem } from '@grafana/ui';
export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
diff --git a/public/app/features/dashboard/utils/panel.test.ts b/public/app/features/dashboard/utils/panel.test.ts
new file mode 100644
index 00000000000..fdb4f16fa17
--- /dev/null
+++ b/public/app/features/dashboard/utils/panel.test.ts
@@ -0,0 +1,74 @@
+import moment from 'moment';
+import { TimeRange } from '@grafana/ui';
+import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
+import { advanceTo, clear } from 'jest-date-mock';
+
+const dashboardTimeRange: TimeRange = {
+ from: moment([2019, 1, 11, 12, 0]),
+ to: moment([2019, 1, 11, 18, 0]),
+ raw: {
+ from: 'now-6h',
+ to: 'now',
+ },
+};
+
+describe('applyPanelTimeOverrides', () => {
+ const fakeCurrentDate = moment([2019, 1, 11, 14, 0, 0]).toDate();
+
+ beforeAll(() => {
+ advanceTo(fakeCurrentDate);
+ });
+
+ afterAll(() => {
+ clear();
+ });
+
+ it('should apply relative time override', () => {
+ const panelModel = {
+ timeFrom: '2h',
+ };
+
+ // @ts-ignore: PanelModel type incositency
+ const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange);
+
+ expect(overrides.timeRange.from.toISOString()).toBe(moment([2019, 1, 11, 12]).toISOString());
+ expect(overrides.timeRange.to.toISOString()).toBe(fakeCurrentDate.toISOString());
+ expect(overrides.timeRange.raw.from).toBe('now-2h');
+ expect(overrides.timeRange.raw.to).toBe('now');
+ });
+
+ it('should apply time shift', () => {
+ const panelModel = {
+ timeShift: '2h'
+ };
+
+ const expectedFromDate = moment([2019, 1, 11, 10, 0, 0]).toDate();
+ const expectedToDate = moment([2019, 1, 11, 16, 0, 0]).toDate();
+
+ // @ts-ignore: PanelModel type incositency
+ const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange);
+
+ expect(overrides.timeRange.from.toISOString()).toBe(expectedFromDate.toISOString());
+ expect(overrides.timeRange.to.toISOString()).toBe(expectedToDate.toISOString());
+ expect((overrides.timeRange.raw.from as moment.Moment).toISOString()).toEqual(expectedFromDate.toISOString());
+ expect((overrides.timeRange.raw.to as moment.Moment).toISOString()).toEqual(expectedToDate.toISOString());
+ });
+
+ it('should apply both relative time and time shift', () => {
+ const panelModel = {
+ timeFrom: '2h',
+ timeShift: '2h'
+ };
+
+ const expectedFromDate = moment([2019, 1, 11, 10, 0, 0]).toDate();
+ const expectedToDate = moment([2019, 1, 11, 12, 0, 0]).toDate();
+
+ // @ts-ignore: PanelModel type incositency
+ const overrides = applyPanelTimeOverrides(panelModel, dashboardTimeRange);
+
+ expect(overrides.timeRange.from.toISOString()).toBe(expectedFromDate.toISOString());
+ expect(overrides.timeRange.to.toISOString()).toBe(expectedToDate.toISOString());
+ expect((overrides.timeRange.raw.from as moment.Moment).toISOString()).toEqual(expectedFromDate.toISOString());
+ expect((overrides.timeRange.raw.to as moment.Moment).toISOString()).toEqual(expectedToDate.toISOString());
+ });
+});
diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts
index f7ed0efd910..c0d753477a7 100644
--- a/public/app/features/dashboard/utils/panel.ts
+++ b/public/app/features/dashboard/utils/panel.ts
@@ -2,8 +2,8 @@
import store from 'app/core/store';
// Models
-import { DashboardModel } from 'app/features/dashboard/dashboard_model';
-import { PanelModel } from 'app/features/dashboard/panel_model';
+import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
+import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { TimeRange } from '@grafana/ui';
// Utils
@@ -80,7 +80,7 @@ export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => {
export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => {
appEvents.emit('show-modal', {
- src: 'public/app/features/dashboard/partials/shareModal.html',
+ src: 'public/app/features/dashboard/components/ShareModal/template.html',
model: {
dashboard: dashboard,
panel: panel,
@@ -142,12 +142,15 @@ export function applyPanelTimeOverrides(panel: PanelModel, timeRange: TimeRange)
const timeShift = '-' + timeShiftInterpolated;
newTimeData.timeInfo += ' timeshift ' + timeShift;
+ const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false);
+ const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true);
+
newTimeData.timeRange = {
- from: dateMath.parseDateMath(timeShift, timeRange.from, false),
- to: dateMath.parseDateMath(timeShift, timeRange.to, true),
+ from,
+ to,
raw: {
- from: timeRange.from,
- to: timeRange.to,
+ from,
+ to,
},
};
}
diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/DashboardsTable.tsx
index b732782c23b..077dc1dba63 100644
--- a/public/app/features/datasources/DashboardsTable.tsx
+++ b/public/app/features/datasources/DashboardsTable.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { PluginDashboard } from '../../types';
export interface Props {
@@ -7,7 +7,7 @@ export interface Props {
onRemove: (dashboard) => void;
}
-const DashboardsTable: SFC = ({ dashboards, onImport, onRemove }) => {
+const DashboardsTable: FC = ({ dashboards, onImport, onRemove }) => {
function buttonText(dashboard: PluginDashboard) {
return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
}
diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx
index 7409572b9cd..32e1d66928c 100644
--- a/public/app/features/datasources/DataSourceDashboards.test.tsx
+++ b/public/app/features/datasources/DataSourceDashboards.test.tsx
@@ -1,18 +1,20 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataSourceDashboards, Props } from './DataSourceDashboards';
-import { DataSource, NavModel, PluginDashboard } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
+import { NavModel, PluginDashboard } from 'app/types';
const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {} as NavModel,
dashboards: [] as PluginDashboard[],
- dataSource: {} as DataSource,
+ dataSource: {} as DataSourceSettings,
pageId: 1,
importDashboard: jest.fn(),
loadDataSource: jest.fn(),
loadPluginDashboards: jest.fn(),
removeDashboard: jest.fn(),
+ isLoading: false
};
Object.assign(props, propOverrides);
diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx
index 51f9c9ca945..382f9433f46 100644
--- a/public/app/features/datasources/DataSourceDashboards.tsx
+++ b/public/app/features/datasources/DataSourceDashboards.tsx
@@ -1,9 +1,13 @@
+// Libraries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
+
+// Components
+import Page from 'app/core/components/Page/Page';
import DashboardTable from './DashboardsTable';
-import { DataSource, NavModel, PluginDashboard } from 'app/types';
+
+// Actions & Selectors
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
import { loadDataSource } from './state/actions';
@@ -11,15 +15,20 @@ import { loadPluginDashboards } from '../plugins/state/actions';
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
import { getDataSource } from './state/selectors';
+// Types
+import { NavModel, PluginDashboard, StoreState } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
+
export interface Props {
navModel: NavModel;
dashboards: PluginDashboard[];
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
pageId: number;
importDashboard: typeof importDashboard;
loadDataSource: typeof loadDataSource;
loadPluginDashboards: typeof loadPluginDashboards;
removeDashboard: typeof removeDashboard;
+ isLoading: boolean;
}
export class DataSourceDashboards extends PureComponent {
@@ -56,30 +65,30 @@ export class DataSourceDashboards extends PureComponent {
};
render() {
- const { dashboards, navModel } = this.props;
+ const { dashboards, navModel, isLoading } = this.props;
return (
-
-
-
+
+
this.onImport(dashboard, overwrite)}
- onRemove={dashboard => this.onRemove(dashboard)}
- />
-
-
+ dashboards={dashboards}
+ onImport={(dashboard, overwrite) => this.onImport(dashboard, overwrite)}
+ onRemove={dashboard => this.onRemove(dashboard)}
+ />
+
+
+
);
}
}
-function mapStateToProps(state) {
+function mapStateToProps(state: StoreState) {
const pageId = getRouteParamsId(state.location);
-
return {
navModel: getNavModel(state.navIndex, `datasource-dashboards-${pageId}`),
pageId: pageId,
dashboards: state.plugins.dashboards,
dataSource: getDataSource(state.dataSources, pageId),
+ isLoading: state.plugins.isLoadingPluginDashboards
};
}
diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx
index 0895b92461b..0da12263aed 100644
--- a/public/app/features/datasources/DataSourcesList.tsx
+++ b/public/app/features/datasources/DataSourcesList.tsx
@@ -1,11 +1,16 @@
+// Libraries
import React, { PureComponent } from 'react';
import classNames from 'classnames';
+
+// Components
import DataSourcesListItem from './DataSourcesListItem';
-import { DataSource } from 'app/types';
+
+// Types
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
export interface Props {
- dataSources: DataSource[];
+ dataSources: DataSourceSettings[];
layoutMode: LayoutMode;
}
diff --git a/public/app/features/datasources/DataSourcesListItem.tsx b/public/app/features/datasources/DataSourcesListItem.tsx
index a4fedb893fb..157e9447852 100644
--- a/public/app/features/datasources/DataSourcesListItem.tsx
+++ b/public/app/features/datasources/DataSourcesListItem.tsx
@@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
-import { DataSource } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
export interface Props {
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
}
export class DataSourcesListItem extends PureComponent {
diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx
index 0ea716d62c9..44ef7a1cc49 100644
--- a/public/app/features/datasources/DataSourcesListPage.test.tsx
+++ b/public/app/features/datasources/DataSourcesListPage.test.tsx
@@ -1,16 +1,24 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataSourcesListPage, Props } from './DataSourcesListPage';
-import { DataSource, NavModel } from 'app/types';
+import { NavModel } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
- dataSources: [] as DataSource[],
+ dataSources: [] as DataSourceSettings[],
layoutMode: LayoutModes.Grid,
loadDataSources: jest.fn(),
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Data Sources'
+ }
+ } as NavModel,
dataSourcesCount: 0,
searchQuery: '',
setDataSourcesSearchQuery: jest.fn(),
diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx
index 6a292d63e53..b0b2ee56169 100644
--- a/public/app/features/datasources/DataSourcesListPage.tsx
+++ b/public/app/features/datasources/DataSourcesListPage.tsx
@@ -1,15 +1,23 @@
+// Libraries
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
-import PageHeader from '../../core/components/PageHeader/PageHeader';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
-import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
-import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
+
+// Components
+import Page from 'app/core/components/Page/Page';
+import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
+import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import DataSourcesList from './DataSourcesList';
-import { DataSource, NavModel } from 'app/types';
-import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+
+// Types
+import { DataSourceSettings } from '@grafana/ui/src/types';
+import { NavModel, StoreState } from 'app/types';
+import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
+
+// Actions
import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
-import { getNavModel } from '../../core/selectors/navModel';
+import { getNavModel } from 'app/core/selectors/navModel';
+
import {
getDataSources,
getDataSourcesCount,
@@ -19,7 +27,7 @@ import {
export interface Props {
navModel: NavModel;
- dataSources: DataSource[];
+ dataSources: DataSourceSettings[];
dataSourcesCount: number;
layoutMode: LayoutMode;
searchQuery: string;
@@ -67,30 +75,30 @@ export class DataSourcesListPage extends PureComponent {
};
return (
-
-
-
- {!hasFetched &&
}
- {hasFetched && dataSourcesCount === 0 &&
}
- {hasFetched &&
- dataSourcesCount > 0 && [
-
setDataSourcesLayoutMode(mode)}
- setSearchQuery={query => setDataSourcesSearchQuery(query)}
- linkButton={linkButton}
- key="action-bar"
- />,
- ,
- ]}
-
-
+
+
+ <>
+ {hasFetched && dataSourcesCount === 0 && }
+ {hasFetched &&
+ dataSourcesCount > 0 && [
+ setDataSourcesLayoutMode(mode)}
+ setSearchQuery={query => setDataSourcesSearchQuery(query)}
+ linkButton={linkButton}
+ key="action-bar"
+ />,
+ ,
+ ]}
+ >
+
+
);
}
}
-function mapStateToProps(state) {
+function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'datasources'),
dataSources: getDataSources(state.dataSources),
diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx
index c6eaa893d97..1d926048b8c 100644
--- a/public/app/features/datasources/NewDataSourcePage.tsx
+++ b/public/app/features/datasources/NewDataSourcePage.tsx
@@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
-import { NavModel, Plugin } from 'app/types';
+import Page from 'app/core/components/Page/Page';
+import { NavModel, Plugin, StoreState } from 'app/types';
import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getDataSourceTypes } from './state/selectors';
@@ -10,6 +10,7 @@ import { getDataSourceTypes } from './state/selectors';
export interface Props {
navModel: NavModel;
dataSourceTypes: Plugin[];
+ isLoading: boolean;
addDataSource: typeof addDataSource;
loadDataSourceTypes: typeof loadDataSourceTypes;
dataSourceTypeSearchQuery: string;
@@ -21,58 +22,59 @@ class NewDataSourcePage extends PureComponent {
this.props.loadDataSourceTypes();
}
- onDataSourceTypeClicked = type => {
- this.props.addDataSource(type);
+ onDataSourceTypeClicked = (plugin: Plugin) => {
+ this.props.addDataSource(plugin);
};
- onSearchQueryChange = event => {
+ onSearchQueryChange = (event: React.ChangeEvent) => {
this.props.setDataSourceTypeSearchQuery(event.target.value);
};
render() {
- const { navModel, dataSourceTypes, dataSourceTypeSearchQuery } = this.props;
-
+ const { navModel, dataSourceTypes, dataSourceTypeSearchQuery, isLoading } = this.props;
return (
-
-
-
-
Choose data source type
-
-
-
-
-
+
+
+
+
Choose data source type
+
+
+
+
+
+
+
+ {dataSourceTypes.map((plugin, index) => {
+ return (
+
this.onDataSourceTypeClicked(plugin)}
+ className="add-data-source-grid-item"
+ key={`${plugin.id}-${index}`}
+ >
+
+
{plugin.name}
+
+ );
+ })}
+
-
- {dataSourceTypes.map((type, index) => {
- return (
-
this.onDataSourceTypeClicked(type)}
- className="add-data-source-grid-item"
- key={`${type.id}-${index}`}
- >
-
-
{type.name}
-
- );
- })}
-
-
-
+
+
);
}
}
-function mapStateToProps(state) {
+function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'datasources'),
dataSourceTypes: getDataSourceTypes(state.dataSources),
+ isLoading: state.dataSources.isLoadingDataSources
};
}
diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
index 755d8eef74a..6658464ef7f 100644
--- a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
+++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
@@ -1,6 +1,6 @@
-import { DataSource } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
-export const getMockDataSources = (amount: number): DataSource[] => {
+export const getMockDataSources = (amount: number): DataSourceSettings[] => {
const dataSources = [];
for (let i = 0; i <= amount; i++) {
@@ -25,7 +25,7 @@ export const getMockDataSources = (amount: number): DataSource[] => {
return dataSources;
};
-export const getMockDataSource = (): DataSource => {
+export const getMockDataSource = (): DataSourceSettings => {
return {
access: '',
basicAuth: false,
diff --git a/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap
index 7a4f05a227f..28b20de8a18 100644
--- a/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap
+++ b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap
@@ -1,18 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
-
+
+
`;
diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
index c26ac50fed8..63998d43870 100644
--- a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
+++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
@@ -1,12 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render action bar and datasources 1`] = `
-
+
+
`;
exports[`Render should render component 1`] = `
-
+
`;
diff --git a/public/app/features/plugins/partials/ds_http_settings.html b/public/app/features/datasources/partials/http_settings.html
similarity index 100%
rename from public/app/features/plugins/partials/ds_http_settings.html
rename to public/app/features/datasources/partials/http_settings.html
diff --git a/public/app/features/datasources/settings/BasicSettings.tsx b/public/app/features/datasources/settings/BasicSettings.tsx
index 569e0909c4d..eef6776ffc4 100644
--- a/public/app/features/datasources/settings/BasicSettings.tsx
+++ b/public/app/features/datasources/settings/BasicSettings.tsx
@@ -1,6 +1,5 @@
-import React, { SFC } from 'react';
-import { Label } from 'app/core/components/Label/Label';
-import { Switch } from '../../../core/components/Switch/Switch';
+import React, { FC } from 'react';
+import { FormLabel, Switch } from '@grafana/ui';
export interface Props {
dataSourceName: string;
@@ -9,19 +8,19 @@ export interface Props {
onDefaultChange: (value: boolean) => void;
}
-const BasicSettings: SFC
= ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
+const BasicSettings: FC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
return (
diff --git a/public/app/features/datasources/settings/ButtonRow.tsx b/public/app/features/datasources/settings/ButtonRow.tsx
index cb70b29c706..6b85e21405c 100644
--- a/public/app/features/datasources/settings/ButtonRow.tsx
+++ b/public/app/features/datasources/settings/ButtonRow.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
export interface Props {
isReadOnly: boolean;
@@ -6,7 +6,7 @@ export interface Props {
onSubmit: (event) => void;
}
-const ButtonRow: SFC = ({ isReadOnly, onDelete, onSubmit }) => {
+const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit }) => {
return (
onSubmit(event)}>
diff --git a/public/app/features/datasources/settings/DataSourceSettings.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
similarity index 84%
rename from public/app/features/datasources/settings/DataSourceSettings.test.tsx
rename to public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
index d6c934aa6a4..8efc92be5be 100644
--- a/public/app/features/datasources/settings/DataSourceSettings.test.tsx
+++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx
@@ -1,7 +1,8 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { DataSourceSettings, Props } from './DataSourceSettings';
-import { DataSource, NavModel } from '../../../types';
+import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage';
+import { NavModel } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui';
import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
@@ -20,7 +21,7 @@ const setup = (propOverrides?: object) => {
Object.assign(props, propOverrides);
- return shallow( );
+ return shallow( );
};
describe('Render', () => {
@@ -32,7 +33,7 @@ describe('Render', () => {
it('should render loader', () => {
const wrapper = setup({
- dataSource: {} as DataSource,
+ dataSource: {} as DataSourceSettings,
});
expect(wrapper).toMatchSnapshot();
diff --git a/public/app/features/datasources/settings/DataSourceSettings.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx
similarity index 86%
rename from public/app/features/datasources/settings/DataSourceSettings.tsx
rename to public/app/features/datasources/settings/DataSourceSettingsPage.tsx
index 5786bd1db57..ff840390cf5 100644
--- a/public/app/features/datasources/settings/DataSourceSettings.tsx
+++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx
@@ -1,28 +1,33 @@
+// Libraries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
-import PageHeader from 'app/core/components/PageHeader/PageHeader';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
+// Components
+import Page from 'app/core/components/Page/Page';
import PluginSettings from './PluginSettings';
import BasicSettings from './BasicSettings';
import ButtonRow from './ButtonRow';
+// Services & Utils
import appEvents from 'app/core/app_events';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+// Actions & selectors
import { getDataSource, getDataSourceMeta } from '../state/selectors';
import { deleteDataSource, loadDataSource, setDataSourceName, setIsDefault, updateDataSource } from '../state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
-import { DataSource, NavModel, Plugin } from 'app/types/';
+// Types
+import { NavModel, Plugin, StoreState } from 'app/types/';
+import { DataSourceSettings } from '@grafana/ui/src/types/';
import { getDataSourceLoadingNav } from '../state/navModel';
export interface Props {
navModel: NavModel;
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
dataSourceMeta: Plugin;
pageId: number;
deleteDataSource: typeof deleteDataSource;
@@ -33,7 +38,7 @@ export interface Props {
}
interface State {
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
isTesting?: boolean;
testingMessage?: string;
testingStatus?: string;
@@ -44,12 +49,12 @@ enum DataSourceStates {
Beta = 'beta',
}
-export class DataSourceSettings extends PureComponent {
- constructor(props) {
+export class DataSourceSettingsPage extends PureComponent {
+ constructor(props: Props) {
super(props);
this.state = {
- dataSource: {} as DataSource,
+ dataSource: {} as DataSourceSettings,
};
}
@@ -59,8 +64,8 @@ export class DataSourceSettings extends PureComponent {
await loadDataSource(pageId);
}
- onSubmit = async event => {
- event.preventDefault();
+ onSubmit = async (evt: React.FormEvent) => {
+ evt.preventDefault();
await this.props.updateDataSource({ ...this.state.dataSource, name: this.props.dataSource.name });
@@ -83,7 +88,7 @@ export class DataSourceSettings extends PureComponent {
this.props.deleteDataSource();
};
- onModelChange = dataSource => {
+ onModelChange = (dataSource: DataSourceSettings) => {
this.setState({
dataSource: dataSource,
});
@@ -164,17 +169,18 @@ export class DataSourceSettings extends PureComponent {
});
}
+ get hasDataSource() {
+ return Object.keys(this.props.dataSource).length > 0;
+ }
+
render() {
const { dataSource, dataSourceMeta, navModel, setDataSourceName, setIsDefault } = this.props;
const { testingMessage, testingStatus } = this.state;
return (
-
-
- {Object.keys(dataSource).length === 0 ? (
-
- ) : (
-
+
+
+ {this.hasDataSource &&
{this.isReadOnly() && this.renderIsReadOnlyMessage()}
@@ -219,14 +225,14 @@ export class DataSourceSettings extends PureComponent {
/>
-
- )}
-
+
}
+
+
);
}
}
-function mapStateToProps(state) {
+function mapStateToProps(state: StoreState) {
const pageId = getRouteParamsId(state.location);
const dataSource = getDataSource(state.dataSources, pageId);
@@ -246,4 +252,4 @@ const mapDispatchToProps = {
setIsDefault,
};
-export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceSettings));
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceSettingsPage));
diff --git a/public/app/features/datasources/settings/HttpSettingsCtrl.ts b/public/app/features/datasources/settings/HttpSettingsCtrl.ts
new file mode 100644
index 00000000000..47022c283f8
--- /dev/null
+++ b/public/app/features/datasources/settings/HttpSettingsCtrl.ts
@@ -0,0 +1,26 @@
+import { coreModule } from 'app/core/core';
+
+coreModule.directive('datasourceHttpSettings', () => {
+ return {
+ scope: {
+ current: '=',
+ suggestUrl: '@',
+ noDirectAccess: '@',
+ },
+ templateUrl: 'public/app/features/datasources/partials/http_settings.html',
+ link: {
+ pre: ($scope, elem, attrs) => {
+ // do not show access option if direct access is disabled
+ $scope.showAccessOption = $scope.noDirectAccess !== 'true';
+ $scope.showAccessHelp = false;
+ $scope.toggleAccessHelp = () => {
+ $scope.showAccessHelp = !$scope.showAccessHelp;
+ };
+
+ $scope.getSuggestUrls = () => {
+ return [$scope.suggestUrl];
+ };
+ },
+ },
+ };
+});
diff --git a/public/app/features/datasources/settings/PluginSettings.tsx b/public/app/features/datasources/settings/PluginSettings.tsx
index e0b742985cc..8b65accd50a 100644
--- a/public/app/features/datasources/settings/PluginSettings.tsx
+++ b/public/app/features/datasources/settings/PluginSettings.tsx
@@ -1,20 +1,21 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
-import { DataSource, Plugin } from 'app/types/';
+import { Plugin } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
export interface Props {
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
dataSourceMeta: Plugin;
- onModelChange: (dataSource: DataSource) => void;
+ onModelChange: (dataSource: DataSourceSettings) => void;
}
export class PluginSettings extends PureComponent {
element: any;
component: AngularComponent;
scopeProps: {
- ctrl: { datasourceMeta: Plugin; current: DataSource };
- onModelChanged: (dataSource: DataSource) => void;
+ ctrl: { datasourceMeta: Plugin; current: DataSourceSettings };
+ onModelChanged: (dataSource: DataSourceSettings) => void;
};
constructor(props) {
@@ -51,7 +52,7 @@ export class PluginSettings extends PureComponent {
}
}
- onModelChanged = (dataSource: DataSource) => {
+ onModelChanged = (dataSource: DataSourceSettings) => {
this.props.onModelChange(dataSource);
};
diff --git a/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap
index 65d4b5ab953..951edf10dcc 100644
--- a/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap
+++ b/public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap
@@ -16,7 +16,7 @@ exports[`Render should render component 1`] = `
}
>
Name
diff --git a/public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap
deleted file mode 100644
index bcd8237ff39..00000000000
--- a/public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap
+++ /dev/null
@@ -1,415 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Render should render alpha info text 1`] = `
-
-
-
-
-
-
- This plugin is marked as being in alpha state, which means it is in early development phase and updates will include breaking changes.
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Render should render beta info text 1`] = `
-
-
-
-
-
-
- This plugin is marked as being in a beta development state. This means it is in currently in active development and could be missing important features.
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Render should render component 1`] = `
-
-`;
-
-exports[`Render should render is ready only message 1`] = `
-
-
-
-
-
-
- This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Render should render loader 1`] = `
-
-`;
diff --git a/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap b/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap
new file mode 100644
index 00000000000..af7895ac6b8
--- /dev/null
+++ b/public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap
@@ -0,0 +1,426 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render alpha info text 1`] = `
+
+
+
+
+
+
+ This plugin is marked as being in alpha state, which means it is in early development phase and updates will include breaking changes.
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Render should render beta info text 1`] = `
+
+
+
+
+
+
+ This plugin is marked as being in a beta development state. This means it is in currently in active development and could be missing important features.
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Render should render component 1`] = `
+
+
+
+
+
+`;
+
+exports[`Render should render is ready only message 1`] = `
+
+
+
+
+
+
+ This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Render should render loader 1`] = `
+
+
+
+`;
diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts
index fa9d47eb6a1..0fa260ffafa 100644
--- a/public/app/features/datasources/state/actions.ts
+++ b/public/app/features/datasources/state/actions.ts
@@ -6,11 +6,13 @@ import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
import { updateLocation, updateNavIndex, UpdateNavIndexAction } from 'app/core/actions';
import { UpdateLocationAction } from 'app/core/actions/location';
import { buildNavModel } from './navModel';
-import { DataSource, Plugin, StoreState } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
+import { Plugin, StoreState } from 'app/types';
export enum ActionTypes {
LoadDataSources = 'LOAD_DATA_SOURCES',
LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
+ LoadedDataSourceTypes = 'LOADED_DATA_SOURCE_TYPES',
LoadDataSource = 'LOAD_DATA_SOURCE',
LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
@@ -22,7 +24,7 @@ export enum ActionTypes {
interface LoadDataSourcesAction {
type: ActionTypes.LoadDataSources;
- payload: DataSource[];
+ payload: DataSourceSettings[];
}
interface SetDataSourcesSearchQueryAction {
@@ -37,6 +39,10 @@ interface SetDataSourcesLayoutModeAction {
interface LoadDataSourceTypesAction {
type: ActionTypes.LoadDataSourceTypes;
+}
+
+interface LoadedDataSourceTypesAction {
+ type: ActionTypes.LoadedDataSourceTypes;
payload: Plugin[];
}
@@ -47,7 +53,7 @@ interface SetDataSourceTypeSearchQueryAction {
interface LoadDataSourceAction {
type: ActionTypes.LoadDataSource;
- payload: DataSource;
+ payload: DataSourceSettings;
}
interface LoadDataSourceMetaAction {
@@ -65,12 +71,12 @@ interface SetIsDefaultAction {
payload: boolean;
}
-const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
+const dataSourcesLoaded = (dataSources: DataSourceSettings[]): LoadDataSourcesAction => ({
type: ActionTypes.LoadDataSources,
payload: dataSources,
});
-const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({
+const dataSourceLoaded = (dataSource: DataSourceSettings): LoadDataSourceAction => ({
type: ActionTypes.LoadDataSource,
payload: dataSource,
});
@@ -80,8 +86,12 @@ const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction
payload: dataSourceMeta,
});
-const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({
+const dataSourceTypesLoad = (): LoadDataSourceTypesAction => ({
type: ActionTypes.LoadDataSourceTypes,
+});
+
+const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadedDataSourceTypesAction => ({
+ type: ActionTypes.LoadedDataSourceTypes,
payload: dataSourceTypes,
});
@@ -116,6 +126,7 @@ export type Action =
| SetDataSourcesLayoutModeAction
| UpdateLocationAction
| LoadDataSourceTypesAction
+ | LoadedDataSourceTypesAction
| SetDataSourceTypeSearchQueryAction
| LoadDataSourceAction
| UpdateNavIndexAction
@@ -166,12 +177,13 @@ export function addDataSource(plugin: Plugin): ThunkResult {
export function loadDataSourceTypes(): ThunkResult {
return async dispatch => {
+ dispatch(dataSourceTypesLoad());
const result = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
dispatch(dataSourceTypesLoaded(result));
};
}
-export function updateDataSource(dataSource: DataSource): ThunkResult {
+export function updateDataSource(dataSource: DataSourceSettings): ThunkResult {
return async dispatch => {
await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource);
await updateFrontendSettings();
diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts
index 19fc422e3b8..b0b121a9997 100644
--- a/public/app/features/datasources/state/navModel.ts
+++ b/public/app/features/datasources/state/navModel.ts
@@ -1,7 +1,8 @@
-import { DataSource, NavModel, NavModelItem, PluginMeta } from 'app/types';
+import { NavModel, NavModelItem } from 'app/types';
+import { PluginMeta, DataSourceSettings } from '@grafana/ui/src/types';
import config from 'app/core/config';
-export function buildNavModel(dataSource: DataSource, pluginMeta: PluginMeta): NavModelItem {
+export function buildNavModel(dataSource: DataSourceSettings, pluginMeta: PluginMeta): NavModelItem {
const navModel = {
img: pluginMeta.info.logos.large,
id: 'datasource-' + dataSource.id,
diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts
index e8625aac0d2..66151990aea 100644
--- a/public/app/features/datasources/state/reducers.ts
+++ b/public/app/features/datasources/state/reducers.ts
@@ -1,16 +1,18 @@
-import { DataSource, DataSourcesState, Plugin } from 'app/types';
+import { DataSourcesState, Plugin } from 'app/types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
import { Action, ActionTypes } from './actions';
-import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
+import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
const initialState: DataSourcesState = {
- dataSources: [] as DataSource[],
- dataSource: {} as DataSource,
- layoutMode: LayoutModes.Grid,
+ dataSources: [] as DataSourceSettings[],
+ dataSource: {} as DataSourceSettings,
+ layoutMode: LayoutModes.List,
searchQuery: '',
dataSourcesCount: 0,
dataSourceTypes: [] as Plugin[],
dataSourceTypeSearchQuery: '',
hasFetched: false,
+ isLoadingDataSources: false,
dataSourceMeta: {} as Plugin,
};
@@ -29,7 +31,10 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
return { ...state, layoutMode: action.payload };
case ActionTypes.LoadDataSourceTypes:
- return { ...state, dataSourceTypes: action.payload };
+ return { ...state, dataSourceTypes: [], isLoadingDataSources: true };
+
+ case ActionTypes.LoadedDataSourceTypes:
+ return { ...state, dataSourceTypes: action.payload, isLoadingDataSources: false };
case ActionTypes.SetDataSourceTypeSearchQuery:
return { ...state, dataSourceTypeSearchQuery: action.payload };
diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts
index 2466e465d1d..bb95d95e59a 100644
--- a/public/app/features/datasources/state/selectors.ts
+++ b/public/app/features/datasources/state/selectors.ts
@@ -1,4 +1,4 @@
-import { DataSource } from '../../../types';
+import { DataSourceSettings } from '@grafana/ui/src/types';
export const getDataSources = state => {
const regex = new RegExp(state.searchQuery, 'i');
@@ -16,11 +16,11 @@ export const getDataSourceTypes = state => {
});
};
-export const getDataSource = (state, dataSourceId): DataSource | null => {
+export const getDataSource = (state, dataSourceId): DataSourceSettings | null => {
if (state.dataSource.id === parseInt(dataSourceId, 10)) {
return state.dataSource;
}
- return {} as DataSource;
+ return {} as DataSourceSettings;
};
export const getDataSourceMeta = (state, type): Plugin => {
diff --git a/public/app/features/explore/Error.tsx b/public/app/features/explore/Error.tsx
index 2bfd366b3c9..0c4e0e7ea85 100644
--- a/public/app/features/explore/Error.tsx
+++ b/public/app/features/explore/Error.tsx
@@ -1,10 +1,10 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
interface Props {
message: any;
}
-export const Alert: SFC = props => {
+export const Alert: FC = props => {
const { message } = props;
return (
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index a905034f302..909c4e81b8b 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -1,55 +1,66 @@
+// Libraries
import React from 'react';
import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
import _ from 'lodash';
+import { AutoSizer } from 'react-virtualized';
-import { DataSource } from 'app/types/datasources';
-import {
- ExploreState,
- ExploreUrlState,
- QueryTransaction,
- ResultType,
- QueryHintGetter,
- QueryHint,
-} from 'app/types/explore';
-import { TimeRange } from '@grafana/ui';
-import { DataQuery } from 'app/types/series';
+// Services & Utils
import store from 'app/core/store';
-import {
- DEFAULT_RANGE,
- calculateResultsFromQueryTransactions,
- ensureQueries,
- getIntervals,
- generateKey,
- generateQueryKeys,
- hasNonEmptyQuery,
- makeTimeSeriesList,
- updateHistory,
-} from 'app/core/utils/explore';
-import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
-import TableModel from 'app/core/table_model';
-import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
-import { Emitter } from 'app/core/utils/emitter';
-import * as dateMath from 'app/core/utils/datemath';
-import Panel from './Panel';
-import QueryRows from './QueryRows';
-import Graph from './Graph';
-import Logs from './Logs';
-import Table from './Table';
-import ErrorBoundary from './ErrorBoundary';
+// Components
import { Alert } from './Error';
+import ErrorBoundary from './ErrorBoundary';
+import GraphContainer from './GraphContainer';
+import LogsContainer from './LogsContainer';
+import QueryRows from './QueryRows';
+import TableContainer from './TableContainer';
import TimePicker, { parseTime } from './TimePicker';
-const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
+// Actions
+import {
+ changeSize,
+ changeTime,
+ initializeExplore,
+ modifyQueries,
+ scanStart,
+ scanStop,
+ setQueries,
+} from './state/actions';
+
+// Types
+import { RawTimeRange, TimeRange, DataQuery } from '@grafana/ui';
+import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore';
+import { StoreState } from 'app/types';
+import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore';
+import { Emitter } from 'app/core/utils/emitter';
+import { ExploreToolbar } from './ExploreToolbar';
interface ExploreProps {
- datasourceSrv: DatasourceSrv;
- onChangeSplit: (split: boolean, state?: ExploreState) => void;
- onSaveState: (key: string, state: ExploreState) => void;
- position: string;
+ StartPage?: any;
+ changeSize: typeof changeSize;
+ changeTime: typeof changeTime;
+ datasourceError: string;
+ datasourceInstance: any;
+ datasourceLoading: boolean | null;
+ datasourceMissing: boolean;
+ exploreId: ExploreId;
+ initialQueries: DataQuery[];
+ initializeExplore: typeof initializeExplore;
+ initialized: boolean;
+ modifyQueries: typeof modifyQueries;
+ range: RawTimeRange;
+ scanner?: RangeScanner;
+ scanning?: boolean;
+ scanRange?: RawTimeRange;
+ scanStart: typeof scanStart;
+ scanStop: typeof scanStop;
+ setQueries: typeof setQueries;
split: boolean;
- splitState?: ExploreState;
- stateKey: string;
+ showingStartPage?: boolean;
+ supportsGraph: boolean | null;
+ supportsLogs: boolean | null;
+ supportsTable: boolean | null;
urlState: ExploreUrlState;
}
@@ -58,26 +69,14 @@ interface ExploreProps {
* Once a datasource is selected it populates the query section at the top.
* When queries are run, their results are being displayed in the main section.
* The datasource determines what kind of query editor it brings, and what kind
- * of results viewers it supports.
+ * of results viewers it supports. The state is managed entirely in Redux.
*
- * QUERY HANDLING
+ * SPLIT VIEW
*
- * TLDR: to not re-render Explore during edits, query editing is not "controlled"
- * in a React sense: values need to be pushed down via `initialQueries`, while
- * edits travel up via `this.modifiedQueries`.
- *
- * By default the query rows start without prior state: `initialQueries` will
- * contain one empty DataQuery. While the user modifies the DataQuery, the
- * modifications are being tracked in `this.modifiedQueries`, which need to be
- * used whenever a query is sent to the datasource to reflect what the user sees
- * on the screen. Query"react-popper": "^0.7.5", rows can be initialized or reset using `initialQueries`,
- * by giving the respec"react-popper": "^0.7.5",tive row a new key. This wipes the old row and its state.
- * This property is als"react-popper": "^0.7.5",o used to govern how many query rows there are (minimum 1).
- *
- * This flow makes sure that a query row can be arbitrarily complex without the
- * fear of being wiped or re-initialized via props. The query row is free to keep
- * its own state while the user edits or builds a query. Valid queries can be sent
- * up to Explore via the `onChangeQuery` prop.
+ * Explore can have two Explore areas side-by-side. This is handled in `Wrapper.tsx`.
+ * Since there can be multiple Explores (e.g., left and right) each action needs
+ * the `exploreId` as first parameter so that the reducer knows which Explore state
+ * is affected.
*
* DATASOURCE REQUESTS
*
@@ -89,23 +88,9 @@ interface ExploreProps {
* The result viewers determine some of the query options sent to the datasource, e.g.,
* `format`, to indicate eventual transformations by the datasources' result transformers.
*/
-export class Explore extends React.PureComponent
{
+export class Explore extends React.PureComponent {
el: any;
exploreEvents: Emitter;
- /**
- * Set via URL or local storage
- */
- initialDatasource: string;
- /**
- * Current query expressions of the rows including their modifications, used for running queries.
- * Not kept in component state to prevent edit-render roundtrips.
- */
- modifiedQueries: DataQuery[];
- /**
- * Local ID cache to compare requested vs selected datasource
- */
- requestedDatasourceId: string;
- scanTimer: NodeJS.Timer;
/**
* Timepicker to control scanning
*/
@@ -113,359 +98,49 @@ export class Explore extends React.PureComponent {
constructor(props) {
super(props);
- const splitState: ExploreState = props.splitState;
- let initialQueries: DataQuery[];
- if (splitState) {
- // Split state overrides everything
- this.state = splitState;
- initialQueries = splitState.initialQueries;
- } else {
- const { datasource, queries, range } = props.urlState as ExploreUrlState;
- const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
- initialQueries = ensureQueries(queries);
- const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE };
- // Millies step for helper bar charts
- const initialGraphInterval = 15 * 1000;
- this.state = {
- datasource: null,
- datasourceError: null,
- datasourceLoading: null,
- datasourceMissing: false,
- exploreDatasources: [],
- graphInterval: initialGraphInterval,
- graphResult: [],
- initialDatasource,
- initialQueries,
- history: [],
- logsResult: null,
- queryTransactions: [],
- range: initialRange,
- scanning: false,
- showingGraph: true,
- showingLogs: true,
- showingStartPage: false,
- showingTable: true,
- supportsGraph: null,
- supportsLogs: null,
- supportsTable: null,
- tableResult: new TableModel(),
- };
- }
- this.modifiedQueries = initialQueries.slice();
this.exploreEvents = new Emitter();
this.timepickerRef = React.createRef();
}
async componentDidMount() {
- const { datasourceSrv } = this.props;
- const { initialDatasource } = this.state;
- if (!datasourceSrv) {
- throw new Error('No datasource service passed as props.');
- }
-
- const datasources = datasourceSrv.getExternal();
- const exploreDatasources = datasources.map(ds => ({
- value: ds.name,
- name: ds.name,
- meta: ds.meta,
- }));
-
- if (datasources.length > 0) {
- this.setState({ datasourceLoading: true, exploreDatasources });
- // Priority for datasource preselection: URL, localstorage, default datasource
- let datasource;
- if (initialDatasource) {
- datasource = await datasourceSrv.get(initialDatasource);
- } else {
- datasource = await datasourceSrv.get();
- }
- await this.setDatasource(datasource);
- } else {
- this.setState({ datasourceMissing: true });
+ const { exploreId, initialized, urlState } = this.props;
+ // Don't initialize on split, but need to initialize urlparameters when present
+ if (!initialized) {
+ // Load URL state and parse range
+ const { datasource, queries, range = DEFAULT_RANGE } = (urlState || {}) as ExploreUrlState;
+ const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
+ const initialQueries: DataQuery[] = ensureQueries(queries);
+ const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
+ const width = this.el ? this.el.offsetWidth : 0;
+ this.props.initializeExplore(
+ exploreId,
+ initialDatasource,
+ initialQueries,
+ initialRange,
+ width,
+ this.exploreEvents
+ );
}
}
componentWillUnmount() {
this.exploreEvents.removeAllListeners();
- clearTimeout(this.scanTimer);
- }
-
- async setDatasource(datasource: any, origin?: DataSource) {
- const { initialQueries, range } = this.state;
-
- const supportsGraph = datasource.meta.metrics;
- const supportsLogs = datasource.meta.logs;
- const supportsTable = datasource.meta.tables;
- const datasourceId = datasource.meta.id;
- let datasourceError = null;
-
- // Keep ID to track selection
- this.requestedDatasourceId = datasourceId;
-
- try {
- const testResult = await datasource.testDatasource();
- datasourceError = testResult.status === 'success' ? null : testResult.message;
- } catch (error) {
- datasourceError = (error && error.statusText) || 'Network error';
- }
-
- if (datasourceId !== this.requestedDatasourceId) {
- // User already changed datasource again, discard results
- return;
- }
-
- const historyKey = `grafana.explore.history.${datasourceId}`;
- const history = store.getObject(historyKey, []);
-
- if (datasource.init) {
- datasource.init();
- }
-
- // Check if queries can be imported from previously selected datasource
- let modifiedQueries = this.modifiedQueries;
- if (origin) {
- if (origin.meta.id === datasource.meta.id) {
- // Keep same queries if same type of datasource
- modifiedQueries = [...this.modifiedQueries];
- } else if (datasource.importQueries) {
- // Datasource-specific importers
- modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta);
- } else {
- // Default is blank queries
- modifiedQueries = ensureQueries();
- }
- }
-
- // Reset edit state with new queries
- const nextQueries = initialQueries.map((q, i) => ({
- ...modifiedQueries[i],
- ...generateQueryKeys(i),
- }));
- this.modifiedQueries = modifiedQueries;
-
- // Custom components
- const StartPage = datasource.pluginExports.ExploreStartPage;
-
- // Calculate graph bucketing interval
- const graphInterval = getIntervals(range, datasource, this.el ? this.el.offsetWidth : 0).intervalMs;
-
- this.setState(
- {
- StartPage,
- datasource,
- datasourceError,
- graphInterval,
- history,
- supportsGraph,
- supportsLogs,
- supportsTable,
- datasourceLoading: false,
- initialDatasource: datasource.name,
- initialQueries: nextQueries,
- logsHighlighterExpressions: undefined,
- showingStartPage: Boolean(StartPage),
- },
- () => {
- if (datasourceError === null) {
- // Save last-used datasource
- store.set(LAST_USED_DATASOURCE_KEY, datasource.name);
- this.onSubmit();
- }
- }
- );
}
getRef = el => {
this.el = el;
};
- onAddQueryRow = index => {
- // Local cache
- this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) };
-
- this.setState(state => {
- const { initialQueries, queryTransactions } = state;
-
- const nextQueries = [
- ...initialQueries.slice(0, index + 1),
- { ...this.modifiedQueries[index + 1] },
- ...initialQueries.slice(index + 1),
- ];
-
- // Ongoing transactions need to update their row indices
- const nextQueryTransactions = queryTransactions.map(qt => {
- if (qt.rowIndex > index) {
- return {
- ...qt,
- rowIndex: qt.rowIndex + 1,
- };
- }
- return qt;
- });
-
- return {
- initialQueries: nextQueries,
- logsHighlighterExpressions: undefined,
- queryTransactions: nextQueryTransactions,
- };
- });
- };
-
- onChangeDatasource = async option => {
- const origin = this.state.datasource;
- this.setState({
- datasource: null,
- datasourceError: null,
- datasourceLoading: true,
- queryTransactions: [],
- });
- const datasourceName = option.value;
- const datasource = await this.props.datasourceSrv.get(datasourceName);
- this.setDatasource(datasource as any, origin);
- };
-
- onChangeQuery = (value: DataQuery, index: number, override?: boolean) => {
- // Null value means reset
- if (value === null) {
- value = { ...generateQueryKeys(index) };
- }
-
- // Keep current value in local cache
- this.modifiedQueries[index] = value;
-
- if (override) {
- this.setState(state => {
- // Replace query row by injecting new key
- const { initialQueries, queryTransactions } = state;
- const query: DataQuery = {
- ...value,
- ...generateQueryKeys(index),
- };
- const nextQueries = [...initialQueries];
- nextQueries[index] = query;
- this.modifiedQueries = [...nextQueries];
-
- // Discard ongoing transaction related to row query
- const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
-
- return {
- initialQueries: nextQueries,
- queryTransactions: nextQueryTransactions,
- };
- }, this.onSubmit);
- } else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) {
- // Live preview of log search matches. Can only work on single row query for now
- this.updateLogsHighlights(value);
- }
- };
-
- onChangeTime = (nextRange: TimeRange, scanning?: boolean) => {
- const range: TimeRange = {
- ...nextRange,
- };
- if (this.state.scanning && !scanning) {
+ onChangeTime = (range: TimeRange, changedByScanner?: boolean) => {
+ if (this.props.scanning && !changedByScanner) {
this.onStopScanning();
}
- this.setState({ range, scanning }, () => this.onSubmit());
- };
-
- onClickClear = () => {
- this.onStopScanning();
- this.modifiedQueries = ensureQueries();
- this.setState(
- prevState => ({
- initialQueries: [...this.modifiedQueries],
- queryTransactions: [],
- showingStartPage: Boolean(prevState.StartPage),
- }),
- this.saveState
- );
- };
-
- onClickCloseSplit = () => {
- const { onChangeSplit } = this.props;
- if (onChangeSplit) {
- onChangeSplit(false);
- }
- };
-
- onClickGraphButton = () => {
- this.setState(
- state => {
- const showingGraph = !state.showingGraph;
- let nextQueryTransactions = state.queryTransactions;
- if (!showingGraph) {
- // Discard transactions related to Graph query
- nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph');
- }
- return { queryTransactions: nextQueryTransactions, showingGraph };
- },
- () => {
- if (this.state.showingGraph) {
- this.onSubmit();
- }
- }
- );
- };
-
- onClickLogsButton = () => {
- this.setState(
- state => {
- const showingLogs = !state.showingLogs;
- let nextQueryTransactions = state.queryTransactions;
- if (!showingLogs) {
- // Discard transactions related to Logs query
- nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs');
- }
- return { queryTransactions: nextQueryTransactions, showingLogs };
- },
- () => {
- if (this.state.showingLogs) {
- this.onSubmit();
- }
- }
- );
+ this.props.changeTime(this.props.exploreId, range);
};
// Use this in help pages to set page to a single query
onClickExample = (query: DataQuery) => {
- const nextQueries = [{ ...query, ...generateQueryKeys() }];
- this.modifiedQueries = [...nextQueries];
- this.setState({ initialQueries: nextQueries }, this.onSubmit);
- };
-
- onClickSplit = () => {
- const { onChangeSplit } = this.props;
- if (onChangeSplit) {
- const state = this.cloneState();
- onChangeSplit(true, state);
- }
- };
-
- onClickTableButton = () => {
- this.setState(
- state => {
- const showingTable = !state.showingTable;
- if (showingTable) {
- return { showingTable, queryTransactions: state.queryTransactions };
- }
-
- // Toggle off needs discarding of table queries
- const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- return { ...results, queryTransactions: nextQueryTransactions, showingTable };
- },
- () => {
- if (this.state.showingTable) {
- this.onSubmit();
- }
- }
- );
+ this.props.setQueries(this.props.exploreId, [query]);
};
onClickLabel = (key: string, value: string) => {
@@ -473,481 +148,52 @@ export class Explore extends React.PureComponent {
};
onModifyQueries = (action, index?: number) => {
- const { datasource } = this.state;
- if (datasource && datasource.modifyQuery) {
- const preventSubmit = action.preventSubmit;
- this.setState(
- state => {
- const { initialQueries, queryTransactions } = state;
- let nextQueries: DataQuery[];
- let nextQueryTransactions;
- if (index === undefined) {
- // Modify all queries
- nextQueries = initialQueries.map((query, i) => ({
- ...datasource.modifyQuery(this.modifiedQueries[i], action),
- ...generateQueryKeys(i),
- }));
- // Discard all ongoing transactions
- nextQueryTransactions = [];
- } else {
- // Modify query only at index
- nextQueries = initialQueries.map((query, i) => {
- // Synchronize all queries with local query cache to ensure consistency
- // TODO still needed?
- return i === index
- ? {
- ...datasource.modifyQuery(this.modifiedQueries[i], action),
- ...generateQueryKeys(i),
- }
- : query;
- });
- nextQueryTransactions = queryTransactions
- // Consume the hint corresponding to the action
- .map(qt => {
- if (qt.hints != null && qt.rowIndex === index) {
- qt.hints = qt.hints.filter(hint => hint.fix.action !== action);
- }
- return qt;
- })
- // Preserve previous row query transaction to keep results visible if next query is incomplete
- .filter(qt => preventSubmit || qt.rowIndex !== index);
- }
- this.modifiedQueries = [...nextQueries];
- return {
- initialQueries: nextQueries,
- queryTransactions: nextQueryTransactions,
- };
- },
- // Accepting certain fixes do not result in a well-formed query which should not be submitted
- !preventSubmit ? () => this.onSubmit() : null
- );
+ const { datasourceInstance } = this.props;
+ if (datasourceInstance && datasourceInstance.modifyQuery) {
+ const modifier = (queries: DataQuery, modification: any) => datasourceInstance.modifyQuery(queries, modification);
+ this.props.modifyQueries(this.props.exploreId, action, index, modifier);
}
};
- onRemoveQueryRow = index => {
- // Remove from local cache
- this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)];
-
- this.setState(
- state => {
- const { initialQueries, queryTransactions } = state;
- if (initialQueries.length <= 1) {
- return null;
- }
- // Remove row from react state
- const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)];
-
- // Discard transactions related to row query
- const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- return {
- ...results,
- initialQueries: nextQueries,
- logsHighlighterExpressions: undefined,
- queryTransactions: nextQueryTransactions,
- };
- },
- () => this.onSubmit()
- );
+ onResize = (size: { height: number; width: number }) => {
+ this.props.changeSize(this.props.exploreId, size);
};
onStartScanning = () => {
- this.setState({ scanning: true }, this.scanPreviousRange);
+ // Scanner will trigger a query
+ const scanner = this.scanPreviousRange;
+ this.props.scanStart(this.props.exploreId, scanner);
};
- scanPreviousRange = () => {
- const scanRange = this.timepickerRef.current.move(-1, true);
- this.setState({ scanRange });
+ scanPreviousRange = (): RawTimeRange => {
+ // Calling move() on the timepicker will trigger this.onChangeTime()
+ return this.timepickerRef.current.move(-1, true);
};
onStopScanning = () => {
- clearTimeout(this.scanTimer);
- this.setState(state => {
- const { queryTransactions } = state;
- const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
- return { queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined };
- });
- };
-
- onSubmit = () => {
- const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
- // Keep table queries first since they need to return quickly
- if (showingTable && supportsTable) {
- this.runQueries(
- 'Table',
- {
- format: 'table',
- instant: true,
- valueWithRefId: true,
- },
- data => data[0]
- );
- }
- if (showingGraph && supportsGraph) {
- this.runQueries(
- 'Graph',
- {
- format: 'time_series',
- instant: false,
- },
- makeTimeSeriesList
- );
- }
- if (showingLogs && supportsLogs) {
- this.runQueries('Logs', { format: 'logs' });
- }
- this.saveState();
- };
-
- buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) {
- const { datasource, range } = this.state;
- const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth);
-
- const configuredQueries = [
- {
- ...query,
- ...queryOptions,
- },
- ];
-
- // Clone range for query request
- // const queryRange: RawTimeRange = { ...range };
- // const { from, to, raw } = this.timeSrv.timeRange();
- // Most datasource is using `panelId + query.refId` for cancellation logic.
- // Using `format` here because it relates to the view panel that the request is for.
- // However, some datasources don't use `panelId + query.refId`, but only `panelId`.
- // Therefore panel id has to be unique.
- const panelId = `${queryOptions.format}-${query.key}`;
-
- return {
- interval,
- intervalMs,
- panelId,
- targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
- range: {
- from: dateMath.parse(range.from, false),
- to: dateMath.parse(range.to, true),
- raw: range,
- },
- rangeRaw: range,
- scopedVars: {
- __interval: { text: interval, value: interval },
- __interval_ms: { text: intervalMs, value: intervalMs },
- },
- };
- }
-
- startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
- const queryOptions = this.buildQueryOptions(query, options);
- const transaction: QueryTransaction = {
- query,
- resultType,
- rowIndex,
- id: generateKey(), // reusing for unique ID
- done: false,
- latency: 0,
- options: queryOptions,
- scanning: this.state.scanning,
- };
-
- // Using updater style because we might be modifying queryTransactions in quick succession
- this.setState(state => {
- const { queryTransactions } = state;
- // Discarding existing transactions of same type
- const remainingTransactions = queryTransactions.filter(
- qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
- );
-
- // Append new transaction
- const nextQueryTransactions = [...remainingTransactions, transaction];
-
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- return {
- ...results,
- queryTransactions: nextQueryTransactions,
- showingStartPage: false,
- graphInterval: queryOptions.intervalMs,
- };
- });
-
- return transaction;
- }
-
- completeQueryTransaction(
- transactionId: string,
- result: any,
- latency: number,
- queries: DataQuery[],
- datasourceId: string
- ) {
- const { datasource } = this.state;
- if (datasource.meta.id !== datasourceId) {
- // Navigated away, queries did not matter
- return;
- }
-
- this.setState(state => {
- const { history, queryTransactions } = state;
- let { scanning } = state;
-
- // Transaction might have been discarded
- const transaction = queryTransactions.find(qt => qt.id === transactionId);
- if (!transaction) {
- return null;
- }
-
- // Get query hints
- let hints: QueryHint[];
- if (datasource.getQueryHints as QueryHintGetter) {
- hints = datasource.getQueryHints(transaction.query, result);
- }
-
- // Mark transactions as complete
- const nextQueryTransactions = queryTransactions.map(qt => {
- if (qt.id === transactionId) {
- return {
- ...qt,
- hints,
- latency,
- result,
- done: true,
- };
- }
- return qt;
- });
-
- const results = calculateResultsFromQueryTransactions(
- nextQueryTransactions,
- state.datasource,
- state.graphInterval
- );
-
- const nextHistory = updateHistory(history, datasourceId, queries);
-
- // Keep scanning for results if this was the last scanning transaction
- if (scanning) {
- if (_.size(result) === 0) {
- const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
- if (!other) {
- this.scanTimer = setTimeout(this.scanPreviousRange, 1000);
- }
- } else {
- // We can stop scanning if we have a result
- scanning = false;
- }
- }
-
- return {
- ...results,
- scanning,
- history: nextHistory,
- queryTransactions: nextQueryTransactions,
- };
- });
- }
-
- failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
- const { datasource } = this.state;
- if (datasource.meta.id !== datasourceId || response.cancelled) {
- // Navigated away, queries did not matter
- return;
- }
-
- console.error(response);
-
- let error: string | JSX.Element;
- if (response.data) {
- if (typeof response.data === 'string') {
- error = response.data;
- } else if (response.data.error) {
- error = response.data.error;
- if (response.data.response) {
- error = (
- <>
- {response.data.error}
- {response.data.response}
- >
- );
- }
- } else {
- throw new Error('Could not handle error response');
- }
- } else if (response.message) {
- error = response.message;
- } else if (typeof response === 'string') {
- error = response;
- } else {
- error = 'Unknown error during query transaction. Please check JS console logs.';
- }
-
- this.setState(state => {
- // Transaction might have been discarded
- if (!state.queryTransactions.find(qt => qt.id === transactionId)) {
- return null;
- }
-
- // Mark transactions as complete
- const nextQueryTransactions = state.queryTransactions.map(qt => {
- if (qt.id === transactionId) {
- return {
- ...qt,
- error,
- done: true,
- };
- }
- return qt;
- });
-
- return {
- queryTransactions: nextQueryTransactions,
- };
- });
- }
-
- async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) {
- const queries = [...this.modifiedQueries];
- if (!hasNonEmptyQuery(queries)) {
- this.setState({
- queryTransactions: [],
- });
- return;
- }
- const { datasource } = this.state;
- const datasourceId = datasource.meta.id;
- // Run all queries concurrentlyso
- queries.forEach(async (query, rowIndex) => {
- const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions);
- try {
- const now = Date.now();
- const res = await datasource.query(transaction.options);
- this.exploreEvents.emit('data-received', res.data || []);
- const latency = Date.now() - now;
- const results = resultGetter ? resultGetter(res.data) : res.data;
- this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
- } catch (response) {
- this.exploreEvents.emit('data-error', response);
- this.failQueryTransaction(transaction.id, response, datasourceId);
- }
- });
- }
-
- updateLogsHighlights = _.debounce((value: DataQuery, index: number) => {
- this.setState(state => {
- const { datasource } = state;
- if (datasource.getHighlighterExpression) {
- const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)];
- return { logsHighlighterExpressions };
- }
- return null;
- });
- }, 500);
-
- cloneState(): ExploreState {
- // Copy state, but copy queries including modifications
- return {
- ...this.state,
- queryTransactions: [],
- initialQueries: [...this.modifiedQueries],
- };
- }
-
- saveState = () => {
- const { stateKey, onSaveState } = this.props;
- onSaveState(stateKey, this.cloneState());
+ this.props.scanStop(this.props.exploreId);
};
render() {
- const { position, split } = this.props;
const {
StartPage,
- datasource,
+ datasourceInstance,
datasourceError,
datasourceLoading,
datasourceMissing,
- exploreDatasources,
- graphResult,
- history,
+ exploreId,
initialQueries,
- logsHighlighterExpressions,
- logsResult,
- queryTransactions,
- range,
- scanning,
- scanRange,
- showingGraph,
- showingLogs,
showingStartPage,
- showingTable,
+ split,
supportsGraph,
supportsLogs,
supportsTable,
- tableResult,
- } = this.state;
- const graphHeight = showingGraph && showingTable ? '200px' : '400px';
+ } = this.props;
const exploreClass = split ? 'explore explore-split' : 'explore';
- const selectedDatasource = datasource ? exploreDatasources.find(d => d.name === datasource.name) : undefined;
- const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
- const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
- const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
- const loading = queryTransactions.some(qt => !qt.done);
return (
-
- {position === 'left' ? (
-
- ) : (
-
-
- Close Split
-
-
- )}
- {!datasourceMissing ? (
-
-
-
- ) : null}
-
- {position === 'left' && !split ? (
-
-
- Split
-
-
- ) : null}
-
-
-
- Clear All
-
-
-
-
- Run Query{' '}
- {loading ? : }
-
-
-
+
{datasourceLoading ?
Loading datasource...
: null}
{datasourceMissing ? (
Please add a datasource that supports Explore (e.g., Prometheus).
@@ -959,80 +205,84 @@ export class Explore extends React.PureComponent
{
)}
- {datasource && !datasourceError ? (
-
-
-
-
- {showingStartPage && }
- {!showingStartPage && (
- <>
- {supportsGraph && (
-
-
-
- )}
- {supportsTable && (
-
-
-
- )}
- {supportsLogs && (
-
-
-
- )}
- >
+ {datasourceInstance &&
+ !datasourceError && (
+
+
+
+ {({ width }) => (
+
+
+ {showingStartPage && }
+ {!showingStartPage && (
+ <>
+ {supportsGraph && }
+ {supportsTable && }
+ {supportsLogs && (
+
+ )}
+ >
+ )}
+
+
)}
-
-
-
- ) : null}
+
+
+ )}
);
}
}
-export default hot(module)(Explore);
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const { split } = explore;
+ const item: ExploreItemState = explore[exploreId];
+ const {
+ StartPage,
+ datasourceError,
+ datasourceInstance,
+ datasourceLoading,
+ datasourceMissing,
+ initialQueries,
+ initialized,
+ range,
+ showingStartPage,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ } = item;
+ return {
+ StartPage,
+ datasourceError,
+ datasourceInstance,
+ datasourceLoading,
+ datasourceMissing,
+ initialQueries,
+ initialized,
+ range,
+ showingStartPage,
+ split,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ };
+}
+
+const mapDispatchToProps = {
+ changeSize,
+ changeTime,
+ initializeExplore,
+ modifyQueries,
+ scanStart,
+ scanStop,
+ setQueries,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Explore));
diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx
new file mode 100644
index 00000000000..35f06d11c81
--- /dev/null
+++ b/public/app/features/explore/ExploreToolbar.tsx
@@ -0,0 +1,191 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { hot } from 'react-hot-loader';
+
+import { ExploreId } from 'app/types/explore';
+import { DataSourceSelectItem, RawTimeRange, TimeRange } from '@grafana/ui';
+import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
+import { StoreState } from 'app/types/store';
+import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions';
+import TimePicker from './TimePicker';
+
+enum IconSide {
+ left = 'left',
+ right = 'right',
+}
+
+const createResponsiveButton = (options: {
+ splitted: boolean;
+ title: string;
+ onClick: () => void;
+ buttonClassName?: string;
+ iconClassName?: string;
+ iconSide?: IconSide;
+}) => {
+ const defaultOptions = {
+ iconSide: IconSide.left,
+ };
+ const props = { ...options, defaultOptions };
+ const { title, onClick, buttonClassName, iconClassName, splitted, iconSide } = props;
+
+ return (
+
+ {iconClassName && iconSide === IconSide.left ? : null}
+ {!splitted ? title : ''}
+ {iconClassName && iconSide === IconSide.right ? : null}
+
+ );
+};
+
+interface OwnProps {
+ exploreId: ExploreId;
+ timepickerRef: React.RefObject;
+ onChangeTime: (range: TimeRange, changedByScanner?: boolean) => void;
+}
+
+interface StateProps {
+ datasourceMissing: boolean;
+ exploreDatasources: DataSourceSelectItem[];
+ loading: boolean;
+ range: RawTimeRange;
+ selectedDatasource: DataSourceSelectItem;
+ splitted: boolean;
+}
+
+interface DispatchProps {
+ changeDatasource: typeof changeDatasource;
+ clearAll: typeof clearQueries;
+ runQuery: typeof runQueries;
+ closeSplit: typeof splitClose;
+ split: typeof splitOpen;
+}
+
+type Props = StateProps & DispatchProps & OwnProps;
+
+export class UnConnectedExploreToolbar extends PureComponent {
+ constructor(props) {
+ super(props);
+ }
+
+ onChangeDatasource = async option => {
+ this.props.changeDatasource(this.props.exploreId, option.value);
+ };
+
+ onClearAll = () => {
+ this.props.clearAll(this.props.exploreId);
+ };
+
+ onRunQuery = () => {
+ this.props.runQuery(this.props.exploreId);
+ };
+
+ render() {
+ const {
+ datasourceMissing,
+ exploreDatasources,
+ exploreId,
+ loading,
+ range,
+ selectedDatasource,
+ splitted,
+ timepickerRef,
+ } = this.props;
+
+ return (
+
+
+
+
+
+ {exploreId === 'right' && (
+
+
+
+ )}
+
+
+
+
+
+ {!datasourceMissing ? (
+
+ ) : null}
+ {exploreId === 'left' && !splitted ? (
+
+ {createResponsiveButton({
+ splitted,
+ title: 'Split',
+ onClick: this.props.split,
+ iconClassName: 'fa fa-fw fa-columns icon-margin-right',
+ iconSide: IconSide.left,
+ })}
+
+ ) : null}
+
+
+
+
+
+ Clear All
+
+
+
+ {createResponsiveButton({
+ splitted,
+ title: 'Run Query',
+ onClick: this.onRunQuery,
+ buttonClassName: 'navbar-button--primary',
+ iconClassName: loading ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-level-down fa-fw run-icon',
+ iconSide: IconSide.right,
+ })}
+
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => {
+ const splitted = state.explore.split;
+ const exploreItem = state.explore[exploreId];
+ const { datasourceInstance, datasourceMissing, exploreDatasources, queryTransactions, range } = exploreItem;
+ const selectedDatasource = datasourceInstance
+ ? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
+ : undefined;
+ const loading = queryTransactions.some(qt => !qt.done);
+
+ return {
+ datasourceMissing,
+ exploreDatasources,
+ loading,
+ range,
+ selectedDatasource,
+ splitted,
+ };
+};
+
+const mapDispatchToProps: DispatchProps = {
+ changeDatasource,
+ clearAll: clearQueries,
+ runQuery: runQueries,
+ closeSplit: splitClose,
+ split: splitOpen,
+};
+
+export const ExploreToolbar = hot(module)(connect(mapStateToProps, mapDispatchToProps)(UnConnectedExploreToolbar));
diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx
new file mode 100644
index 00000000000..7263fd09288
--- /dev/null
+++ b/public/app/features/explore/GraphContainer.tsx
@@ -0,0 +1,71 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { TimeRange, RawTimeRange } from '@grafana/ui';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleGraph, changeTime } from './state/actions';
+import Graph from './Graph';
+import Panel from './Panel';
+
+interface GraphContainerProps {
+ exploreId: ExploreId;
+ graphResult?: any[];
+ loading: boolean;
+ range: RawTimeRange;
+ showingGraph: boolean;
+ showingTable: boolean;
+ split: boolean;
+ toggleGraph: typeof toggleGraph;
+ changeTime: typeof changeTime;
+}
+
+export class GraphContainer extends PureComponent {
+ onClickGraphButton = () => {
+ this.props.toggleGraph(this.props.exploreId);
+ };
+
+ onChangeTime = (timeRange: TimeRange) => {
+ this.props.changeTime(this.props.exploreId, timeRange);
+ };
+
+ render() {
+ const { exploreId, graphResult, loading, showingGraph, showingTable, range, split } = this.props;
+ const graphHeight = showingGraph && showingTable ? '200px' : '400px';
+
+ if (!graphResult) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const { split } = explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { graphResult, queryTransactions, range, showingGraph, showingTable } = item;
+ const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
+ return { graphResult, loading, range, showingGraph, showingTable, split };
+}
+
+const mapDispatchToProps = {
+ toggleGraph,
+ changeTime,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(GraphContainer));
diff --git a/public/app/features/explore/LogLabel.tsx b/public/app/features/explore/LogLabel.tsx
new file mode 100644
index 00000000000..b4570f10c82
--- /dev/null
+++ b/public/app/features/explore/LogLabel.tsx
@@ -0,0 +1,74 @@
+import React, { PureComponent } from 'react';
+
+import { calculateLogsLabelStats, LogLabelStatsModel, LogRowModel } from 'app/core/logs_model';
+import { LogLabelStats } from './LogLabelStats';
+
+interface Props {
+ getRows?: () => LogRowModel[];
+ label: string;
+ plain?: boolean;
+ value: string;
+ onClickLabel?: (label: string, value: string) => void;
+}
+
+interface State {
+ showStats: boolean;
+ stats: LogLabelStatsModel[];
+}
+
+export class LogLabel extends PureComponent {
+ state = {
+ stats: null,
+ showStats: false,
+ };
+
+ onClickClose = () => {
+ this.setState({ showStats: false });
+ };
+
+ onClickLabel = () => {
+ const { onClickLabel, label, value } = this.props;
+ if (onClickLabel) {
+ onClickLabel(label, value);
+ }
+ };
+
+ onClickStats = () => {
+ this.setState(state => {
+ if (state.showStats) {
+ return { showStats: false, stats: null };
+ }
+ const allRows = this.props.getRows();
+ const stats = calculateLogsLabelStats(allRows, this.props.label);
+ return { showStats: true, stats };
+ });
+ };
+
+ render() {
+ const { getRows, label, plain, value } = this.props;
+ const { showStats, stats } = this.state;
+ const tooltip = `${label}: ${value}`;
+ return (
+
+
+ {value}
+
+ {!plain && (
+
+ )}
+ {!plain && getRows && }
+ {showStats && (
+
+
+
+ )}
+
+ );
+ }
+}
diff --git a/public/app/features/explore/LogLabelStats.tsx b/public/app/features/explore/LogLabelStats.tsx
new file mode 100644
index 00000000000..b0bd69170c5
--- /dev/null
+++ b/public/app/features/explore/LogLabelStats.tsx
@@ -0,0 +1,72 @@
+import React, { PureComponent } from 'react';
+import classnames from 'classnames';
+import { LogLabelStatsModel } from 'app/core/logs_model';
+
+function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) {
+ const { active, count, proportion, value } = logLabelStatsModel;
+ const percent = `${Math.round(proportion * 100)}%`;
+ const barStyle = { width: percent };
+ const className = classnames('logs-stats-row', { 'logs-stats-row--active': active });
+
+ return (
+
+
+
{value}
+
{count}
+
{percent}
+
+
+
+ );
+}
+
+const STATS_ROW_LIMIT = 5;
+
+interface Props {
+ stats: LogLabelStatsModel[];
+ label: string;
+ value: string;
+ rowCount: number;
+ onClickClose: () => void;
+}
+
+export class LogLabelStats extends PureComponent {
+ render() {
+ const { label, rowCount, stats, value, onClickClose } = this.props;
+ const topRows = stats.slice(0, STATS_ROW_LIMIT);
+ let activeRow = topRows.find(row => row.value === value);
+ let otherRows = stats.slice(STATS_ROW_LIMIT);
+ const insertActiveRow = !activeRow;
+
+ // Remove active row from other to show extra
+ if (insertActiveRow) {
+ activeRow = otherRows.find(row => row.value === value);
+ otherRows = otherRows.filter(row => row.value !== value);
+ }
+
+ const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
+ const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
+ const total = topCount + otherCount;
+ const otherProportion = otherCount / total;
+
+ return (
+
+
+
+ {label}: {total} of {rowCount} rows have that label
+
+
+
+
+ {topRows.map(stat => )}
+ {insertActiveRow && activeRow && }
+ {otherCount > 0 && (
+
+ )}
+
+
+ );
+ }
+}
diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx
index 8aa1789017e..7105a2a5370 100644
--- a/public/app/features/explore/LogLabels.tsx
+++ b/public/app/features/explore/LogLabels.tsx
@@ -1,148 +1,20 @@
-import _ from 'lodash';
import React, { PureComponent } from 'react';
-import classnames from 'classnames';
-import { calculateLogsLabelStats, LogsLabelStat, LogsStreamLabels, LogRow } from 'app/core/logs_model';
+import { LogsStreamLabels, LogRowModel } from 'app/core/logs_model';
+import { LogLabel } from './LogLabel';
-function StatsRow({ active, count, proportion, value }: LogsLabelStat) {
- const percent = `${Math.round(proportion * 100)}%`;
- const barStyle = { width: percent };
- const className = classnames('logs-stats-row', { 'logs-stats-row--active': active });
-
- return (
-
-
-
{value}
-
{count}
-
{percent}
-
-
-
- );
-}
-
-const STATS_ROW_LIMIT = 5;
-export class Stats extends PureComponent<{
- stats: LogsLabelStat[];
- label: string;
- value: string;
- rowCount: number;
- onClickClose: () => void;
-}> {
- render() {
- const { label, rowCount, stats, value, onClickClose } = this.props;
- const topRows = stats.slice(0, STATS_ROW_LIMIT);
- let activeRow = topRows.find(row => row.value === value);
- let otherRows = stats.slice(STATS_ROW_LIMIT);
- const insertActiveRow = !activeRow;
- // Remove active row from other to show extra
- if (insertActiveRow) {
- activeRow = otherRows.find(row => row.value === value);
- otherRows = otherRows.filter(row => row.value !== value);
- }
- const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
- const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
- const total = topCount + otherCount;
- const otherProportion = otherCount / total;
-
- return (
-
-
-
- {label}: {total} of {rowCount} rows have that label
-
-
-
-
- {topRows.map(stat => )}
- {insertActiveRow && activeRow && }
- {otherCount > 0 && (
-
- )}
-
-
- );
- }
-}
-
-class Label extends PureComponent<
- {
- getRows?: () => LogRow[];
- label: string;
- plain?: boolean;
- value: string;
- onClickLabel?: (label: string, value: string) => void;
- },
- { showStats: boolean; stats: LogsLabelStat[] }
-> {
- state = {
- stats: null,
- showStats: false,
- };
-
- onClickClose = () => {
- this.setState({ showStats: false });
- };
-
- onClickLabel = () => {
- const { onClickLabel, label, value } = this.props;
- if (onClickLabel) {
- onClickLabel(label, value);
- }
- };
-
- onClickStats = () => {
- this.setState(state => {
- if (state.showStats) {
- return { showStats: false, stats: null };
- }
- const allRows = this.props.getRows();
- const stats = calculateLogsLabelStats(allRows, this.props.label);
- return { showStats: true, stats };
- });
- };
-
- render() {
- const { getRows, label, plain, value } = this.props;
- const { showStats, stats } = this.state;
- const tooltip = `${label}: ${value}`;
- return (
-
-
- {value}
-
- {!plain && (
-
- )}
- {!plain && getRows && }
- {showStats && (
-
-
-
- )}
-
- );
- }
-}
-
-export default class LogLabels extends PureComponent<{
- getRows?: () => LogRow[];
+interface Props {
+ getRows?: () => LogRowModel[];
labels: LogsStreamLabels;
plain?: boolean;
onClickLabel?: (label: string, value: string) => void;
-}> {
+}
+
+export class LogLabels extends PureComponent {
render() {
const { getRows, labels, onClickLabel, plain } = this.props;
return Object.keys(labels).map(key => (
-
+
));
}
}
diff --git a/public/app/features/explore/LogRow.tsx b/public/app/features/explore/LogRow.tsx
new file mode 100644
index 00000000000..66b0e6a69fe
--- /dev/null
+++ b/public/app/features/explore/LogRow.tsx
@@ -0,0 +1,194 @@
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+import Highlighter from 'react-highlight-words';
+import classnames from 'classnames';
+
+import { LogRowModel, LogLabelStatsModel, LogsParser, calculateFieldStats, getParser } from 'app/core/logs_model';
+import { LogLabels } from './LogLabels';
+import { findHighlightChunksInText } from 'app/core/utils/text';
+import { LogLabelStats } from './LogLabelStats';
+
+interface Props {
+ highlighterExpressions?: string[];
+ row: LogRowModel;
+ showDuplicates: boolean;
+ showLabels: boolean | null; // Tristate: null means auto
+ showLocalTime: boolean;
+ showUtc: boolean;
+ getRows: () => LogRowModel[];
+ onClickLabel?: (label: string, value: string) => void;
+}
+
+interface State {
+ fieldCount: number;
+ fieldLabel: string;
+ fieldStats: LogLabelStatsModel[];
+ fieldValue: string;
+ parsed: boolean;
+ parser?: LogsParser;
+ parsedFieldHighlights: string[];
+ showFieldStats: boolean;
+}
+
+/**
+ * Renders a highlighted field.
+ * When hovering, a stats icon is shown.
+ */
+const FieldHighlight = onClick => props => {
+ return (
+
+ {props.children}
+ onClick(props.children)} />
+
+ );
+};
+
+/**
+ * Renders a log line.
+ *
+ * When user hovers over it for a certain time, it lazily parses the log line.
+ * Once a parser is found, it will determine fields, that will be highlighted.
+ * When the user requests stats for a field, they will be calculated and rendered below the row.
+ */
+export class LogRow extends PureComponent {
+ mouseMessageTimer: NodeJS.Timer;
+
+ state = {
+ fieldCount: 0,
+ fieldLabel: null,
+ fieldStats: null,
+ fieldValue: null,
+ parsed: false,
+ parser: undefined,
+ parsedFieldHighlights: [],
+ showFieldStats: false,
+ };
+
+ componentWillUnmount() {
+ clearTimeout(this.mouseMessageTimer);
+ }
+
+ onClickClose = () => {
+ this.setState({ showFieldStats: false });
+ };
+
+ onClickHighlight = (fieldText: string) => {
+ const { getRows } = this.props;
+ const { parser } = this.state;
+ const allRows = getRows();
+
+ // Build value-agnostic row matcher based on the field label
+ const fieldLabel = parser.getLabelFromField(fieldText);
+ const fieldValue = parser.getValueFromField(fieldText);
+ const matcher = parser.buildMatcher(fieldLabel);
+ const fieldStats = calculateFieldStats(allRows, matcher);
+ const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
+
+ this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true });
+ };
+
+ onMouseOverMessage = () => {
+ // Don't parse right away, user might move along
+ this.mouseMessageTimer = setTimeout(this.parseMessage, 500);
+ };
+
+ onMouseOutMessage = () => {
+ clearTimeout(this.mouseMessageTimer);
+ this.setState({ parsed: false });
+ };
+
+ parseMessage = () => {
+ if (!this.state.parsed) {
+ const { row } = this.props;
+ const parser = getParser(row.entry);
+ if (parser) {
+ // Use parser to highlight detected fields
+ const parsedFieldHighlights = parser.getFields(this.props.row.entry);
+ this.setState({ parsedFieldHighlights, parsed: true, parser });
+ }
+ }
+ };
+
+ render() {
+ const {
+ getRows,
+ highlighterExpressions,
+ onClickLabel,
+ row,
+ showDuplicates,
+ showLabels,
+ showLocalTime,
+ showUtc,
+ } = this.props;
+ const {
+ fieldCount,
+ fieldLabel,
+ fieldStats,
+ fieldValue,
+ parsed,
+ parsedFieldHighlights,
+ showFieldStats,
+ } = this.state;
+ const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
+ const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
+ const needsHighlighter = highlights && highlights.length > 0;
+ const highlightClassName = classnames('logs-row__match-highlight', {
+ 'logs-row__match-highlight--preview': previewHighlights,
+ });
+ return (
+
+ {showDuplicates && (
+
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
+ )}
+
+ {showUtc && (
+
+ {row.timestamp}
+
+ )}
+ {showLocalTime && (
+
+ {row.timeLocal}
+
+ )}
+ {showLabels && (
+
+
+
+ )}
+
+ {parsed && (
+
+ )}
+ {!parsed &&
+ needsHighlighter && (
+
+ )}
+ {!parsed && !needsHighlighter && row.entry}
+ {showFieldStats && (
+
+
+
+ )}
+
+
+ );
+ }
+}
diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx
index 1a384cf011d..6f8077064e0 100644
--- a/public/app/features/explore/Logs.tsx
+++ b/public/app/features/explore/Logs.tsx
@@ -1,30 +1,23 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
-import Highlighter from 'react-highlight-words';
-import classnames from 'classnames';
import * as rangeUtil from 'app/core/utils/rangeutil';
-import { RawTimeRange } from '@grafana/ui';
+import { RawTimeRange, Switch } from '@grafana/ui';
import {
LogsDedupDescription,
LogsDedupStrategy,
LogsModel,
dedupLogRows,
filterLogLevels,
- getParser,
LogLevel,
LogsMetaKind,
- LogsLabelStat,
- LogsParser,
- LogRow,
- calculateFieldStats,
} from 'app/core/logs_model';
-import { findHighlightChunksInText } from 'app/core/utils/text';
-import { Switch } from 'app/core/components/Switch/Switch';
+
import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
import Graph from './Graph';
-import LogLabels, { Stats } from './LogLabels';
+import { LogLabels } from './LogLabels';
+import { LogRow } from './LogRow';
const PREVIEW_LIMIT = 100;
@@ -43,191 +36,6 @@ const graphOptions = {
},
};
-/**
- * Renders a highlighted field.
- * When hovering, a stats icon is shown.
- */
-const FieldHighlight = onClick => props => {
- return (
-
- {props.children}
- onClick(props.children)} />
-
- );
-};
-
-interface RowProps {
- highlighterExpressions?: string[];
- row: LogRow;
- showDuplicates: boolean;
- showLabels: boolean | null; // Tristate: null means auto
- showLocalTime: boolean;
- showUtc: boolean;
- getRows: () => LogRow[];
- onClickLabel?: (label: string, value: string) => void;
-}
-
-interface RowState {
- fieldCount: number;
- fieldLabel: string;
- fieldStats: LogsLabelStat[];
- fieldValue: string;
- parsed: boolean;
- parser?: LogsParser;
- parsedFieldHighlights: string[];
- showFieldStats: boolean;
-}
-
-/**
- * Renders a log line.
- *
- * When user hovers over it for a certain time, it lazily parses the log line.
- * Once a parser is found, it will determine fields, that will be highlighted.
- * When the user requests stats for a field, they will be calculated and rendered below the row.
- */
-class Row extends PureComponent {
- mouseMessageTimer: NodeJS.Timer;
-
- state = {
- fieldCount: 0,
- fieldLabel: null,
- fieldStats: null,
- fieldValue: null,
- parsed: false,
- parser: undefined,
- parsedFieldHighlights: [],
- showFieldStats: false,
- };
-
- componentWillUnmount() {
- clearTimeout(this.mouseMessageTimer);
- }
-
- onClickClose = () => {
- this.setState({ showFieldStats: false });
- };
-
- onClickHighlight = (fieldText: string) => {
- const { getRows } = this.props;
- const { parser } = this.state;
- const allRows = getRows();
-
- // Build value-agnostic row matcher based on the field label
- const fieldLabel = parser.getLabelFromField(fieldText);
- const fieldValue = parser.getValueFromField(fieldText);
- const matcher = parser.buildMatcher(fieldLabel);
- const fieldStats = calculateFieldStats(allRows, matcher);
- const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
-
- this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true });
- };
-
- onMouseOverMessage = () => {
- // Don't parse right away, user might move along
- this.mouseMessageTimer = setTimeout(this.parseMessage, 500);
- };
-
- onMouseOutMessage = () => {
- clearTimeout(this.mouseMessageTimer);
- this.setState({ parsed: false });
- };
-
- parseMessage = () => {
- if (!this.state.parsed) {
- const { row } = this.props;
- const parser = getParser(row.entry);
- if (parser) {
- // Use parser to highlight detected fields
- const parsedFieldHighlights = parser.getFields(this.props.row.entry);
- this.setState({ parsedFieldHighlights, parsed: true, parser });
- }
- }
- };
-
- render() {
- const {
- getRows,
- highlighterExpressions,
- onClickLabel,
- row,
- showDuplicates,
- showLabels,
- showLocalTime,
- showUtc,
- } = this.props;
- const {
- fieldCount,
- fieldLabel,
- fieldStats,
- fieldValue,
- parsed,
- parsedFieldHighlights,
- showFieldStats,
- } = this.state;
- const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
- const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
- const needsHighlighter = highlights && highlights.length > 0;
- const highlightClassName = classnames('logs-row__match-highlight', {
- 'logs-row__match-highlight--preview': previewHighlights,
- });
- return (
-
- {showDuplicates && (
-
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
- )}
-
- {showUtc && (
-
- {row.timestamp}
-
- )}
- {showLocalTime && (
-
- {row.timeLocal}
-
- )}
- {showLabels && (
-
-
-
- )}
-
- {parsed && (
-
- )}
- {!parsed &&
- needsHighlighter && (
-
- )}
- {!parsed && !needsHighlighter && row.entry}
- {showFieldStats && (
-
-
-
- )}
-
-
- );
- }
-}
-
function renderMetaItem(value: any, kind: LogsMetaKind) {
if (kind === LogsMetaKind.LabelsMap) {
return (
@@ -239,11 +47,11 @@ function renderMetaItem(value: any, kind: LogsMetaKind) {
return value;
}
-interface LogsProps {
- data: LogsModel;
+interface Props {
+ data?: LogsModel;
+ exploreId: string;
highlighterExpressions: string[];
loading: boolean;
- position: string;
range?: RawTimeRange;
scanning?: boolean;
scanRange?: RawTimeRange;
@@ -253,7 +61,7 @@ interface LogsProps {
onStopScanning?: () => void;
}
-interface LogsState {
+interface State {
dedup: LogsDedupStrategy;
deferLogs: boolean;
hiddenLogLevels: Set;
@@ -263,7 +71,7 @@ interface LogsState {
showUtc: boolean;
}
-export default class Logs extends PureComponent {
+export default class Logs extends PureComponent {
deferLogsTimer: NodeJS.Timer;
renderAllTimer: NodeJS.Timer;
@@ -348,14 +156,19 @@ export default class Logs extends PureComponent {
render() {
const {
data,
+ exploreId,
highlighterExpressions,
loading = false,
onClickLabel,
- position,
range,
scanning,
scanRange,
} = this.props;
+
+ if (!data) {
+ return null;
+ }
+
const { dedup, deferLogs, hiddenLogLevels, renderAll, showLocalTime, showUtc } = this.state;
let { showLabels } = this.state;
const hasData = data && data.rows && data.rows.length > 0;
@@ -400,7 +213,7 @@ export default class Logs extends PureComponent {
data={data.series}
height="100px"
range={range}
- id={`explore-logs-graph-${position}`}
+ id={`explore-logs-graph-${exploreId}`}
onChangeTime={this.props.onChangeTime}
onToggleSeries={this.onToggleLogLevel}
userOptions={graphOptions}
@@ -441,10 +254,9 @@ export default class Logs extends PureComponent {
{hasData &&
- !deferLogs &&
- // Only inject highlighterExpression in the first set for performance reasons
+ !deferLogs && // Only inject highlighterExpression in the first set for performance reasons
firstRows.map(row => (
-
{
!deferLogs &&
renderAll &&
lastRows.map(row => (
- void;
+ onClickLabel: (key: string, value: string) => void;
+ onStartScanning: () => void;
+ onStopScanning: () => void;
+ range: RawTimeRange;
+ scanning?: boolean;
+ scanRange?: RawTimeRange;
+ showingLogs: boolean;
+ toggleLogs: typeof toggleLogs;
+}
+
+export class LogsContainer extends PureComponent {
+ onClickLogsButton = () => {
+ this.props.toggleLogs(this.props.exploreId);
+ };
+
+ render() {
+ const {
+ exploreId,
+ loading,
+ logsHighlighterExpressions,
+ logsResult,
+ onChangeTime,
+ onClickLabel,
+ onStartScanning,
+ onStopScanning,
+ range,
+ showingLogs,
+ scanning,
+ scanRange,
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { logsHighlighterExpressions, logsResult, queryTransactions, scanning, scanRange, showingLogs, range } = item;
+ const loading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
+ return {
+ loading,
+ logsHighlighterExpressions,
+ logsResult,
+ scanning,
+ scanRange,
+ showingLogs,
+ range,
+ };
+}
+
+const mapDispatchToProps = {
+ toggleLogs,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer));
diff --git a/public/app/features/explore/QueryEditor.tsx b/public/app/features/explore/QueryEditor.tsx
index ce0a8a6e03e..083cd8a2e17 100644
--- a/public/app/features/explore/QueryEditor.tsx
+++ b/public/app/features/explore/QueryEditor.tsx
@@ -1,10 +1,13 @@
+// Libraries
import React, { PureComponent } from 'react';
+
+// Services
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
+
+// Types
import { Emitter } from 'app/core/utils/emitter';
-import { getIntervals } from 'app/core/utils/explore';
-import { DataQuery } from 'app/types';
-import { RawTimeRange } from '@grafana/ui';
-import { getTimeSrv } from 'app/features/dashboard/time_srv';
+import { RawTimeRange, DataQuery } from '@grafana/ui';
import 'app/features/plugins/plugin_loader';
interface QueryEditorProps {
@@ -33,8 +36,9 @@ export default class QueryEditor extends PureComponent {
const template = ' ';
const target = { datasource: datasource.name, ...initialQuery };
const scopeProps = {
- target,
ctrl: {
+ datasource,
+ target,
refresh: () => {
this.props.onQueryChange(target, false);
this.props.onExecuteQuery();
@@ -44,11 +48,7 @@ export default class QueryEditor extends PureComponent {
datasource,
targets: [target],
},
- dashboard: {
- getNextQueryLetter: x => '',
- },
- hideEditorRowActions: true,
- ...getIntervals(range, datasource, null), // Possible to get resolution?
+ dashboard: {},
},
};
@@ -73,6 +73,6 @@ export default class QueryEditor extends PureComponent {
}
render() {
- return