diff --git a/.circleci/config.yml b/.circleci/config.yml
index dba6c5f8bd0..209cf5c98cc 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -19,7 +19,7 @@ version: 2
jobs:
mysql-integration-test:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
- image: circleci/mysql:5.6-ram
environment:
MYSQL_ROOT_PASSWORD: rootpass
@@ -39,7 +39,7 @@ jobs:
postgres-integration-test:
docker:
- - image: circleci/golang:1.11.4
+ - image: circleci/golang:1.11.5
- image: circleci/postgres:9.3-ram
environment:
POSTGRES_USER: grafanatest
@@ -74,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,7 +331,7 @@ jobs:
deploy-enterprise-release:
docker:
- - image: grafana/grafana-ci-deploy:1.0.0
+ - image: grafana/grafana-ci-deploy:1.2.0
steps:
- attach_workspace:
at: .
@@ -365,10 +350,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 +389,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 +414,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..8c2f94609ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,25 +1,61 @@
-# 5.5.0 (unreleased)
+# 6.0.0-beta1 (unreleased)
### 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)
### Minor
+* **Alerting**: Use seperate timeouts for alert evals and notifications [#14701](https://github.com/grafana/grafana/issues/14701), thx [@sharkpc0813](https://github.com/sharkpc0813)
* **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
* **Elasticsearch**: Add support for moving average and derivative using doc count (metric count) [#8843](https://github.com/grafana/grafana/issues/8843) [#11175](https://github.com/grafana/grafana/issues/11175)
+* **Elasticsearch**: Add support for template variable interpolation in alias field [#4075](https://github.com/grafana/grafana/issues/4075), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Influxdb**: Fix autocomplete of measurements does not escape search string properly [#11503](https://github.com/grafana/grafana/issues/11503), thx [@SamuelToh](https://github.com/SamuelToh)
+* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
+* **Cloudwatch**: Fix Assume Role Arn [#14722](https://github.com/grafana/grafana/issues/14722), thx [@jaken551](https://github.com/jaken551)
+* **Provisioning**: Fixes bug causing infinite growth in dashboard_version table. [#12864](https://github.com/grafana/grafana/issues/12864)
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
-* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
-* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
-* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
+* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
+* **Dashboard**: `Min width` changed to `Max per row` for repeating panels. This lets you specify the maximum number of panels to show per row and by that repeated panels will always take up full width of row [#12991](https://github.com/grafana/grafana/pull/12991), thx [@pgiraud](https://github.com/pgiraud)
+* **Dashboard**: Retain decimal precision when exporting CSV [#13929](https://github.com/grafana/grafana/issues/13929), thx [@cinaglia](https://github.com/cinaglia)
+* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
+* **Units**: Add Floating Point Operations per Second units [#14558](https://github.com/grafana/grafana/pull/14558), thx [@hahnjo](https://github.com/hahnjo)
+* **Table**: Renders epoch string as date if date column style [#14484](https://github.com/grafana/grafana/issues/14484)
+* **Piechart/Flot**: Fixes multiple piechart instances with donut bug [#15062](https://github.com/grafana/grafana/pull/15062)
+* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
+* **Dataproxy**: Add global datasource proxy timeout setting [#5699](https://github.com/grafana/grafana/issues/5699), thx [@RangerRick](https://github.com/RangerRick)
+* **Database**: Support specifying database host using IPV6 for backend database and sql datasources [#13711](https://github.com/grafana/grafana/issues/13711), thx [@ellisvlad](https://github.com/ellisvlad)
### Bug fixes
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
+* **Prometheus**: Query for annotation always uses 60s step regardless of dashboard range, fixes [#14795](https://github.com/grafana/grafana/issues/14795)
+* **Annotations**: Fix creating annotation when graph panel has no data points position the popup outside viewport [#13765](https://github.com/grafana/grafana/issues/13765), thx [@banjeremy](https://github.com/banjeremy)
+
+### Breaking changes
+* **Text Panel**: The text panel does no longer by default allow unsantizied HTML. [#4117](https://github.com/grafana/grafana/issues/4117). This means that if you have text panels with scripts tags they will no longer work as before. To enable unsafe javascript execution in text panels enable the settings `disable_sanitize_html` under the section `[panels]` in your Grafana ini file, or set env variable `GF_PANELS_DISABLE_SANITIZE_HTML=true`.
+* **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/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/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/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/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/explore/index.md b/docs/sources/features/explore/index.md
index 438943b3243..0ba3d2f7d44 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.
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..3ef32b1b10f 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).
@@ -292,9 +293,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..c794375793b 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-pre1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"
@@ -24,7 +24,6 @@
"@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-select": "^2.0.4",
"angular-mocks": "1.6.6",
@@ -65,6 +64,7 @@
"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",
@@ -72,8 +72,8 @@
"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",
@@ -167,7 +167,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 +188,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/package.json b/packages/grafana-ui/package.json
index 2fb210e3b46..91695dc5647 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -11,23 +11,34 @@
"license": "ISC",
"dependencies": {
"@torkelo/react-select": "2.1.1",
+ "@types/react-test-renderer": "^16.0.3",
+ "@types/react-transition-group": "^2.0.15",
"classnames": "^2.2.5",
"jquery": "^3.2.1",
"lodash": "^4.17.10",
"moment": "^2.22.2",
"react": "^16.6.3",
+ "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/classnames": "^2.2.6",
"@types/jest": "^23.3.2",
+ "@types/jquery": "^1.10.35",
"@types/lodash": "^4.14.119",
"@types/react": "^16.7.6",
- "@types/classnames": "^2.2.6",
- "@types/jquery": "^1.10.35",
+ "@types/react-custom-scrollbars": "^4.0.5",
+ "@types/react-test-renderer": "^16.0.3",
+ "@types/tether-drop": "^1.4.8",
+ "@types/tinycolor2": "^1.4.1",
+ "react-test-renderer": "^16.7.0",
"typescript": "^3.2.2"
}
}
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/public/app/core/components/colorpicker/ColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
similarity index 83%
rename from public/app/core/components/colorpicker/ColorPicker.tsx
rename to packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
index 9541001b0a8..485aa5f03d3 100644
--- a/public/app/core/components/colorpicker/ColorPicker.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
@@ -2,7 +2,6 @@ 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;
@@ -10,7 +9,7 @@ export interface Props {
}
export class ColorPicker extends React.Component {
- pickerElem: HTMLElement;
+ pickerElem: HTMLElement | null;
colorPickerDrop: any;
openColorPicker = () => {
@@ -20,7 +19,7 @@ export class ColorPicker extends React.Component {
ReactDOM.render(dropContent, dropContentElem);
const drop = new Drop({
- target: this.pickerElem,
+ target: this.pickerElem as Element,
content: dropContentElem,
position: 'top center',
classes: 'drop-popover',
@@ -28,6 +27,7 @@ export class ColorPicker extends React.Component {
hoverCloseDelay: 200,
tetherOptions: {
constraints: [{ to: 'scrollParent', attachment: 'none both' }],
+ attachment: 'bottom center',
},
});
@@ -45,7 +45,7 @@ export class ColorPicker extends React.Component {
}, 100);
};
- onColorSelect = color => {
+ onColorSelect = (color: string) => {
this.props.onChange(color);
};
@@ -59,8 +59,3 @@ export class ColorPicker extends React.Component {
);
}
}
-
-react2AngularDirective('colorPicker', ColorPicker, [
- 'color',
- ['onChange', { watchDepth: 'reference', wrapApply: true }],
-]);
diff --git a/public/app/core/components/colorpicker/ColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
similarity index 83%
rename from public/app/core/components/colorpicker/ColorPickerPopover.tsx
rename to packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
index c42bcfa1d06..e8305c99319 100644
--- a/public/app/core/components/colorpicker/ColorPickerPopover.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
@@ -14,7 +14,7 @@ export interface Props {
export class ColorPickerPopover extends React.Component {
pickerNavElem: any;
- constructor(props) {
+ constructor(props: Props) {
super(props);
this.state = {
tab: 'palette',
@@ -23,60 +23,51 @@ export class ColorPickerPopover extends React.Component {
};
}
- setPickerNavElem(elem) {
+ setPickerNavElem(elem: any) {
this.pickerNavElem = $(elem);
}
- setColor(color) {
+ setColor(color: string) {
const newColor = tinycolor(color);
if (newColor.isValid()) {
- this.setState({
- color: newColor.toString(),
- colorString: newColor.toString(),
- });
+ this.setState({ color: newColor.toString(), colorString: newColor.toString() });
this.props.onColorSelect(color);
}
}
- sampleColorSelected(color) {
+ sampleColorSelected(color: string) {
this.setColor(color);
}
- spectrumColorSelected(color) {
+ spectrumColorSelected(color: any) {
const rgbColor = color.toRgbString();
this.setColor(rgbColor);
}
- onColorStringChange(e) {
+ onColorStringChange(e: any) {
const colorString = e.target.value;
- this.setState({
- colorString: colorString,
- });
+ this.setState({ colorString: colorString });
const newColor = tinycolor(colorString);
if (newColor.isValid()) {
// Update only color state
const newColorString = newColor.toString();
- this.setState({
- color: newColorString,
- });
+ this.setState({ color: newColorString });
this.props.onColorSelect(newColorString);
}
}
- onColorStringBlur(e) {
+ onColorStringBlur(e: any) {
const colorString = e.target.value;
this.setColor(colorString);
}
componentDidMount() {
this.pickerNavElem.find('li:first').addClass('active');
- this.pickerNavElem.on('show', e => {
+ this.pickerNavElem.on('show', (e: any) => {
// use href attr (#name => name)
const tab = e.target.hash.slice(1);
- this.setState({
- tab: tab,
- });
+ this.setState({ tab: tab });
});
}
diff --git a/public/app/core/components/colorpicker/SeriesColorPicker.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx
similarity index 96%
rename from public/app/core/components/colorpicker/SeriesColorPicker.tsx
rename to packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx
index 32b7554e38d..7c3848f6868 100644
--- a/public/app/core/components/colorpicker/SeriesColorPicker.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx
@@ -21,7 +21,7 @@ export class SeriesColorPicker extends React.Component {
onToggleAxis: () => {},
};
- constructor(props) {
+ constructor(props: SeriesColorPickerProps) {
super(props);
}
@@ -51,6 +51,7 @@ export class SeriesColorPicker extends React.Component {
remove: true,
tetherOptions: {
constraints: [{ to: 'scrollParent', attachment: 'none both' }],
+ attachment: 'bottom center',
},
});
diff --git a/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
similarity index 85%
rename from public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx
rename to packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
index 085d554300d..541a77ddabc 100644
--- a/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
@@ -1,6 +1,5 @@
import React from 'react';
import { ColorPickerPopover } from './ColorPickerPopover';
-import { react2AngularDirective } from 'app/core/utils/react2angular';
export interface SeriesColorPickerPopoverProps {
color: string;
@@ -22,7 +21,7 @@ export class SeriesColorPickerPopover extends React.PureComponent void;
+ onToggleAxis?: () => void;
}
interface AxisSelectorState {
@@ -30,7 +29,7 @@ interface AxisSelectorState {
}
export class AxisSelector extends React.PureComponent {
- constructor(props) {
+ constructor(props: AxisSelectorProps) {
super(props);
this.state = {
yaxis: this.props.yaxis,
@@ -42,7 +41,10 @@ export class AxisSelector extends React.PureComponent {
elem: any;
isMoving: boolean;
- constructor(props) {
+ constructor(props: Props) {
super(props);
this.onSpectrumMove = this.onSpectrumMove.bind(this);
this.setComponentElem = this.setComponentElem.bind(this);
}
- setComponentElem(elem) {
+ setComponentElem(elem: any) {
this.elem = $(elem);
}
- onSpectrumMove(color) {
+ onSpectrumMove(color: any) {
this.isMoving = true;
this.props.onColorSelect(color);
}
@@ -46,7 +46,7 @@ export class SpectrumPicker extends React.Component {
this.elem.spectrum('set', this.props.color);
}
- componentWillUpdate(nextProps) {
+ componentWillUpdate(nextProps: any) {
// If user move pointer over spectrum field this produce 'move' event and component
// may update props.color. We don't want to update spectrum color in this case, so we can use
// isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which
diff --git a/public/sass/components/_color_picker.scss b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
similarity index 100%
rename from public/sass/components/_color_picker.scss
rename to packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss
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..12b5ff8062e
--- /dev/null
+++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
@@ -0,0 +1,96 @@
+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;
+ 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,
+ } = this.props;
+
+ return (
+ }
+ 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/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..2bd4fbc153b
--- /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 '..';
+
+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 (
+
+ );
+};
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..396b7a03162
--- /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/series';
+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/public/app/viz/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
similarity index 53%
rename from public/app/viz/Gauge.tsx
rename to packages/grafana-ui/src/components/Gauge/Gauge.tsx
index 031d856f492..2dce20543fd 100644
--- a/public/app/viz/Gauge.tsx
+++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx
@@ -1,15 +1,15 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
-import { BasicGaugeColor, MappingType, RangeMap, Threshold, ValueMap } from 'app/types';
-import { TimeSeriesVMs } from '@grafana/ui';
-import config from '../core/config';
-import kbn from '../core/utils/kbn';
-interface Props {
- baseColor: string;
+import { ValueMapping, Threshold, ThemeName, BasicGaugeColor, ThemeNames } from '../../types/panel';
+import { TimeSeriesVMs } from '../../types/series';
+import { getValueFormat } from '../../utils/valueFormats/valueFormats';
+import { TimeSeriesValue, getMappedValue } from '../../utils/valueMappings';
+
+export interface Props {
decimals: number;
height: number;
- mappings: Array;
+ valueMappings: ValueMapping[];
maxValue: number;
minValue: number;
prefix: string;
@@ -21,15 +21,15 @@ interface Props {
suffix: string;
unit: string;
width: number;
+ theme?: ThemeName;
}
export class Gauge extends PureComponent {
canvasElement: any;
static defaultProps = {
- baseColor: BasicGaugeColor.Green,
maxValue: 100,
- mappings: [],
+ valueMappings: [],
minValue: 0,
prefix: '',
showThresholdMarkers: true,
@@ -38,6 +38,7 @@ export class Gauge extends PureComponent {
thresholds: [],
unit: 'none',
stat: 'avg',
+ theme: ThemeNames.Dark,
};
componentDidMount() {
@@ -48,89 +49,93 @@ export class Gauge extends PureComponent {
this.draw();
}
- formatWithMappings(mappings, value) {
- const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
- const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
+ formatValue(value: TimeSeriesValue) {
+ const { decimals, valueMappings, prefix, suffix, unit } = this.props;
- const valueMap = valueMaps.map(mapping => {
- if (mapping.value && value === mapping.value) {
- return mapping.text;
+ if (isNaN(value as number)) {
+ return value;
+ }
+
+ if (valueMappings.length > 0) {
+ const valueMappedValue = getMappedValue(valueMappings, value);
+ if (valueMappedValue) {
+ return `${prefix} ${valueMappedValue.text} ${suffix}`;
}
- })[0];
+ }
- const rangeMap = rangeMaps.map(mapping => {
- if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
- return mapping.text;
- }
- })[0];
+ const formatFunc = getValueFormat(unit);
+ const formattedValue = formatFunc(value as number, decimals);
+ const handleNoValueValue = formattedValue || 'no value';
- return {
- rangeMap,
- valueMap,
- };
+ return `${prefix} ${handleNoValueValue} ${suffix}`;
}
- formatValue(value) {
- const { decimals, mappings, prefix, suffix, unit } = this.props;
+ getFontColor(value: TimeSeriesValue) {
+ const { thresholds } = this.props;
- const formatFunc = kbn.valueFormats[unit];
- const formattedValue = formatFunc(value, decimals);
-
- if (mappings.length > 0) {
- const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
-
- if (valueMap) {
- return valueMap;
- } else if (rangeMap) {
- return rangeMap;
- }
+ if (thresholds.length === 1) {
+ return thresholds[0].color;
}
- if (isNaN(value)) {
- return '-';
+ const atThreshold = thresholds.filter(threshold => (value as number) === threshold.value)[0];
+ if (atThreshold) {
+ return atThreshold.color;
}
- return `${prefix} ${formattedValue} ${suffix}`;
+ const belowThreshold = thresholds.filter(threshold => (value as number) > threshold.value);
+
+ if (belowThreshold.length > 0) {
+ const nearestThreshold = belowThreshold.sort((t1, t2) => t2.value - t1.value)[0];
+ return nearestThreshold.color;
+ }
+
+ return BasicGaugeColor.Red;
}
- getFontColor(value) {
- const { baseColor, maxValue, thresholds } = this.props;
+ getFormattedThresholds() {
+ const { maxValue, minValue, thresholds } = this.props;
- const atThreshold = thresholds.filter(threshold => value <= threshold.value);
+ const thresholdsSortedByIndex = [...thresholds].sort((t1, t2) => t1.index - t2.index);
+ const lastThreshold = thresholdsSortedByIndex[thresholdsSortedByIndex.length - 1];
- if (atThreshold.length > 0) {
- return atThreshold[0].color;
- } else if (value <= maxValue) {
- return BasicGaugeColor.Red;
- }
+ const formattedThresholds = [
+ ...thresholdsSortedByIndex.map(threshold => {
+ if (threshold.index === 0) {
+ return { value: minValue, color: threshold.color };
+ }
- return baseColor;
+ const previousThreshold = thresholdsSortedByIndex[threshold.index - 1];
+ return { value: threshold.value, color: previousThreshold.color };
+ }),
+ { value: maxValue, color: lastThreshold.color },
+ ];
+
+ return formattedThresholds;
}
draw() {
const {
- baseColor,
maxValue,
minValue,
timeSeries,
showThresholdLabels,
showThresholdMarkers,
- thresholds,
width,
height,
stat,
+ theme,
} = this.props;
- let value: string | number = '';
+ let value: TimeSeriesValue = '';
if (timeSeries[0]) {
value = timeSeries[0].stats[stat];
} else {
- value = 'N/A';
+ value = null;
}
const dimension = Math.min(width, height * 1.3);
- const backgroundColor = config.bootData.user.lightTheme ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
+ const backgroundColor = theme === ThemeNames.Light ? 'rgb(230,230,230)' : 'rgb(38,38,38)';
const fontScale = parseInt('80', 10) / 100;
const fontSize = Math.min(dimension / 5, 100) * fontScale;
const gaugeWidthReduceRatio = showThresholdLabels ? 1.5 : 1;
@@ -138,20 +143,6 @@ export class Gauge extends PureComponent {
const thresholdMarkersWidth = gaugeWidth / 5;
const thresholdLabelFontSize = fontSize / 2.5;
- const formattedThresholds = [
- { value: minValue, color: BasicGaugeColor.Green },
- ...thresholds.map((threshold, index) => {
- return {
- value: threshold.value,
- color: index === 0 ? threshold.color : thresholds[index].color,
- };
- }),
- {
- value: maxValue,
- color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor,
- },
- ];
-
const options = {
series: {
gauges: {
@@ -168,7 +159,7 @@ export class Gauge extends PureComponent {
layout: { margin: 0, thresholdWidth: 0 },
cell: { border: { width: 0 } },
threshold: {
- values: formattedThresholds,
+ values: this.getFormattedThresholds(),
label: {
show: showThresholdLabels,
margin: thresholdMarkersWidth + 1,
@@ -182,19 +173,14 @@ export class Gauge extends PureComponent {
formatter: () => {
return this.formatValue(value);
},
- font: {
- size: fontSize,
- family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
- },
+ font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
},
show: true,
},
},
};
- const plotSeries = {
- data: [[0, value]],
- };
+ const plotSeries = { data: [[0, value]] };
try {
$.plot(this.canvasElement, [plotSeries], options);
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 && (
)}
-
{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..87d5b00f3b1
--- /dev/null
+++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
@@ -0,0 +1,27 @@
+.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;
+
+ .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 51%
rename from public/app/core/components/Select/PickerOption.test.tsx
rename to packages/grafana-ui/src/components/Select/SelectOption.test.tsx
index 6b4aedcfcc0..1876e438d75 100644
--- a/public/app/core/components/Select/PickerOption.test.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectOption.test.tsx
@@ -1,11 +1,12 @@
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 = {
+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 +19,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/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..590aca5c7a1
--- /dev/null
+++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
@@ -0,0 +1,211 @@
+import React, { PureComponent } from 'react';
+// import tinycolor, { ColorInput } from 'tinycolor2';
+
+import { Threshold } from '../../types';
+import { ColorPicker } from '../ColorPicker/ColorPicker';
+import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+import { colors } from '../../utils';
+
+export interface Props {
+ 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;
+
+ 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/public/app/core/components/Tooltip/Popper.tsx b/packages/grafana-ui/src/components/Tooltip/Popper.tsx
similarity index 61%
rename from public/app/core/components/Tooltip/Popper.tsx
rename to packages/grafana-ui/src/components/Tooltip/Popper.tsx
index 36cf0fe837e..eb64df1cb6e 100644
--- a/public/app/core/components/Tooltip/Popper.tsx
+++ b/packages/grafana-ui/src/components/Tooltip/Popper.tsx
@@ -1,49 +1,54 @@
import React, { PureComponent } from 'react';
-import Portal from 'app/core/components/Portal/Portal';
-import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
+import * as PopperJS from 'popper.js';
+import { Manager, Popper as ReactPopper } from 'react-popper';
+import { Portal } from '@grafana/ui';
import Transition from 'react-transition-group/Transition';
+export enum Themes {
+ Default = 'popper__background--default',
+ Error = 'popper__background--error',
+ Brand = 'popper__background--brand',
+}
+
const defaultTransitionStyles = {
transition: 'opacity 200ms linear',
opacity: 0,
};
-const transitionStyles = {
+const transitionStyles: {[key: string]: object} = {
exited: { opacity: 0 },
entering: { opacity: 0 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
};
-interface Props {
+interface Props extends React.DOMAttributes {
renderContent: (content: any) => any;
show: boolean;
- placement?: any;
+ placement?: PopperJS.Placement;
content: string | ((props: any) => JSX.Element);
- refClassName?: string;
+ referenceElement: PopperJS.ReferenceObject;
+ theme?: Themes;
}
class Popper extends PureComponent {
render() {
- const { children, renderContent, show, placement, refClassName } = this.props;
+ const { renderContent, show, placement, onMouseEnter, onMouseLeave, theme } = this.props;
const { content } = this.props;
+ const popperBackgroundClassName = 'popper__background' + (theme ? ' ' + theme : '');
+
return (
-
- {({ ref }) => (
-
- {children}
-
- )}
-
{transitionState => (
-
+
{({ ref, style, placement, arrowProps }) => {
return (
{
data-placement={placement}
className="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..5f4010ac58a
--- /dev/null
+++ b/packages/grafana-ui/src/components/Tooltip/PopperController.tsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import * as PopperJS from 'popper.js';
+import { Themes } from './Popper';
+
+type PopperContent = string | (() => JSX.Element);
+
+export interface UsingPopperProps {
+ show?: boolean;
+ placement?: PopperJS.Placement;
+ content: PopperContent;
+ children: JSX.Element;
+ renderContent?: (content: PopperContent) => JSX.Element;
+ theme?: Themes;
+}
+
+type PopperControllerRenderProp = (
+ showPopper: () => void,
+ hidePopper: () => void,
+ popperProps: {
+ show: boolean;
+ placement: PopperJS.Placement;
+ content: string | ((props: any) => JSX.Element);
+ renderContent: (content: any) => any;
+ theme?: Themes;
+ }
+) => JSX.Element;
+
+interface Props {
+ placement?: PopperJS.Placement;
+ content: PopperContent;
+ className?: string;
+ children: PopperControllerRenderProp;
+ theme?: Themes;
+}
+
+interface State {
+ placement: PopperJS.Placement;
+ show: boolean;
+}
+
+class PopperController extends React.Component
{
+ 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 = () => {
+ this.setState(prevState => ({
+ ...prevState,
+ show: true,
+ }));
+ };
+
+ hidePopper = () => {
+ this.setState(prevState => ({
+ ...prevState,
+ show: false,
+ }));
+ };
+
+ renderContent(content: PopperContent) {
+ if (typeof content === 'function') {
+ // If it's a function we assume it's a React component
+ const ReactComponent = content;
+ return ;
+ }
+ return content;
+ }
+
+ render() {
+ const { children, content, theme } = this.props;
+ const { show, placement } = this.state;
+
+ return children(this.showPopper, this.hidePopper, {
+ show,
+ placement,
+ content,
+ renderContent: this.renderContent,
+ theme,
+ });
+ }
+}
+
+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..9cffb151d83
--- /dev/null
+++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
@@ -0,0 +1,32 @@
+import React, { createRef } from 'react';
+import * as PopperJS from 'popper.js';
+import Popper from './Popper';
+import PopperController, { UsingPopperProps } from './PopperController';
+
+export const Tooltip = ({ children, renderContent, ...controllerProps }: UsingPopperProps) => {
+ const tooltipTriggerRef = createRef();
+
+ 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 83%
rename from public/sass/components/_popper.scss
rename to packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
index d869d52b92f..c8fa099cce6 100644
--- a/public/sass/components/_popper.scss
+++ b/packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
@@ -1,5 +1,13 @@
$popper-margin-from-ref: 5px;
+
+@mixin popper-theme($backgroundColor, $arrowColor) {
+ background: $backgroundColor;
+ .popper__arrow {
+ border-color: $arrowColor;
+ }
+}
+
.popper {
position: absolute;
z-index: $zindex-tooltip;
@@ -8,7 +16,24 @@ $popper-margin-from-ref: 5px;
text-align: center;
}
-.popper .popper__arrow {
+.popper__background {
+ background: $tooltipBackground;
+ border-radius: $border-radius;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
+ padding: 10px;
+
+ // Themes
+ &.popper__background--error {
+ @include popper-theme($tooltipBackgroundError, $tooltipBackgroundError);
+ }
+
+ &.popper__background--brand {
+ @include popper-theme($tooltipBackgroundBrand, $tooltipBackgroundBrand);
+ @include gradient-vertical($red, $orange);
+ }
+}
+
+.popper__arrow {
width: 0;
height: 0;
border-style: solid;
@@ -16,17 +41,10 @@ $popper-margin-from-ref: 5px;
margin: 0px;
}
-.popper .popper__arrow {
+.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 (
<>
-
-
+
+
+
+ Text
-
-
-
-
-
-
-
-
@@ -96,17 +92,16 @@ export default class MappingRow extends PureComponent
{
return (
<>
-
-
-
-
+
-
+
Text
{
return (
-
+ 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..584992f8803 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 } from './ColorPicker/ColorPicker';
+export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
+export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
+export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
+export { Graph } from './Graph/Graph';
+export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
+export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
+export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
+export { Gauge } from './Gauge/Gauge';
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 (
-
- );
-};
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.scss b/packages/grafana-ui/src/index.scss
index 841415620d6..74fdcde3072 100644
--- a/packages/grafana-ui/src/index.scss
+++ b/packages/grafana-ui/src/index.scss
@@ -1 +1,3 @@
+@import 'vendor/spectrum';
@import 'components/index';
+
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/datasource.ts b/packages/grafana-ui/src/types/datasource.ts
new file mode 100644
index 00000000000..ffcbbb5fe64
--- /dev/null
+++ b/packages/grafana-ui/src/types/datasource.ts
@@ -0,0 +1,89 @@
+import { TimeRange, RawTimeRange } from './time';
+import { TimeSeries } from './series';
+import { PluginMeta } from './plugin';
+
+export interface DataQueryResponse {
+ data: TimeSeries[];
+}
+
+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..eaa39fb080b 100644
--- a/packages/grafana-ui/src/types/index.ts
+++ b/packages/grafana-ui/src/types/index.ts
@@ -1,3 +1,5 @@
export * from './series';
export * from './time';
export * from './panel';
+export * from './plugin';
+export * from './datasource';
diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts
index 44336555a81..881bf920c27 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 { TimeRange } from './time';
+export type InterpolateFunction = (value: string, format?: string | Function) => string;
+
export interface PanelProps {
timeSeries: TimeSeries[];
timeRange: TimeRange;
@@ -9,6 +11,7 @@ export interface PanelProps {
renderCounter: number;
width: number;
height: number;
+ onInterpolate: InterpolateFunction;
}
export interface PanelOptionsProps {
@@ -29,3 +32,44 @@ 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;
+}
+
+export type ThemeName = 'dark' | 'light';
+
+export enum ThemeNames {
+ Dark = 'dark',
+ Light = 'light',
+}
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/types/series.ts b/packages/grafana-ui/src/types/series.ts
index 49662e9872d..5cad1e4a72a 100644
--- a/packages/grafana-ui/src/types/series.ts
+++ b/packages/grafana-ui/src/types/series.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 {
diff --git a/packages/grafana-ui/src/utils/colors.ts b/packages/grafana-ui/src/utils/colors.ts
new file mode 100644
index 00000000000..263d128aec4
--- /dev/null
+++ b/packages/grafana-ui/src/utils/colors.ts
@@ -0,0 +1,93 @@
+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..aeb65032067 100644
--- a/packages/grafana-ui/src/utils/index.ts
+++ b/packages/grafana-ui/src/utils/index.ts
@@ -1 +1,3 @@
export * from './processTimeSeries';
+export * from './valueFormats/valueFormats';
+export * from './colors';
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/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/public/vendor/css/spectrum.css b/packages/grafana-ui/src/vendor/spectrum.css
similarity index 100%
rename from public/vendor/css/spectrum.css
rename to packages/grafana-ui/src/vendor/spectrum.css
diff --git a/public/vendor/spectrum.js b/packages/grafana-ui/src/vendor/spectrum.js
similarity index 100%
rename from public/vendor/spectrum.js
rename to packages/grafana-ui/src/vendor/spectrum.js
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/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/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/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/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_proxy.go b/pkg/api/pluginproxy/ds_proxy.go
index 38a2fd187e3..b569b006531 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},
}
}
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/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/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/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/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..4423046d600 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
@@ -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,6 +117,13 @@ 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()
diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go
index 2a9e95e5723..cf25cc118f4 100644
--- a/pkg/services/alerting/rule_test.go
+++ b/pkg/services/alerting/rule_test.go
@@ -14,6 +14,36 @@ 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) {
Convey("Testing alert rule", t, func() {
@@ -21,26 +51,6 @@ func TestAlertRuleModel(t *testing.T) {
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)
@@ -89,5 +99,35 @@ func TestAlertRuleModel(t *testing.T) {
So(len(alertRule.Notifications), ShouldEqual, 2)
})
})
+
+ 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)
+ })
})
}
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/auth/auth_token.go b/pkg/services/auth/auth_token.go
new file mode 100644
index 00000000000..7e9433c2d70
--- /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.Info("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 59ceefa0be5..33f418cbee3 100644
--- a/pkg/services/dashboards/dashboard_service.go
+++ b/pkg/services/dashboards/dashboard_service.go
@@ -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/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/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/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/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..7673d80ea91 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"
@@ -222,13 +223,9 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
cnnstr += "&tls=custom"
}
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 = "''"
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/mssql.go b/pkg/tsdb/mssql/mssql.go
index 469d6baa5de..bb2e06ed673 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/util/encoding.go b/pkg/util/encoding.go
index 0edb721e422..e82344d73f9 100644
--- a/pkg/util/encoding.go
+++ b/pkg/util/encoding.go
@@ -101,3 +101,11 @@ func DecodeBasicAuthHeader(header string) (string, string, error) {
return userAndPass[0], userAndPass[1], nil
}
+
+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/ip.go b/pkg/util/ip.go
new file mode 100644
index 00000000000..351abd7a03b
--- /dev/null
+++ b/pkg/util/ip.go
@@ -0,0 +1,24 @@
+package util
+
+import (
+ "net"
+)
+
+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..f938c182b02
--- /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/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..65eb68966ff 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,37 @@ export function registerAngularDirectives() {
['onChange', { watchDepth: 'reference' }],
['tagOptions', { watchDepth: 'reference' }],
]);
+ react2AngularDirective('colorPicker', ColorPicker, [
+ 'color',
+ ['onChange', { watchDepth: 'reference', wrapApply: true }],
+ ]);
+ react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
+ '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..8c9a5595cb7
--- /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';
+
+interface Props {
+ title?: string;
+ 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 (prevProps.title !== this.props.title) {
+ 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..83066054f88 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 });
};
diff --git a/public/app/core/components/PageLoader/PageLoader.tsx b/public/app/core/components/PageLoader/PageLoader.tsx
index dcb67dde220..3182695e5e5 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 (
+
-
+
+
+
`;
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..4d46d88a1d2 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 '../../panel_model';
+import { DashboardModel } from '../../dashboard_model';
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..20ab21541a5 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 '../../dashboard_model';
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..22b93b767d6 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 '../../dashboard_model';
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..d7305b948dc 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 '../../dashboard_model';
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 100%
rename from public/app/features/dashboard/dashnav/dashnav.html
rename to public/app/features/dashboard/components/DashNav/template.html
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..506709fad75 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/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..a0eb5c8c6b3 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 '../../dashboard_model';
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 100%
rename from public/app/features/dashboard/dashgrid/RowOptions.ts
rename to public/app/features/dashboard/components/RowOptions/RowOptionsCtrl.ts
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..b8632e2eeae 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 '../../dashboard_model';
+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..75766060e7f 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 '../../dashboard_model';
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 96%
rename from public/app/features/dashboard/history/history_srv.ts
rename to public/app/features/dashboard/components/VersionHistory/HistorySrv.ts
index 7f7dc950de3..d52f3ab879c 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 '../../dashboard_model';
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/dashboard_ctrl.ts
index 6611a728803..5c4480dbad5 100644
--- a/public/app/features/dashboard/dashboard_ctrl.ts
+++ b/public/app/features/dashboard/dashboard_ctrl.ts
@@ -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/dashboard_migration.ts b/public/app/features/dashboard/dashboard_migration.ts
index abd12ab4b13..2dbeb6c6e80 100644
--- a/public/app/features/dashboard/dashboard_migration.ts
+++ b/public/app/features/dashboard/dashboard_migration.ts
@@ -9,6 +9,7 @@ import {
} from 'app/core/constants';
import { PanelModel } from './panel_model';
import { DashboardModel } from './dashboard_model';
+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/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts
index 6f98bc5a17a..2ae2df0124b 100644
--- a/public/app/features/dashboard/dashboard_model.ts
+++ b/public/app/features/dashboard/dashboard_model.ts
@@ -1,8 +1,8 @@
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';
@@ -442,7 +442,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 +462,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;
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/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
index 9de298e0799..cfff64cb042 100644
--- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
@@ -5,11 +5,11 @@ 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 { PanelChrome } from './PanelChrome';
-import { PanelEditor } from './PanelEditor';
+import { PanelEditor } from '../panel_editor/PanelEditor';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
@@ -53,7 +53,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..d4f6859f1b6 100644
--- a/public/app/features/dashboard/dashgrid/DataPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx
@@ -1,5 +1,9 @@
// Library
import React, { Component } from 'react';
+import { Tooltip } from '@grafana/ui';
+import { Themes } from '@grafana/ui/src/components/Tooltip/Popper';
+
+import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary';
// Services
import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource_srv';
@@ -8,8 +12,9 @@ import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource
import kbn from 'app/core/utils/kbn';
// Types
-import { DataQueryOptions, DataQueryResponse } from 'app/types';
-import { TimeRange, TimeSeries, LoadingState } from '@grafana/ui';
+import { TimeRange, TimeSeries, LoadingState, DataQueryResponse, DataQueryOptions } from '@grafana/ui/src/types';
+
+const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
interface RenderProps {
loading: LoadingState;
@@ -33,6 +38,7 @@ export interface Props {
export interface State {
isFirstLoad: boolean;
loading: LoadingState;
+ errorMessage: string;
response: DataQueryResponse;
}
@@ -51,6 +57,7 @@ export class DataPanel extends Component {
this.state = {
loading: LoadingState.NotStarted,
+ errorMessage: '',
response: {
data: [],
},
@@ -90,7 +97,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);
@@ -128,7 +135,17 @@ 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,
+ });
}
};
@@ -139,7 +156,7 @@ export class DataPanel extends Component {
const timeSeries = response.data;
if (isFirstLoad && loading === LoadingState.Loading) {
- return this.renderLoadingSpinner();
+ return this.renderLoadingStates();
}
if (!queries.length) {
@@ -152,24 +169,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({
+ timeSeries,
+ loading,
+ })}
+ >
+ );
+ }}
+
>
);
}
- 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..6b4ef48c32e 100644
--- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx
@@ -19,6 +19,9 @@ import { DashboardModel } from '../dashboard_model';
import { PanelPlugin } from 'app/types';
import { TimeRange } from '@grafana/ui';
+import variables from 'sass/_variables.scss';
+import templateSrv from 'app/features/templating/template_srv';
+
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
@@ -76,6 +79,10 @@ export class PanelChrome extends PureComponent {
});
};
+ onInterpolate = (value: string, format?: string) => {
+ return templateSrv.replace(value, this.props.panel.scopedVars, format);
+ };
+
get isVisible() {
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
}
@@ -87,7 +94,6 @@ export class PanelChrome extends PureComponent {
const { datasource, targets, transparent } = panel;
const PanelComponent = plugin.exports.Panel;
const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
-
return (
{({ width, height }) => {
@@ -123,9 +129,10 @@ export class PanelChrome extends PureComponent {
timeSeries={timeSeries}
timeRange={timeRange}
options={panel.getOptions(plugin.exports.PanelDefaults)}
- width={width}
- height={height - PANEL_HEADER_HEIGHT}
+ width={width - 2 * variables.panelHorizontalPadding}
+ height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding}
renderCounter={renderCounter}
+ onInterpolate={this.onInterpolate}
/>
);
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
index 8b7afd7d09e..b5cd9258c08 100644
--- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
@@ -3,6 +3,7 @@ import classNames from 'classnames';
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';
@@ -45,7 +46,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..01f790f73ee 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/panel_model';
+import templateSrv from 'app/features/templating/template_srv';
+import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
+import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
enum InfoModes {
Error = 'Error',
@@ -78,12 +78,14 @@ export class PanelHeaderCorner extends Component
{
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
-
-
+
+
+
+
) : null}
>
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..ca8abd0d1e3 100644
--- a/public/app/features/dashboard/dashgrid/PanelResizer.tsx
+++ b/public/app/features/dashboard/dashgrid/PanelResizer.tsx
@@ -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;
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..efa54f0ee07
--- /dev/null
+++ b/public/app/features/dashboard/index.ts
@@ -0,0 +1,33 @@
+import './dashboard_ctrl';
+import './time_srv';
+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 75%
rename from public/app/features/dashboard/dashgrid/DataSourceOption.tsx
rename to public/app/features/dashboard/panel_editor/DataSourceOption.tsx
index 0adfc4abe16..e4bbcfffe1d 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 { Tooltip } from '@grafana/ui';
interface Props {
label: string;
@@ -10,7 +10,7 @@ interface Props {
tooltipInfo?: any;
}
-export const DataSourceOptions: SFC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
+export const DataSourceOptions: FC = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
const dsOption = (
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 100%
rename from public/app/features/dashboard/dashgrid/GeneralTab.tsx
rename to public/app/features/dashboard/panel_editor/GeneralTab.tsx
diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx
similarity index 92%
rename from public/app/features/dashboard/dashgrid/PanelEditor.tsx
rename to public/app/features/dashboard/panel_editor/PanelEditor.tsx
index a746d6c4b91..a09ff66f114 100644
--- a/public/app/features/dashboard/dashgrid/PanelEditor.tsx
+++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx
@@ -4,7 +4,7 @@ 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';
@@ -15,7 +15,8 @@ import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { PanelPlugin } from 'app/types/plugins';
-import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import { Tooltip } from '@grafana/ui';
+import { Themes } from '@grafana/ui/src/components/Tooltip/Popper';
interface PanelEditorProps {
panel: PanelModel;
@@ -54,7 +55,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/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx
similarity index 50%
rename from public/app/features/dashboard/dashgrid/QueriesTab.tsx
rename to public/app/features/dashboard/panel_editor/QueriesTab.tsx
index 9ad0bb3cadd..28d822e3ad5 100644
--- a/public/app/features/dashboard/dashgrid/QueriesTab.tsx
+++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx
@@ -1,26 +1,24 @@
// Libraries
-import React, { SFC, PureComponent } from 'react';
+import React, { PureComponent } from 'react';
import _ from 'lodash';
// Components
-import './../../panel/metrics_tab';
-import { EditorTabBody } from './EditorTabBody';
+import { EditorTabBody, EditorToolbarView } 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';
+import { PanelOptionsGroup } from '@grafana/ui';
+import { QueryEditorRow } from './QueryEditorRow';
// 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 { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
import config from 'app/core/config';
// Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
-import { DataSourceSelectItem, DataQuery } from 'app/types';
+import { DataQuery, DataSourceSelectItem } from '@grafana/ui/src/types';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
interface Props {
@@ -34,66 +32,25 @@ interface State {
isLoadingHelp: boolean;
isPickerOpen: boolean;
isAddingMixed: boolean;
+ scrollTop: number;
}
-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;
+ state: State = {
+ isLoadingHelp: false,
+ currentDS: this.findCurrentDataSource(),
+ helpContent: null,
+ isPickerOpen: false,
+ isAddingMixed: false,
+ scrollTop: 0,
+ };
- 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();
- }
+ findCurrentDataSource(): DataSourceSelectItem {
+ const { panel } = this.props;
+ return this.datasources.find(datasource => datasource.value === panel.datasource) || this.datasources[0];
}
onChangeDataSource = datasource => {
@@ -130,7 +87,7 @@ export class QueriesTab extends PureComponent {
renderQueryInspector = () => {
const { panel } = this.props;
- return ;
+ return ;
};
renderHelp = () => {
@@ -139,7 +96,7 @@ export class QueriesTab extends PureComponent {
onAddQuery = (query?: Partial) => {
this.props.panel.addQuery(query);
- this.forceUpdate();
+ this.setState({ scrollTop: this.state.scrollTop + 100000 });
};
onAddQueryClick = () => {
@@ -148,9 +105,7 @@ export class QueriesTab extends PureComponent {
return;
}
- this.props.panel.addQuery();
- this.component.digest();
- this.forceUpdate();
+ this.onAddQuery();
};
onRemoveQuery = (query: DataQuery) => {
@@ -173,9 +128,20 @@ export class QueriesTab extends PureComponent {
};
renderToolbar = () => {
- const { currentDS } = this.state;
+ const { currentDS, isAddingMixed } = this.state;
- return ;
+ return (
+ <>
+
+
+ {!isAddingMixed && (
+
+ Add Query
+
+ )}
+ {isAddingMixed && this.renderMixedPicker()}
+ >
+ );
};
renderMixedPicker = () => {
@@ -192,59 +158,59 @@ export class QueriesTab extends PureComponent {
onAddMixedQuery = datasource => {
this.onAddQuery({ datasource: datasource.name });
- this.component.digest();
- this.setState({ isAddingMixed: false });
+ 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, isAddingMixed } = this.state;
+ const { currentDS, scrollTop } = this.state;
- const queryInspector = {
+ const queryInspector: EditorToolbarView = {
title: 'Query Inspector',
render: this.renderQueryInspector,
};
- const dsHelp = {
+ const dsHelp: EditorToolbarView = {
heading: 'Help',
icon: 'fa fa-question',
render: this.renderHelp,
};
return (
-
+
<>
-
-
-
(this.element = element)} />
-
-
-
-
-
-
- {!isAddingMixed && (
-
- Add Query
-
- )}
- {isAddingMixed && this.renderMixedPicker()}
-
-
-
-
-
+
+ {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..2651ab0608c
--- /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 '../panel_model';
+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 96%
rename from public/app/features/dashboard/dashgrid/QueryOptions.tsx
rename to public/app/features/dashboard/panel_editor/QueryOptions.tsx
index fad70d92990..61cdbefbce2 100644
--- a/public/app/features/dashboard/dashgrid/QueryOptions.tsx
+++ b/public/app/features/dashboard/panel_editor/QueryOptions.tsx
@@ -10,11 +10,12 @@ 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 { 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 (
-
+
<>
) {
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/partials/folder_permissions.html b/public/app/features/dashboard/partials/folder_permissions.html
deleted file mode 100644
index be44c1051f2..00000000000
--- a/public/app/features/dashboard/partials/folder_permissions.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
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}}
-
-
-
-
-{{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 97%
rename from public/app/features/dashboard/specs/change_tracker.test.ts
rename to public/app/features/dashboard/services/ChangeTracker.test.ts
index e7f8ce977b1..dfc9b3fa03f 100644
--- a/public/app/features/dashboard/specs/change_tracker.test.ts
+++ b/public/app/features/dashboard/services/ChangeTracker.test.ts
@@ -1,4 +1,4 @@
-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';
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..ef3d456db48 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 '../dashboard_model';
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..67a4938c6aa 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 '../dashboard_model';
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 79%
rename from public/app/features/dashboard/specs/viewstate_srv.test.ts
rename to public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
index f9963afbf85..20215017e1d 100644
--- a/public/app/features/dashboard/specs/viewstate_srv.test.ts
+++ b/public/app/features/dashboard/services/DashboardViewStateSrv.test.ts
@@ -1,7 +1,5 @@
-//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 { DashboardViewStateSrv } from './DashboardViewStateSrv';
import { DashboardModel } from '../dashboard_model';
describe('when updating view state', () => {
@@ -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..816b6d8bd2d 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 '../dashboard_model';
// 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/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/specs/dashboard_migration.test.ts
index 5f693c9f6d9..e15bd65d5a5 100644
--- a/public/app/features/dashboard/specs/dashboard_migration.test.ts
+++ b/public/app/features/dashboard/specs/dashboard_migration.test.ts
@@ -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/specs/panel_model.test.ts b/public/app/features/dashboard/specs/panel_model.test.ts
index 36bb8d6297e..89976fa275a 100644
--- a/public/app/features/dashboard/specs/panel_model.test.ts
+++ b/public/app/features/dashboard/specs/panel_model.test.ts
@@ -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/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..cfbe094125f 100644
--- a/public/app/features/dashboard/utils/panel.ts
+++ b/public/app/features/dashboard/utils/panel.ts
@@ -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..1cc4933519e 100644
--- a/public/app/features/datasources/DataSourceDashboards.test.tsx
+++ b/public/app/features/datasources/DataSourceDashboards.test.tsx
@@ -1,13 +1,14 @@
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(),
diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx
index 51f9c9ca945..327908af44a 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';
+
+// Components
import PageHeader from 'app/core/components/PageHeader/PageHeader';
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,10 +15,14 @@ import { loadPluginDashboards } from '../plugins/state/actions';
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
import { getDataSource } from './state/selectors';
+// Types
+import { NavModel, PluginDashboard } 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;
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/__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__/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..77c4f3d13c6 100644
--- a/public/app/features/datasources/settings/BasicSettings.tsx
+++ b/public/app/features/datasources/settings/BasicSettings.tsx
@@ -1,5 +1,5 @@
-import React, { SFC } from 'react';
-import { Label } from 'app/core/components/Label/Label';
+import React, { FC } from 'react';
+import { FormLabel } from '@grafana/ui';
import { Switch } from '../../../core/components/Switch/Switch';
export interface Props {
@@ -9,19 +9,19 @@ export interface Props {
onDefaultChange: (value: boolean) => void;
}
-const BasicSettings: SFC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
+const BasicSettings: FC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
return (
-
+
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 94%
rename from public/app/features/datasources/settings/DataSourceSettings.tsx
rename to public/app/features/datasources/settings/DataSourceSettingsPage.tsx
index 5786bd1db57..87efed266b0 100644
--- a/public/app/features/datasources/settings/DataSourceSettings.tsx
+++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx
@@ -1,28 +1,34 @@
+// Libraries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
+// Components
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
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 } 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 +39,7 @@ export interface Props {
}
interface State {
- dataSource: DataSource;
+ dataSource: DataSourceSettings;
isTesting?: boolean;
testingMessage?: string;
testingStatus?: string;
@@ -44,12 +50,12 @@ enum DataSourceStates {
Beta = 'beta',
}
-export class DataSourceSettings extends PureComponent {
+export class DataSourceSettingsPage extends PureComponent {
constructor(props) {
super(props);
this.state = {
- dataSource: {} as DataSource,
+ dataSource: {} as DataSourceSettings,
};
}
@@ -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__/DataSourceSettingsPage.test.tsx.snap
similarity index 100%
rename from public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap
rename to public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap
diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts
index fa9d47eb6a1..008dc9fe816 100644
--- a/public/app/features/datasources/state/actions.ts
+++ b/public/app/features/datasources/state/actions.ts
@@ -6,7 +6,8 @@ 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',
@@ -22,7 +23,7 @@ export enum ActionTypes {
interface LoadDataSourcesAction {
type: ActionTypes.LoadDataSources;
- payload: DataSource[];
+ payload: DataSourceSettings[];
}
interface SetDataSourcesSearchQueryAction {
@@ -47,7 +48,7 @@ interface SetDataSourceTypeSearchQueryAction {
interface LoadDataSourceAction {
type: ActionTypes.LoadDataSource;
- payload: DataSource;
+ payload: DataSourceSettings;
}
interface LoadDataSourceMetaAction {
@@ -65,12 +66,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,
});
@@ -171,7 +172,7 @@ export function loadDataSourceTypes(): ThunkResult {
};
}
-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..7be93f5a644 100644
--- a/public/app/features/datasources/state/reducers.ts
+++ b/public/app/features/datasources/state/reducers.ts
@@ -1,11 +1,12 @@
-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[],
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..c3a92f4007e
--- /dev/null
+++ b/public/app/features/explore/GraphContainer.tsx
@@ -0,0 +1,66 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import { RawTimeRange, TimeRange } from '@grafana/ui';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleGraph } from './state/actions';
+import Graph from './Graph';
+import Panel from './Panel';
+
+interface GraphContainerProps {
+ onChangeTime: (range: TimeRange) => void;
+ exploreId: ExploreId;
+ graphResult?: any[];
+ loading: boolean;
+ range: RawTimeRange;
+ showingGraph: boolean;
+ showingTable: boolean;
+ split: boolean;
+ toggleGraph: typeof toggleGraph;
+}
+
+export class GraphContainer extends PureComponent {
+ onClickGraphButton = () => {
+ this.props.toggleGraph(this.props.exploreId);
+ };
+
+ render() {
+ const { exploreId, graphResult, loading, onChangeTime, 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,
+};
+
+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..7e3cdfe9315 100644
--- a/public/app/features/explore/Logs.tsx
+++ b/public/app/features/explore/Logs.tsx
@@ -1,7 +1,5 @@
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';
@@ -11,20 +9,16 @@ import {
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 +37,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 +48,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 +62,7 @@ interface LogsProps {
onStopScanning?: () => void;
}
-interface LogsState {
+interface State {
dedup: LogsDedupStrategy;
deferLogs: boolean;
hiddenLogLevels: Set;
@@ -263,7 +72,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 +157,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 +214,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 +255,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..266e6fb42df 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 { 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';
+
+// Types
+import { Emitter } from 'app/core/utils/emitter';
+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 (this.element = element)} style={{ width: '100%' }} />;
+ return
(this.element = element)} style={{ width: '100%' }} />;
}
}
diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx
index 24b8b8f5b16..d27213cea34 100644
--- a/public/app/features/explore/QueryField.tsx
+++ b/public/app/features/explore/QueryField.tsx
@@ -73,6 +73,7 @@ export class QueryField extends React.PureComponent
{
- this.setState({
- suggestions: [],
- typeaheadIndex: 0,
- typeaheadPrefix: '',
- typeaheadContext: null,
- });
- this.resetTimer = null;
+ if (this.mounted) {
+ this.setState({
+ suggestions: [],
+ typeaheadIndex: 0,
+ typeaheadPrefix: '',
+ typeaheadContext: null,
+ });
+ this.resetTimer = null;
+ }
};
handleBlur = () => {
diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx
new file mode 100644
index 00000000000..3bc2b556a63
--- /dev/null
+++ b/public/app/features/explore/QueryRow.tsx
@@ -0,0 +1,167 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+// Components
+import QueryEditor from './QueryEditor';
+import QueryTransactionStatus from './QueryTransactionStatus';
+
+// Actions
+import {
+ addQueryRow,
+ changeQuery,
+ highlightLogsExpression,
+ modifyQueries,
+ removeQueryRow,
+ runQueries,
+} from './state/actions';
+
+// Types
+import { StoreState } from 'app/types';
+import { RawTimeRange, DataQuery, QueryHint } from '@grafana/ui';
+import { QueryTransaction, HistoryItem, ExploreItemState, ExploreId } from 'app/types/explore';
+import { Emitter } from 'app/core/utils/emitter';
+
+function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
+ const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
+ if (transaction) {
+ return transaction.hints[0];
+ }
+ return undefined;
+}
+
+interface QueryRowProps {
+ addQueryRow: typeof addQueryRow;
+ changeQuery: typeof changeQuery;
+ className?: string;
+ exploreId: ExploreId;
+ datasourceInstance: any;
+ highlightLogsExpression: typeof highlightLogsExpression;
+ history: HistoryItem[];
+ index: number;
+ initialQuery: DataQuery;
+ modifyQueries: typeof modifyQueries;
+ queryTransactions: QueryTransaction[];
+ exploreEvents: Emitter;
+ range: RawTimeRange;
+ removeQueryRow: typeof removeQueryRow;
+ runQueries: typeof runQueries;
+}
+
+export class QueryRow extends PureComponent {
+ onExecuteQuery = () => {
+ const { exploreId } = this.props;
+ this.props.runQueries(exploreId);
+ };
+
+ onChangeQuery = (query: DataQuery, override?: boolean) => {
+ const { datasourceInstance, exploreId, index } = this.props;
+ this.props.changeQuery(exploreId, query, index, override);
+ if (query && !override && datasourceInstance.getHighlighterExpression && index === 0) {
+ // Live preview of log search matches. Only use on first row for now
+ this.updateLogsHighlights(query);
+ }
+ };
+
+ onClickAddButton = () => {
+ const { exploreId, index } = this.props;
+ this.props.addQueryRow(exploreId, index);
+ };
+
+ onClickClearButton = () => {
+ this.onChangeQuery(null, true);
+ };
+
+ onClickHintFix = action => {
+ const { datasourceInstance, exploreId, index } = this.props;
+ if (datasourceInstance && datasourceInstance.modifyQuery) {
+ const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action);
+ this.props.modifyQueries(exploreId, action, index, modifier);
+ }
+ };
+
+ onClickRemoveButton = () => {
+ const { exploreId, index } = this.props;
+ this.props.removeQueryRow(exploreId, index);
+ };
+
+ updateLogsHighlights = _.debounce((value: DataQuery) => {
+ const { datasourceInstance } = this.props;
+ if (datasourceInstance.getHighlighterExpression) {
+ const expressions = [datasourceInstance.getHighlighterExpression(value)];
+ this.props.highlightLogsExpression(this.props.exploreId, expressions);
+ }
+ }, 500);
+
+ render() {
+ const { datasourceInstance, history, index, initialQuery, queryTransactions, exploreEvents, range } = this.props;
+ const transactions = queryTransactions.filter(t => t.rowIndex === index);
+ const transactionWithError = transactions.find(t => t.error !== undefined);
+ const hint = getFirstHintFromTransactions(transactions);
+ const queryError = transactionWithError ? transactionWithError.error : null;
+ const QueryField = datasourceInstance.pluginExports.ExploreQueryField;
+ return (
+
+
+
+
+
+ {QueryField ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId, index }) {
+ const explore = state.explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { datasourceInstance, history, initialQueries, queryTransactions, range } = item;
+ const initialQuery = initialQueries[index];
+ return { datasourceInstance, history, initialQuery, queryTransactions, range };
+}
+
+const mapDispatchToProps = {
+ addQueryRow,
+ changeQuery,
+ highlightLogsExpression,
+ modifyQueries,
+ removeQueryRow,
+ runQueries,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(QueryRow));
diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx
index 4101475092b..f8bb6e5ce6b 100644
--- a/public/app/features/explore/QueryRows.tsx
+++ b/public/app/features/explore/QueryRows.tsx
@@ -1,159 +1,29 @@
+// Libraries
import React, { PureComponent } from 'react';
-import { QueryTransaction, HistoryItem, QueryHint } from 'app/types/explore';
+// Components
+import QueryRow from './QueryRow';
+
+// Types
import { Emitter } from 'app/core/utils/emitter';
+import { DataQuery } from '@grafana/ui/src/types';
+import { ExploreId } from 'app/types/explore';
-// import DefaultQueryField from './QueryField';
-import QueryEditor from './QueryEditor';
-import QueryTransactionStatus from './QueryTransactionStatus';
-import { DataSource, DataQuery } from 'app/types';
-import { RawTimeRange } from '@grafana/ui';
-
-function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint {
- const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0);
- if (transaction) {
- return transaction.hints[0];
- }
- return undefined;
-}
-
-interface QueryRowEventHandlers {
- onAddQueryRow: (index: number) => void;
- onChangeQuery: (value: DataQuery, index: number, override?: boolean) => void;
- onClickHintFix: (action: object, index?: number) => void;
- onExecuteQuery: () => void;
- onRemoveQueryRow: (index: number) => void;
-}
-
-interface QueryRowCommonProps {
+interface QueryRowsProps {
className?: string;
- datasource: DataSource;
- history: HistoryItem[];
- transactions: QueryTransaction[];
exploreEvents: Emitter;
- range: RawTimeRange;
+ exploreId: ExploreId;
+ initialQueries: DataQuery[];
}
-type QueryRowProps = QueryRowCommonProps &
- QueryRowEventHandlers & {
- index: number;
- initialQuery: DataQuery;
- };
-
-class QueryRow extends PureComponent {
- onExecuteQuery = () => {
- const { onExecuteQuery } = this.props;
- onExecuteQuery();
- };
-
- onChangeQuery = (value: DataQuery, override?: boolean) => {
- const { index, onChangeQuery } = this.props;
- if (onChangeQuery) {
- onChangeQuery(value, index, override);
- }
- };
-
- onClickAddButton = () => {
- const { index, onAddQueryRow } = this.props;
- if (onAddQueryRow) {
- onAddQueryRow(index);
- }
- };
-
- onClickClearButton = () => {
- this.onChangeQuery(null, true);
- };
-
- onClickHintFix = action => {
- const { index, onClickHintFix } = this.props;
- if (onClickHintFix) {
- onClickHintFix(action, index);
- }
- };
-
- onClickRemoveButton = () => {
- const { index, onRemoveQueryRow } = this.props;
- if (onRemoveQueryRow) {
- onRemoveQueryRow(index);
- }
- };
-
- onPressEnter = () => {
- const { onExecuteQuery } = this.props;
- if (onExecuteQuery) {
- onExecuteQuery();
- }
- };
-
- render() {
- const { datasource, history, initialQuery, transactions, exploreEvents, range } = this.props;
- const transactionWithError = transactions.find(t => t.error !== undefined);
- const hint = getFirstHintFromTransactions(transactions);
- const queryError = transactionWithError ? transactionWithError.error : null;
- const QueryField = datasource.pluginExports.ExploreQueryField;
- return (
-
-
-
-
-
- {QueryField ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-type QueryRowsProps = QueryRowCommonProps &
- QueryRowEventHandlers & {
- initialQueries: DataQuery[];
- };
-
export default class QueryRows extends PureComponent {
render() {
- const { className = '', initialQueries, transactions, ...handlers } = this.props;
+ const { className = '', exploreEvents, exploreId, initialQueries } = this.props;
return (
{initialQueries.map((query, index) => (
- t.rowIndex === index)}
- {...handlers}
- />
+ // TODO instead of relying on initialQueries, move to react key list in redux
+
))}
);
diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx
new file mode 100644
index 00000000000..f386e5ab99b
--- /dev/null
+++ b/public/app/features/explore/TableContainer.tsx
@@ -0,0 +1,54 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { StoreState } from 'app/types';
+
+import { toggleTable } from './state/actions';
+import Table from './Table';
+import Panel from './Panel';
+import TableModel from 'app/core/table_model';
+
+interface TableContainerProps {
+ exploreId: ExploreId;
+ loading: boolean;
+ onClickCell: (key: string, value: string) => void;
+ showingTable: boolean;
+ tableResult?: TableModel;
+ toggleTable: typeof toggleTable;
+}
+
+export class TableContainer extends PureComponent {
+ onClickTableButton = () => {
+ this.props.toggleTable(this.props.exploreId);
+ };
+
+ render() {
+ const { loading, onClickCell, showingTable, tableResult } = this.props;
+
+ if (!tableResult) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState, { exploreId }) {
+ const explore = state.explore;
+ const item: ExploreItemState = explore[exploreId];
+ const { queryTransactions, showingTable, tableResult } = item;
+ const loading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
+ return { loading, showingTable, tableResult };
+}
+
+const mapDispatchToProps = {
+ toggleTable,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer));
diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx
index 8476c6b2b27..38c3f2e7498 100644
--- a/public/app/features/explore/TimePicker.tsx
+++ b/public/app/features/explore/TimePicker.tsx
@@ -293,6 +293,7 @@ export default class TimePicker extends PureComponent
diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx
index 721527fbebe..6d6c60c0553 100644
--- a/public/app/features/explore/Typeahead.tsx
+++ b/public/app/features/explore/Typeahead.tsx
@@ -55,7 +55,7 @@ class TypeaheadItem extends React.PureComponent
{
interface TypeaheadGroupProps {
items: CompletionItem[];
label: string;
- onClickItem: (CompletionItem) => void;
+ onClickItem: (suggestion: CompletionItem) => void;
selected: CompletionItem;
prefix?: string;
}
diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx
index de1eee4c662..aca2e6d8cbd 100644
--- a/public/app/features/explore/Wrapper.tsx
+++ b/public/app/features/explore/Wrapper.tsx
@@ -3,104 +3,83 @@ import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { updateLocation } from 'app/core/actions';
-import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore';
import { StoreState } from 'app/types';
-import { ExploreState } from 'app/types/explore';
+import { ExploreId, ExploreUrlState } from 'app/types/explore';
+import { parseUrlState } from 'app/core/utils/explore';
+import { initializeExploreSplit, resetExplore } from './state/actions';
import ErrorBoundary from './ErrorBoundary';
import Explore from './Explore';
+import { CustomScrollbar } from '@grafana/ui';
interface WrapperProps {
- backendSrv?: any;
- datasourceSrv?: any;
- updateLocation: typeof updateLocation;
- urlStates: { [key: string]: string };
-}
-
-interface WrapperState {
+ initializeExploreSplit: typeof initializeExploreSplit;
split: boolean;
- splitState: ExploreState;
+ updateLocation: typeof updateLocation;
+ resetExplore: typeof resetExplore;
+ urlStates: { [key: string]: string };
}
-const STATE_KEY_LEFT = 'state';
-const STATE_KEY_RIGHT = 'stateRight';
-
-export class Wrapper extends Component {
- urlStates: { [key: string]: string };
+export class Wrapper extends Component {
+ initialSplit: boolean;
+ urlStates: { [key: string]: ExploreUrlState };
constructor(props: WrapperProps) {
super(props);
- this.urlStates = props.urlStates;
- this.state = {
- split: Boolean(props.urlStates[STATE_KEY_RIGHT]),
- splitState: undefined,
- };
+ this.urlStates = {};
+ const { left, right } = props.urlStates;
+ if (props.urlStates.left) {
+ this.urlStates.leftState = parseUrlState(left);
+ }
+ if (props.urlStates.right) {
+ this.urlStates.rightState = parseUrlState(right);
+ this.initialSplit = true;
+ }
}
- onChangeSplit = (split: boolean, splitState: ExploreState) => {
- this.setState({ split, splitState });
- // When closing split, remove URL state for split part
- if (!split) {
- delete this.urlStates[STATE_KEY_RIGHT];
- this.props.updateLocation({
- query: this.urlStates,
- });
+ componentDidMount() {
+ if (this.initialSplit) {
+ this.props.initializeExploreSplit();
}
- };
+ }
- onSaveState = (key: string, state: ExploreState) => {
- const urlState = serializeStateToUrlParam(state, true);
- this.urlStates[key] = urlState;
- this.props.updateLocation({
- query: this.urlStates,
- });
- };
+ componentWillUnmount() {
+ this.props.resetExplore();
+ }
render() {
- const { datasourceSrv } = this.props;
- // State overrides for props from first Explore
- const { split, splitState } = this.state;
- const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]);
- const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]);
+ const { split } = this.props;
+ const { leftState, rightState } = this.urlStates;
return (
-
-
-
-
- {split && (
-
-
-
- )}
+
+
+
+
+
+
+ {split && (
+
+
+
+ )}
+
+
);
}
}
-const mapStateToProps = (state: StoreState) => ({
- urlStates: state.location.query,
-});
+const mapStateToProps = (state: StoreState) => {
+ const urlStates = state.location.query;
+ const { split } = state.explore;
+ return { split, urlStates };
+};
const mapDispatchToProps = {
+ initializeExploreSplit,
updateLocation,
+ resetExplore,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper));
diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts
new file mode 100644
index 00000000000..757f946f37a
--- /dev/null
+++ b/public/app/features/explore/state/actionTypes.ts
@@ -0,0 +1,315 @@
+// Types
+import { Emitter } from 'app/core/core';
+import { RawTimeRange, TimeRange, DataQuery, DataSourceSelectItem, DataSourceApi } from '@grafana/ui/src/types';
+import {
+ ExploreId,
+ ExploreItemState,
+ HistoryItem,
+ RangeScanner,
+ ResultType,
+ QueryTransaction,
+} from 'app/types/explore';
+
+export enum ActionTypes {
+ AddQueryRow = 'explore/ADD_QUERY_ROW',
+ ChangeDatasource = 'explore/CHANGE_DATASOURCE',
+ ChangeQuery = 'explore/CHANGE_QUERY',
+ ChangeSize = 'explore/CHANGE_SIZE',
+ ChangeTime = 'explore/CHANGE_TIME',
+ ClearQueries = 'explore/CLEAR_QUERIES',
+ HighlightLogsExpression = 'explore/HIGHLIGHT_LOGS_EXPRESSION',
+ InitializeExplore = 'explore/INITIALIZE_EXPLORE',
+ InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT',
+ LoadDatasourceFailure = 'explore/LOAD_DATASOURCE_FAILURE',
+ LoadDatasourceMissing = 'explore/LOAD_DATASOURCE_MISSING',
+ LoadDatasourcePending = 'explore/LOAD_DATASOURCE_PENDING',
+ LoadDatasourceSuccess = 'explore/LOAD_DATASOURCE_SUCCESS',
+ ModifyQueries = 'explore/MODIFY_QUERIES',
+ QueryTransactionFailure = 'explore/QUERY_TRANSACTION_FAILURE',
+ QueryTransactionStart = 'explore/QUERY_TRANSACTION_START',
+ QueryTransactionSuccess = 'explore/QUERY_TRANSACTION_SUCCESS',
+ RemoveQueryRow = 'explore/REMOVE_QUERY_ROW',
+ RunQueries = 'explore/RUN_QUERIES',
+ RunQueriesEmpty = 'explore/RUN_QUERIES_EMPTY',
+ ScanRange = 'explore/SCAN_RANGE',
+ ScanStart = 'explore/SCAN_START',
+ ScanStop = 'explore/SCAN_STOP',
+ SetQueries = 'explore/SET_QUERIES',
+ SplitClose = 'explore/SPLIT_CLOSE',
+ SplitOpen = 'explore/SPLIT_OPEN',
+ StateSave = 'explore/STATE_SAVE',
+ ToggleGraph = 'explore/TOGGLE_GRAPH',
+ ToggleLogs = 'explore/TOGGLE_LOGS',
+ ToggleTable = 'explore/TOGGLE_TABLE',
+ UpdateDatasourceInstance = 'explore/UPDATE_DATASOURCE_INSTANCE',
+ ResetExplore = 'explore/RESET_EXPLORE',
+}
+
+export interface AddQueryRowAction {
+ type: ActionTypes.AddQueryRow;
+ payload: {
+ exploreId: ExploreId;
+ index: number;
+ query: DataQuery;
+ };
+}
+
+export interface ChangeQueryAction {
+ type: ActionTypes.ChangeQuery;
+ payload: {
+ exploreId: ExploreId;
+ query: DataQuery;
+ index: number;
+ override: boolean;
+ };
+}
+
+export interface ChangeSizeAction {
+ type: ActionTypes.ChangeSize;
+ payload: {
+ exploreId: ExploreId;
+ width: number;
+ height: number;
+ };
+}
+
+export interface ChangeTimeAction {
+ type: ActionTypes.ChangeTime;
+ payload: {
+ exploreId: ExploreId;
+ range: TimeRange;
+ };
+}
+
+export interface ClearQueriesAction {
+ type: ActionTypes.ClearQueries;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface HighlightLogsExpressionAction {
+ type: ActionTypes.HighlightLogsExpression;
+ payload: {
+ exploreId: ExploreId;
+ expressions: string[];
+ };
+}
+
+export interface InitializeExploreAction {
+ type: ActionTypes.InitializeExplore;
+ payload: {
+ exploreId: ExploreId;
+ containerWidth: number;
+ eventBridge: Emitter;
+ exploreDatasources: DataSourceSelectItem[];
+ queries: DataQuery[];
+ range: RawTimeRange;
+ };
+}
+
+export interface InitializeExploreSplitAction {
+ type: ActionTypes.InitializeExploreSplit;
+}
+
+export interface LoadDatasourceFailureAction {
+ type: ActionTypes.LoadDatasourceFailure;
+ payload: {
+ exploreId: ExploreId;
+ error: string;
+ };
+}
+
+export interface LoadDatasourcePendingAction {
+ type: ActionTypes.LoadDatasourcePending;
+ payload: {
+ exploreId: ExploreId;
+ requestedDatasourceName: string;
+ };
+}
+
+export interface LoadDatasourceMissingAction {
+ type: ActionTypes.LoadDatasourceMissing;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface LoadDatasourceSuccessAction {
+ type: ActionTypes.LoadDatasourceSuccess;
+ payload: {
+ exploreId: ExploreId;
+ StartPage?: any;
+ datasourceInstance: any;
+ history: HistoryItem[];
+ initialQueries: DataQuery[];
+ logsHighlighterExpressions?: any[];
+ showingStartPage: boolean;
+ supportsGraph: boolean;
+ supportsLogs: boolean;
+ supportsTable: boolean;
+ };
+}
+
+export interface ModifyQueriesAction {
+ type: ActionTypes.ModifyQueries;
+ payload: {
+ exploreId: ExploreId;
+ modification: any;
+ index: number;
+ modifier: (queries: DataQuery[], modification: any) => DataQuery[];
+ };
+}
+
+export interface QueryTransactionFailureAction {
+ type: ActionTypes.QueryTransactionFailure;
+ payload: {
+ exploreId: ExploreId;
+ queryTransactions: QueryTransaction[];
+ };
+}
+
+export interface QueryTransactionStartAction {
+ type: ActionTypes.QueryTransactionStart;
+ payload: {
+ exploreId: ExploreId;
+ resultType: ResultType;
+ rowIndex: number;
+ transaction: QueryTransaction;
+ };
+}
+
+export interface QueryTransactionSuccessAction {
+ type: ActionTypes.QueryTransactionSuccess;
+ payload: {
+ exploreId: ExploreId;
+ history: HistoryItem[];
+ queryTransactions: QueryTransaction[];
+ };
+}
+
+export interface RemoveQueryRowAction {
+ type: ActionTypes.RemoveQueryRow;
+ payload: {
+ exploreId: ExploreId;
+ index: number;
+ };
+}
+
+export interface RunQueriesEmptyAction {
+ type: ActionTypes.RunQueriesEmpty;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface ScanStartAction {
+ type: ActionTypes.ScanStart;
+ payload: {
+ exploreId: ExploreId;
+ scanner: RangeScanner;
+ };
+}
+
+export interface ScanRangeAction {
+ type: ActionTypes.ScanRange;
+ payload: {
+ exploreId: ExploreId;
+ range: RawTimeRange;
+ };
+}
+
+export interface ScanStopAction {
+ type: ActionTypes.ScanStop;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface SetQueriesAction {
+ type: ActionTypes.SetQueries;
+ payload: {
+ exploreId: ExploreId;
+ queries: DataQuery[];
+ };
+}
+
+export interface SplitCloseAction {
+ type: ActionTypes.SplitClose;
+}
+
+export interface SplitOpenAction {
+ type: ActionTypes.SplitOpen;
+ payload: {
+ itemState: ExploreItemState;
+ };
+}
+
+export interface StateSaveAction {
+ type: ActionTypes.StateSave;
+}
+
+export interface ToggleTableAction {
+ type: ActionTypes.ToggleTable;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface ToggleGraphAction {
+ type: ActionTypes.ToggleGraph;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface ToggleLogsAction {
+ type: ActionTypes.ToggleLogs;
+ payload: {
+ exploreId: ExploreId;
+ };
+}
+
+export interface UpdateDatasourceInstanceAction {
+ type: ActionTypes.UpdateDatasourceInstance;
+ payload: {
+ exploreId: ExploreId;
+ datasourceInstance: DataSourceApi;
+ };
+}
+
+export interface ResetExploreAction {
+ type: ActionTypes.ResetExplore;
+ payload: {};
+}
+
+export type Action =
+ | AddQueryRowAction
+ | ChangeQueryAction
+ | ChangeSizeAction
+ | ChangeTimeAction
+ | ClearQueriesAction
+ | HighlightLogsExpressionAction
+ | InitializeExploreAction
+ | InitializeExploreSplitAction
+ | LoadDatasourceFailureAction
+ | LoadDatasourceMissingAction
+ | LoadDatasourcePendingAction
+ | LoadDatasourceSuccessAction
+ | ModifyQueriesAction
+ | QueryTransactionFailureAction
+ | QueryTransactionStartAction
+ | QueryTransactionSuccessAction
+ | RemoveQueryRowAction
+ | RunQueriesEmptyAction
+ | ScanRangeAction
+ | ScanStartAction
+ | ScanStopAction
+ | SetQueriesAction
+ | SplitCloseAction
+ | SplitOpenAction
+ | ToggleGraphAction
+ | ToggleLogsAction
+ | ToggleTableAction
+ | UpdateDatasourceInstanceAction
+ | ResetExploreAction;
diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts
new file mode 100644
index 00000000000..40a8f367672
--- /dev/null
+++ b/public/app/features/explore/state/actions.ts
@@ -0,0 +1,801 @@
+// Libraries
+import _ from 'lodash';
+import { ThunkAction } from 'redux-thunk';
+
+// Services & Utils
+import store from 'app/core/store';
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { Emitter } from 'app/core/core';
+import {
+ LAST_USED_DATASOURCE_KEY,
+ clearQueryKeys,
+ ensureQueries,
+ generateEmptyQuery,
+ hasNonEmptyQuery,
+ makeTimeSeriesList,
+ updateHistory,
+ buildQueryTransaction,
+ serializeStateToUrlParam,
+} from 'app/core/utils/explore';
+
+// Actions
+import { updateLocation } from 'app/core/actions';
+
+// Types
+import { StoreState } from 'app/types';
+import {
+ RawTimeRange,
+ TimeRange,
+ DataSourceApi,
+ DataQuery,
+ DataSourceSelectItem,
+ QueryHint,
+} from '@grafana/ui/src/types';
+import {
+ ExploreId,
+ ExploreUrlState,
+ RangeScanner,
+ ResultType,
+ QueryOptions,
+ QueryTransaction,
+} from 'app/types/explore';
+
+import {
+ Action as ThunkableAction,
+ ActionTypes,
+ AddQueryRowAction,
+ ChangeSizeAction,
+ HighlightLogsExpressionAction,
+ LoadDatasourceFailureAction,
+ LoadDatasourceMissingAction,
+ LoadDatasourcePendingAction,
+ LoadDatasourceSuccessAction,
+ QueryTransactionStartAction,
+ ScanStopAction,
+ UpdateDatasourceInstanceAction,
+} from './actionTypes';
+
+type ThunkResult
= ThunkAction;
+
+/**
+ * Adds a query row after the row with the given index.
+ */
+export function addQueryRow(exploreId: ExploreId, index: number): AddQueryRowAction {
+ const query = generateEmptyQuery(index + 1);
+ return { type: ActionTypes.AddQueryRow, payload: { exploreId, index, query } };
+}
+
+/**
+ * Loads a new datasource identified by the given name.
+ */
+export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult {
+ return async dispatch => {
+ const instance = await getDatasourceSrv().get(datasource);
+ dispatch(updateDatasourceInstance(exploreId, instance));
+ dispatch(loadDatasource(exploreId, instance));
+ };
+}
+
+/**
+ * Query change handler for the query row with the given index.
+ * If `override` is reset the query modifications and run the queries. Use this to set queries via a link.
+ */
+export function changeQuery(
+ exploreId: ExploreId,
+ query: DataQuery,
+ index: number,
+ override: boolean
+): ThunkResult {
+ return dispatch => {
+ // Null query means reset
+ if (query === null) {
+ query = { ...generateEmptyQuery(index) };
+ }
+
+ dispatch({ type: ActionTypes.ChangeQuery, payload: { exploreId, query, index, override } });
+ if (override) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Keep track of the Explore container size, in particular the width.
+ * The width will be used to calculate graph intervals (number of datapoints).
+ */
+export function changeSize(
+ exploreId: ExploreId,
+ { height, width }: { height: number; width: number }
+): ChangeSizeAction {
+ return { type: ActionTypes.ChangeSize, payload: { exploreId, height, width } };
+}
+
+/**
+ * Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
+ */
+export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.ChangeTime, payload: { exploreId, range } });
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Clear all queries and results.
+ */
+export function clearQueries(exploreId: ExploreId): ThunkResult {
+ return dispatch => {
+ dispatch(scanStop(exploreId));
+ dispatch({ type: ActionTypes.ClearQueries, payload: { exploreId } });
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Highlight expressions in the log results
+ */
+export function highlightLogsExpression(exploreId: ExploreId, expressions: string[]): HighlightLogsExpressionAction {
+ return { type: ActionTypes.HighlightLogsExpression, payload: { exploreId, expressions } };
+}
+
+/**
+ * Initialize Explore state with state from the URL and the React component.
+ * Call this only on components for with the Explore state has not been initialized.
+ */
+export function initializeExplore(
+ exploreId: ExploreId,
+ datasourceName: string,
+ queries: DataQuery[],
+ range: RawTimeRange,
+ containerWidth: number,
+ eventBridge: Emitter
+): ThunkResult {
+ return async dispatch => {
+ const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
+ .getExternal()
+ .map(ds => ({
+ value: ds.name,
+ name: ds.name,
+ meta: ds.meta,
+ }));
+
+ dispatch({
+ type: ActionTypes.InitializeExplore,
+ payload: {
+ exploreId,
+ containerWidth,
+ datasourceName,
+ eventBridge,
+ exploreDatasources,
+ queries,
+ range,
+ },
+ });
+
+ if (exploreDatasources.length >= 1) {
+ let instance;
+ if (datasourceName) {
+ try {
+ instance = await getDatasourceSrv().get(datasourceName);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ // Checking on instance here because requested datasource could be deleted already
+ if (!instance) {
+ instance = await getDatasourceSrv().get();
+ }
+ dispatch(updateDatasourceInstance(exploreId, instance));
+ dispatch(loadDatasource(exploreId, instance));
+ } else {
+ dispatch(loadDatasourceMissing(exploreId));
+ }
+ };
+}
+
+/**
+ * Initialize the wrapper split state
+ */
+export function initializeExploreSplit() {
+ return async dispatch => {
+ dispatch({ type: ActionTypes.InitializeExploreSplit });
+ };
+}
+
+/**
+ * Display an error that happened during the selection of a datasource
+ */
+export const loadDatasourceFailure = (exploreId: ExploreId, error: string): LoadDatasourceFailureAction => ({
+ type: ActionTypes.LoadDatasourceFailure,
+ payload: {
+ exploreId,
+ error,
+ },
+});
+
+/**
+ * Display an error when no datasources have been configured
+ */
+export const loadDatasourceMissing = (exploreId: ExploreId): LoadDatasourceMissingAction => ({
+ type: ActionTypes.LoadDatasourceMissing,
+ payload: { exploreId },
+});
+
+/**
+ * Start the async process of loading a datasource to display a loading indicator
+ */
+export const loadDatasourcePending = (exploreId: ExploreId, requestedDatasourceName: string): LoadDatasourcePendingAction => ({
+ type: ActionTypes.LoadDatasourcePending,
+ payload: {
+ exploreId,
+ requestedDatasourceName,
+ },
+});
+
+/**
+ * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
+ * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
+ * e.g., Prometheus -> Loki queries.
+ */
+export const loadDatasourceSuccess = (
+ exploreId: ExploreId,
+ instance: any,
+ queries: DataQuery[]
+): LoadDatasourceSuccessAction => {
+ // Capabilities
+ const supportsGraph = instance.meta.metrics;
+ const supportsLogs = instance.meta.logs;
+ const supportsTable = instance.meta.tables;
+ // Custom components
+ const StartPage = instance.pluginExports.ExploreStartPage;
+
+ const historyKey = `grafana.explore.history.${instance.meta.id}`;
+ const history = store.getObject(historyKey, []);
+ // Save last-used datasource
+ store.set(LAST_USED_DATASOURCE_KEY, instance.name);
+
+ return {
+ type: ActionTypes.LoadDatasourceSuccess,
+ payload: {
+ exploreId,
+ StartPage,
+ datasourceInstance: instance,
+ history,
+ initialQueries: queries,
+ showingStartPage: Boolean(StartPage),
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ },
+ };
+};
+
+/**
+ * Updates datasource instance before datasouce loading has started
+ */
+export function updateDatasourceInstance(
+ exploreId: ExploreId,
+ instance: DataSourceApi
+): UpdateDatasourceInstanceAction {
+ return {
+ type: ActionTypes.UpdateDatasourceInstance,
+ payload: {
+ exploreId,
+ datasourceInstance: instance,
+ },
+ };
+}
+
+/**
+ * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
+ */
+export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): ThunkResult {
+ return async (dispatch, getState) => {
+ const datasourceName = instance.name;
+
+ // Keep ID to track selection
+ dispatch(loadDatasourcePending(exploreId, datasourceName));
+
+ let datasourceError = null;
+ try {
+ const testResult = await instance.testDatasource();
+ datasourceError = testResult.status === 'success' ? null : testResult.message;
+ } catch (error) {
+ datasourceError = (error && error.statusText) || 'Network error';
+ }
+
+ if (datasourceError) {
+ dispatch(loadDatasourceFailure(exploreId, datasourceError));
+ return;
+ }
+
+ if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
+ // User already changed datasource again, discard results
+ return;
+ }
+
+ if (instance.init) {
+ instance.init();
+ }
+
+ // Check if queries can be imported from previously selected datasource
+ const queries = getState().explore[exploreId].modifiedQueries;
+ let importedQueries = queries;
+ const origin = getState().explore[exploreId].datasourceInstance;
+ if (origin) {
+ if (origin.meta.id === instance.meta.id) {
+ // Keep same queries if same type of datasource
+ importedQueries = [...queries];
+ } else if (instance.importQueries) {
+ // Datasource-specific importers
+ importedQueries = await instance.importQueries(queries, origin.meta);
+ } else {
+ // Default is blank queries
+ importedQueries = ensureQueries();
+ }
+ }
+
+ if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
+ // User already changed datasource again, discard results
+ return;
+ }
+
+ // Reset edit state with new queries
+ const nextQueries = importedQueries.map((q, i) => ({
+ ...importedQueries[i],
+ ...generateEmptyQuery(i),
+ }));
+
+ dispatch(loadDatasourceSuccess(exploreId, instance, nextQueries));
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Action to modify a query given a datasource-specific modifier action.
+ * @param exploreId Explore area
+ * @param modification Action object with a type, e.g., ADD_FILTER
+ * @param index Optional query row index. If omitted, the modification is applied to all query rows.
+ * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`.
+ */
+export function modifyQueries(
+ exploreId: ExploreId,
+ modification: any,
+ index: number,
+ modifier: any
+): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.ModifyQueries, payload: { exploreId, modification, index, modifier } });
+ if (!modification.preventSubmit) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Mark a query transaction as failed with an error extracted from the query response.
+ * The transaction will be marked as `done`.
+ */
+export function queryTransactionFailure(
+ exploreId: ExploreId,
+ transactionId: string,
+ response: any,
+ datasourceId: string
+): ThunkResult {
+ return (dispatch, getState) => {
+ const { datasourceInstance, queryTransactions } = getState().explore[exploreId];
+ if (datasourceInstance.meta.id !== datasourceId || response.cancelled) {
+ // Navigated away, queries did not matter
+ return;
+ }
+
+ // Transaction might have been discarded
+ if (!queryTransactions.find(qt => qt.id === transactionId)) {
+ return;
+ }
+
+ console.error(response);
+
+ let error: string;
+ let errorDetails: string;
+ if (response.data) {
+ if (typeof response.data === 'string') {
+ error = response.data;
+ } else if (response.data.error) {
+ error = response.data.error;
+ if (response.data.response) {
+ errorDetails = 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.';
+ }
+
+ // Mark transactions as complete
+ const nextQueryTransactions = queryTransactions.map(qt => {
+ if (qt.id === transactionId) {
+ return {
+ ...qt,
+ error,
+ errorDetails,
+ done: true,
+ };
+ }
+ return qt;
+ });
+
+ dispatch({
+ type: ActionTypes.QueryTransactionFailure,
+ payload: { exploreId, queryTransactions: nextQueryTransactions },
+ });
+ };
+}
+
+/**
+ * Start a query transaction for the given result type.
+ * @param exploreId Explore area
+ * @param transaction Query options and `done` status.
+ * @param resultType Associate the transaction with a result viewer, e.g., Graph
+ * @param rowIndex Index is used to associate latency for this transaction with a query row
+ */
+export function queryTransactionStart(
+ exploreId: ExploreId,
+ transaction: QueryTransaction,
+ resultType: ResultType,
+ rowIndex: number
+): QueryTransactionStartAction {
+ return { type: ActionTypes.QueryTransactionStart, payload: { exploreId, resultType, rowIndex, transaction } };
+}
+
+/**
+ * Complete a query transaction, mark the transaction as `done` and store query state in URL.
+ * If the transaction was started by a scanner, it keeps on scanning for more results.
+ * Side-effect: the query is stored in localStorage.
+ * @param exploreId Explore area
+ * @param transactionId ID
+ * @param result Response from `datasourceInstance.query()`
+ * @param latency Duration between request and response
+ * @param queries Queries from all query rows
+ * @param datasourceId Origin datasource instance, used to discard results if current datasource is different
+ */
+export function queryTransactionSuccess(
+ exploreId: ExploreId,
+ transactionId: string,
+ result: any,
+ latency: number,
+ queries: DataQuery[],
+ datasourceId: string
+): ThunkResult {
+ return (dispatch, getState) => {
+ const { datasourceInstance, history, queryTransactions, scanner, scanning } = getState().explore[exploreId];
+
+ // If datasource already changed, results do not matter
+ if (datasourceInstance.meta.id !== datasourceId) {
+ return;
+ }
+
+ // Transaction might have been discarded
+ const transaction = queryTransactions.find(qt => qt.id === transactionId);
+ if (!transaction) {
+ return;
+ }
+
+ // Get query hints
+ let hints: QueryHint[];
+ if (datasourceInstance.getQueryHints) {
+ hints = datasourceInstance.getQueryHints(transaction.query, result);
+ }
+
+ // Mark transactions as complete and attach result
+ const nextQueryTransactions = queryTransactions.map(qt => {
+ if (qt.id === transactionId) {
+ return {
+ ...qt,
+ hints,
+ latency,
+ result,
+ done: true,
+ };
+ }
+ return qt;
+ });
+
+ // Side-effect: Saving history in localstorage
+ const nextHistory = updateHistory(history, datasourceId, queries);
+
+ dispatch({
+ type: ActionTypes.QueryTransactionSuccess,
+ payload: {
+ exploreId,
+ history: nextHistory,
+ queryTransactions: nextQueryTransactions,
+ },
+ });
+
+ // 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) {
+ const range = scanner();
+ dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+ }
+ } else {
+ // We can stop scanning if we have a result
+ dispatch(scanStop(exploreId));
+ }
+ }
+ };
+}
+
+/**
+ * Remove query row of the given index, as well as associated query results.
+ */
+export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.RemoveQueryRow, payload: { exploreId, index } });
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Main action to run queries and dispatches sub-actions based on which result viewers are active
+ */
+export function runQueries(exploreId: ExploreId) {
+ return (dispatch, getState) => {
+ const {
+ datasourceInstance,
+ modifiedQueries,
+ showingLogs,
+ showingGraph,
+ showingTable,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ } = getState().explore[exploreId];
+
+ if (!hasNonEmptyQuery(modifiedQueries)) {
+ dispatch({ type: ActionTypes.RunQueriesEmpty, payload: { exploreId } });
+ dispatch(stateSave()); // Remember to saves to state and update location
+ return;
+ }
+
+ // Some datasource's query builders allow per-query interval limits,
+ // but we're using the datasource interval limit for now
+ const interval = datasourceInstance.interval;
+
+ // Keep table queries first since they need to return quickly
+ if (showingTable && supportsTable) {
+ dispatch(
+ runQueriesForType(
+ exploreId,
+ 'Table',
+ {
+ interval,
+ format: 'table',
+ instant: true,
+ valueWithRefId: true,
+ },
+ data => data[0]
+ )
+ );
+ }
+ if (showingGraph && supportsGraph) {
+ dispatch(
+ runQueriesForType(
+ exploreId,
+ 'Graph',
+ {
+ interval,
+ format: 'time_series',
+ instant: false,
+ },
+ makeTimeSeriesList
+ )
+ );
+ }
+ if (showingLogs && supportsLogs) {
+ dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
+ }
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Helper action to build a query transaction object and handing the query to the datasource.
+ * @param exploreId Explore area
+ * @param resultType Result viewer that will be associated with this query result
+ * @param queryOptions Query options as required by the datasource's `query()` function.
+ * @param resultGetter Optional result extractor, e.g., if the result is a list and you only need the first element.
+ */
+function runQueriesForType(
+ exploreId: ExploreId,
+ resultType: ResultType,
+ queryOptions: QueryOptions,
+ resultGetter?: any
+) {
+ return async (dispatch, getState) => {
+ const {
+ datasourceInstance,
+ eventBridge,
+ modifiedQueries: queries,
+ queryIntervals,
+ range,
+ scanning,
+ } = getState().explore[exploreId];
+ const datasourceId = datasourceInstance.meta.id;
+
+ // Run all queries concurrently
+ queries.forEach(async (query, rowIndex) => {
+ const transaction = buildQueryTransaction(
+ query,
+ rowIndex,
+ resultType,
+ queryOptions,
+ range,
+ queryIntervals,
+ scanning
+ );
+ dispatch(queryTransactionStart(exploreId, transaction, resultType, rowIndex));
+ try {
+ const now = Date.now();
+ const res = await datasourceInstance.query(transaction.options);
+ eventBridge.emit('data-received', res.data || []);
+ const latency = Date.now() - now;
+ const results = resultGetter ? resultGetter(res.data) : res.data;
+ dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId));
+ } catch (response) {
+ eventBridge.emit('data-error', response);
+ dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId));
+ }
+ });
+ };
+}
+
+/**
+ * Start a scan for more results using the given scanner.
+ * @param exploreId Explore area
+ * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range
+ */
+export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult {
+ return dispatch => {
+ // Register the scanner
+ dispatch({ type: ActionTypes.ScanStart, payload: { exploreId, scanner } });
+ // Scanning must trigger query run, and return the new range
+ const range = scanner();
+ // Set the new range to be displayed
+ dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } });
+ };
+}
+
+/**
+ * Stop any scanning for more results.
+ */
+export function scanStop(exploreId: ExploreId): ScanStopAction {
+ return { type: ActionTypes.ScanStop, payload: { exploreId } };
+}
+
+/**
+ * Reset queries to the given queries. Any modifications will be discarded.
+ * Use this action for clicks on query examples. Triggers a query run.
+ */
+export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult {
+ return dispatch => {
+ // Inject react keys into query objects
+ const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() }));
+ dispatch({
+ type: ActionTypes.SetQueries,
+ payload: {
+ exploreId,
+ queries,
+ },
+ });
+ dispatch(runQueries(exploreId));
+ };
+}
+
+/**
+ * Close the split view and save URL state.
+ */
+export function splitClose(): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.SplitClose });
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Open the split view and copy the left state to be the right state.
+ * The right state is automatically initialized.
+ * The copy keeps all query modifications but wipes the query results.
+ */
+export function splitOpen(): ThunkResult {
+ return (dispatch, getState) => {
+ // Clone left state to become the right state
+ const leftState = getState().explore.left;
+ const itemState = {
+ ...leftState,
+ queryTransactions: [],
+ initialQueries: leftState.modifiedQueries.slice(),
+ };
+ dispatch({ type: ActionTypes.SplitOpen, payload: { itemState } });
+ dispatch(stateSave());
+ };
+}
+
+/**
+ * Saves Explore state to URL using the `left` and `right` parameters.
+ * If split view is not active, `right` will not be set.
+ */
+export function stateSave() {
+ return (dispatch, getState) => {
+ const { left, right, split } = getState().explore;
+ const urlStates: { [index: string]: string } = {};
+ const leftUrlState: ExploreUrlState = {
+ datasource: left.datasourceInstance.name,
+ queries: left.modifiedQueries.map(clearQueryKeys),
+ range: left.range,
+ };
+ urlStates.left = serializeStateToUrlParam(leftUrlState, true);
+ if (split) {
+ const rightUrlState: ExploreUrlState = {
+ datasource: right.datasourceInstance.name,
+ queries: right.modifiedQueries.map(clearQueryKeys),
+ range: right.range,
+ };
+ urlStates.right = serializeStateToUrlParam(rightUrlState, true);
+ }
+ dispatch(updateLocation({ query: urlStates }));
+ };
+}
+
+/**
+ * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
+ */
+export function toggleGraph(exploreId: ExploreId): ThunkResult {
+ return (dispatch, getState) => {
+ dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } });
+ if (getState().explore[exploreId].showingGraph) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
+ */
+export function toggleLogs(exploreId: ExploreId): ThunkResult {
+ return (dispatch, getState) => {
+ dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } });
+ if (getState().explore[exploreId].showingLogs) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Expand/collapse the table result viewer. When collapsed, table queries won't be run.
+ */
+export function toggleTable(exploreId: ExploreId): ThunkResult {
+ return (dispatch, getState) => {
+ dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } });
+ if (getState().explore[exploreId].showingTable) {
+ dispatch(runQueries(exploreId));
+ }
+ };
+}
+
+/**
+ * Resets state for explore.
+ */
+export function resetExplore(): ThunkResult {
+ return dispatch => {
+ dispatch({ type: ActionTypes.ResetExplore, payload: {} });
+ };
+}
diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts
new file mode 100644
index 00000000000..8227a947c5b
--- /dev/null
+++ b/public/app/features/explore/state/reducers.test.ts
@@ -0,0 +1,42 @@
+import { Action, ActionTypes } from './actionTypes';
+import { itemReducer, makeExploreItemState } from './reducers';
+import { ExploreId } from 'app/types/explore';
+
+describe('Explore item reducer', () => {
+ describe('scanning', () => {
+ test('should start scanning', () => {
+ let state = makeExploreItemState();
+ const action: Action = {
+ type: ActionTypes.ScanStart,
+ payload: {
+ exploreId: ExploreId.left,
+ scanner: jest.fn(),
+ },
+ };
+ state = itemReducer(state, action);
+ expect(state.scanning).toBeTruthy();
+ expect(state.scanner).toBe(action.payload.scanner);
+ });
+ test('should stop scanning', () => {
+ let state = makeExploreItemState();
+ const start: Action = {
+ type: ActionTypes.ScanStart,
+ payload: {
+ exploreId: ExploreId.left,
+ scanner: jest.fn(),
+ },
+ };
+ state = itemReducer(state, start);
+ expect(state.scanning).toBeTruthy();
+ const action: Action = {
+ type: ActionTypes.ScanStop,
+ payload: {
+ exploreId: ExploreId.left,
+ },
+ };
+ state = itemReducer(state, action);
+ expect(state.scanning).toBeFalsy();
+ expect(state.scanner).toBeUndefined();
+ });
+ });
+});
diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts
new file mode 100644
index 00000000000..ad5ef8b5a71
--- /dev/null
+++ b/public/app/features/explore/state/reducers.ts
@@ -0,0 +1,464 @@
+import {
+ calculateResultsFromQueryTransactions,
+ generateEmptyQuery,
+ getIntervals,
+ ensureQueries,
+} from 'app/core/utils/explore';
+import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore';
+import { DataQuery } from '@grafana/ui/src/types';
+
+import { Action, ActionTypes } from './actionTypes';
+
+export const DEFAULT_RANGE = {
+ from: 'now-6h',
+ to: 'now',
+};
+
+// Millies step for helper bar charts
+const DEFAULT_GRAPH_INTERVAL = 15 * 1000;
+
+/**
+ * Returns a fresh Explore area state
+ */
+export const makeExploreItemState = (): ExploreItemState => ({
+ StartPage: undefined,
+ containerWidth: 0,
+ datasourceInstance: null,
+ requestedDatasourceName: null,
+ datasourceError: null,
+ datasourceLoading: null,
+ datasourceMissing: false,
+ exploreDatasources: [],
+ history: [],
+ initialQueries: [],
+ initialized: false,
+ modifiedQueries: [],
+ queryTransactions: [],
+ queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL },
+ range: DEFAULT_RANGE,
+ scanning: false,
+ scanRange: null,
+ showingGraph: true,
+ showingLogs: true,
+ showingTable: true,
+ supportsGraph: null,
+ supportsLogs: null,
+ supportsTable: null,
+});
+
+/**
+ * Global Explore state that handles multiple Explore areas and the split state
+ */
+export const initialExploreState: ExploreState = {
+ split: null,
+ left: makeExploreItemState(),
+ right: makeExploreItemState(),
+};
+
+/**
+ * Reducer for an Explore area, to be used by the global Explore reducer.
+ */
+export const itemReducer = (state, action: Action): ExploreItemState => {
+ switch (action.type) {
+ case ActionTypes.AddQueryRow: {
+ const { initialQueries, modifiedQueries, queryTransactions } = state;
+ const { index, query } = action.payload;
+
+ // Add new query row after given index, keep modifications of existing rows
+ const nextModifiedQueries = [
+ ...modifiedQueries.slice(0, index + 1),
+ { ...query },
+ ...initialQueries.slice(index + 1),
+ ];
+
+ // Add to initialQueries, which will cause a new row to be rendered
+ const nextQueries = [...initialQueries.slice(0, index + 1), { ...query }, ...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 {
+ ...state,
+ initialQueries: nextQueries,
+ logsHighlighterExpressions: undefined,
+ modifiedQueries: nextModifiedQueries,
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.ChangeQuery: {
+ const { initialQueries, queryTransactions } = state;
+ let { modifiedQueries } = state;
+ const { query, index, override } = action.payload;
+
+ // Fast path: only change modifiedQueries to not trigger an update
+ modifiedQueries[index] = query;
+ if (!override) {
+ return {
+ ...state,
+ modifiedQueries,
+ };
+ }
+
+ // Override path: queries are completely reset
+ const nextQuery: DataQuery = {
+ ...query,
+ ...generateEmptyQuery(index),
+ };
+ const nextQueries = [...initialQueries];
+ nextQueries[index] = nextQuery;
+ modifiedQueries = [...nextQueries];
+
+ // Discard ongoing transaction related to row query
+ const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+ return {
+ ...state,
+ initialQueries: nextQueries,
+ modifiedQueries: nextQueries.slice(),
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.ChangeSize: {
+ const { range, datasourceInstance } = state;
+ let interval = '1s';
+ if (datasourceInstance && datasourceInstance.interval) {
+ interval = datasourceInstance.interval;
+ }
+ const containerWidth = action.payload.width;
+ const queryIntervals = getIntervals(range, interval, containerWidth);
+ return { ...state, containerWidth, queryIntervals };
+ }
+
+ case ActionTypes.ChangeTime: {
+ return {
+ ...state,
+ range: action.payload.range,
+ };
+ }
+
+ case ActionTypes.ClearQueries: {
+ const queries = ensureQueries();
+ return {
+ ...state,
+ initialQueries: queries.slice(),
+ modifiedQueries: queries.slice(),
+ queryTransactions: [],
+ showingStartPage: Boolean(state.StartPage),
+ };
+ }
+
+ case ActionTypes.HighlightLogsExpression: {
+ const { expressions } = action.payload;
+ return { ...state, logsHighlighterExpressions: expressions };
+ }
+
+ case ActionTypes.InitializeExplore: {
+ const { containerWidth, eventBridge, exploreDatasources, queries, range } = action.payload;
+ return {
+ ...state,
+ containerWidth,
+ eventBridge,
+ exploreDatasources,
+ range,
+ initialQueries: queries,
+ initialized: true,
+ modifiedQueries: queries.slice(),
+ };
+ }
+
+ case ActionTypes.UpdateDatasourceInstance: {
+ const { datasourceInstance } = action.payload;
+ return {
+ ...state,
+ datasourceInstance,
+ datasourceName: datasourceInstance.name,
+ };
+ }
+
+ case ActionTypes.LoadDatasourceFailure: {
+ return { ...state, datasourceError: action.payload.error, datasourceLoading: false };
+ }
+
+ case ActionTypes.LoadDatasourceMissing: {
+ return { ...state, datasourceMissing: true, datasourceLoading: false };
+ }
+
+ case ActionTypes.LoadDatasourcePending: {
+ return { ...state, datasourceLoading: true, requestedDatasourceName: action.payload.requestedDatasourceName };
+ }
+
+ case ActionTypes.LoadDatasourceSuccess: {
+ const { containerWidth, range } = state;
+ const {
+ StartPage,
+ datasourceInstance,
+ history,
+ initialQueries,
+ showingStartPage,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ } = action.payload;
+ const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth);
+
+ return {
+ ...state,
+ queryIntervals,
+ StartPage,
+ datasourceInstance,
+ history,
+ initialQueries,
+ showingStartPage,
+ supportsGraph,
+ supportsLogs,
+ supportsTable,
+ datasourceLoading: false,
+ datasourceMissing: false,
+ datasourceError: null,
+ logsHighlighterExpressions: undefined,
+ modifiedQueries: initialQueries.slice(),
+ queryTransactions: [],
+ };
+ }
+
+ case ActionTypes.ModifyQueries: {
+ const { initialQueries, modifiedQueries, queryTransactions } = state;
+ const { modification, index, modifier } = action.payload as any;
+ let nextQueries: DataQuery[];
+ let nextQueryTransactions;
+ if (index === undefined) {
+ // Modify all queries
+ nextQueries = initialQueries.map((query, i) => ({
+ ...modifier(modifiedQueries[i], modification),
+ ...generateEmptyQuery(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
+ ? {
+ ...modifier(modifiedQueries[i], modification),
+ ...generateEmptyQuery(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 !== modification);
+ }
+ return qt;
+ })
+ // Preserve previous row query transaction to keep results visible if next query is incomplete
+ .filter(qt => modification.preventSubmit || qt.rowIndex !== index);
+ }
+ return {
+ ...state,
+ initialQueries: nextQueries,
+ modifiedQueries: nextQueries.slice(),
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.QueryTransactionFailure: {
+ const { queryTransactions } = action.payload;
+ return {
+ ...state,
+ queryTransactions,
+ showingStartPage: false,
+ };
+ }
+
+ case ActionTypes.QueryTransactionStart: {
+ const { queryTransactions } = state;
+ const { resultType, rowIndex, transaction } = action.payload;
+ // Discarding existing transactions of same type
+ const remainingTransactions = queryTransactions.filter(
+ qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex)
+ );
+
+ // Append new transaction
+ const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction];
+
+
+ return {
+ ...state,
+ queryTransactions: nextQueryTransactions,
+ showingStartPage: false,
+ };
+ }
+
+ case ActionTypes.QueryTransactionSuccess: {
+ const { datasourceInstance, queryIntervals } = state;
+ const { history, queryTransactions } = action.payload;
+ const results = calculateResultsFromQueryTransactions(
+ queryTransactions,
+ datasourceInstance,
+ queryIntervals.intervalMs
+ );
+
+ return {
+ ...state,
+ ...results,
+ history,
+ queryTransactions,
+ showingStartPage: false,
+ };
+ }
+
+ case ActionTypes.RemoveQueryRow: {
+ const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state;
+ let { modifiedQueries } = state;
+ const { index } = action.payload;
+
+ modifiedQueries = [...modifiedQueries.slice(0, index), ...modifiedQueries.slice(index + 1)];
+
+ if (initialQueries.length <= 1) {
+ return 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,
+ datasourceInstance,
+ queryIntervals.intervalMs
+ );
+
+ return {
+ ...state,
+ ...results,
+ initialQueries: nextQueries,
+ logsHighlighterExpressions: undefined,
+ modifiedQueries: nextQueries.slice(),
+ queryTransactions: nextQueryTransactions,
+ };
+ }
+
+ case ActionTypes.RunQueriesEmpty: {
+ return { ...state, queryTransactions: [] };
+ }
+
+ case ActionTypes.ScanRange: {
+ return { ...state, scanRange: action.payload.range };
+ }
+
+ case ActionTypes.ScanStart: {
+ return { ...state, scanning: true, scanner: action.payload.scanner };
+ }
+
+ case ActionTypes.ScanStop: {
+ const { queryTransactions } = state;
+ const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
+ return {
+ ...state,
+ queryTransactions: nextQueryTransactions,
+ scanning: false,
+ scanRange: undefined,
+ scanner: undefined,
+ };
+ }
+
+ case ActionTypes.SetQueries: {
+ const { queries } = action.payload;
+ return { ...state, initialQueries: queries.slice(), modifiedQueries: queries.slice() };
+ }
+
+ case ActionTypes.ToggleGraph: {
+ 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 { ...state, queryTransactions: nextQueryTransactions, showingGraph };
+ }
+
+ case ActionTypes.ToggleLogs: {
+ 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 { ...state, queryTransactions: nextQueryTransactions, showingLogs };
+ }
+
+ case ActionTypes.ToggleTable: {
+ const showingTable = !state.showingTable;
+ if (showingTable) {
+ return { ...state, showingTable, queryTransactions: state.queryTransactions };
+ }
+
+ // Toggle off needs discarding of table queries and results
+ const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
+ const results = calculateResultsFromQueryTransactions(
+ nextQueryTransactions,
+ state.datasourceInstance,
+ state.queryIntervals.intervalMs
+ );
+
+ return { ...state, ...results, queryTransactions: nextQueryTransactions, showingTable };
+ }
+ }
+
+ return state;
+};
+
+/**
+ * Global Explore reducer that handles multiple Explore areas (left and right).
+ * Actions that have an `exploreId` get routed to the ExploreItemReducer.
+ */
+export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => {
+ switch (action.type) {
+ case ActionTypes.SplitClose: {
+ return { ...state, split: false };
+ }
+
+ case ActionTypes.SplitOpen: {
+ return { ...state, split: true, right: action.payload.itemState };
+ }
+
+ case ActionTypes.InitializeExploreSplit: {
+ return { ...state, split: true };
+ }
+
+ case ActionTypes.ResetExplore: {
+ return initialExploreState;
+ }
+ }
+
+ if (action.payload) {
+ const { exploreId } = action.payload as any;
+ if (exploreId !== undefined) {
+ const exploreItemState = state[exploreId];
+ return {
+ ...state,
+ [exploreId]: itemReducer(exploreItemState, action),
+ };
+ }
+ }
+
+ return state;
+};
+
+export default {
+ explore: exploreReducer,
+};
diff --git a/public/app/features/dashboard/create_folder_ctrl.ts b/public/app/features/folders/CreateFolderCtrl.ts
similarity index 96%
rename from public/app/features/dashboard/create_folder_ctrl.ts
rename to public/app/features/folders/CreateFolderCtrl.ts
index 99b2e8d4853..db70c2a18a2 100644
--- a/public/app/features/dashboard/create_folder_ctrl.ts
+++ b/public/app/features/folders/CreateFolderCtrl.ts
@@ -1,7 +1,7 @@
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
-export class CreateFolderCtrl {
+export default class CreateFolderCtrl {
title = '';
navModel: any;
titleTouched = false;
@@ -38,3 +38,4 @@ export class CreateFolderCtrl {
});
}
}
+
diff --git a/public/app/features/dashboard/folder_dashboards_ctrl.ts b/public/app/features/folders/FolderDashboardsCtrl.ts
similarity index 84%
rename from public/app/features/dashboard/folder_dashboards_ctrl.ts
rename to public/app/features/folders/FolderDashboardsCtrl.ts
index 05cc420c489..95ff355937b 100644
--- a/public/app/features/dashboard/folder_dashboards_ctrl.ts
+++ b/public/app/features/folders/FolderDashboardsCtrl.ts
@@ -1,7 +1,7 @@
-import { FolderPageLoader } from './folder_page_loader';
+import { FolderPageLoader } from './services/FolderPageLoader';
import locationUtil from 'app/core/utils/location_util';
-export class FolderDashboardsCtrl {
+export default class FolderDashboardsCtrl {
navModel: any;
folderId: number;
uid: string;
@@ -23,3 +23,4 @@ export class FolderDashboardsCtrl {
}
}
}
+
diff --git a/public/app/features/folders/FolderPermissions.tsx b/public/app/features/folders/FolderPermissions.tsx
index 176e270038b..62a479b9124 100644
--- a/public/app/features/folders/FolderPermissions.tsx
+++ b/public/app/features/folders/FolderPermissions.tsx
@@ -2,7 +2,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 Tooltip from 'app/core/components/Tooltip/Tooltip';
+import { Tooltip } from '@grafana/ui';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { getNavModel } from 'app/core/selectors/navModel';
import { NavModel, StoreState, FolderState } from 'app/types';
@@ -84,8 +84,10 @@ export class FolderPermissions extends PureComponent {
Folder Permissions
-
-
+
+
+
+
diff --git a/public/app/features/dashboard/partials/create_folder.html b/public/app/features/folders/partials/create_folder.html
similarity index 100%
rename from public/app/features/dashboard/partials/create_folder.html
rename to public/app/features/folders/partials/create_folder.html
diff --git a/public/app/features/dashboard/partials/folder_dashboards.html b/public/app/features/folders/partials/folder_dashboards.html
similarity index 100%
rename from public/app/features/dashboard/partials/folder_dashboards.html
rename to public/app/features/folders/partials/folder_dashboards.html
diff --git a/public/app/features/dashboard/folder_page_loader.ts b/public/app/features/folders/services/FolderPageLoader.ts
similarity index 100%
rename from public/app/features/dashboard/folder_page_loader.ts
rename to public/app/features/folders/services/FolderPageLoader.ts
diff --git a/public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts b/public/app/features/manage-dashboards/DashboardImportCtrl.test.ts
similarity index 95%
rename from public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts
rename to public/app/features/manage-dashboards/DashboardImportCtrl.test.ts
index bcde009cb3a..c9037c0a62d 100644
--- a/public/app/features/dashboard/specs/dashboard_import_ctrl.test.ts
+++ b/public/app/features/manage-dashboards/DashboardImportCtrl.test.ts
@@ -1,5 +1,5 @@
-import { DashboardImportCtrl } from '../dashboard_import_ctrl';
-import config from '../../../core/config';
+import { DashboardImportCtrl } from './DashboardImportCtrl';
+import config from 'app/core/config';
describe('DashboardImportCtrl', () => {
const ctx: any = {};
diff --git a/public/app/features/dashboard/dashboard_import_ctrl.ts b/public/app/features/manage-dashboards/DashboardImportCtrl.ts
similarity index 99%
rename from public/app/features/dashboard/dashboard_import_ctrl.ts
rename to public/app/features/manage-dashboards/DashboardImportCtrl.ts
index 455fa682edd..d2c6584d13d 100644
--- a/public/app/features/dashboard/dashboard_import_ctrl.ts
+++ b/public/app/features/manage-dashboards/DashboardImportCtrl.ts
@@ -232,3 +232,5 @@ export class DashboardImportCtrl {
this.gnetInfo = '';
}
}
+
+export default DashboardImportCtrl;
diff --git a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts b/public/app/features/manage-dashboards/components/MoveToFolderModal/MoveToFolderCtrl.ts
similarity index 93%
rename from public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts
rename to public/app/features/manage-dashboards/components/MoveToFolderModal/MoveToFolderCtrl.ts
index 075583b971b..c183f38d92a 100644
--- a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts
+++ b/public/app/features/manage-dashboards/components/MoveToFolderModal/MoveToFolderCtrl.ts
@@ -46,7 +46,7 @@ export class MoveToFolderCtrl {
export function moveToFolderModal() {
return {
restrict: 'E',
- templateUrl: 'public/app/features/dashboard/move_to_folder_modal/move_to_folder.html',
+ templateUrl: 'public/app/features/manage-dashboards/components/MoveToFolderModal/template.html',
controller: MoveToFolderCtrl,
bindToController: true,
controllerAs: 'ctrl',
diff --git a/public/app/features/manage-dashboards/components/MoveToFolderModal/index.ts b/public/app/features/manage-dashboards/components/MoveToFolderModal/index.ts
new file mode 100644
index 00000000000..df0553aedb9
--- /dev/null
+++ b/public/app/features/manage-dashboards/components/MoveToFolderModal/index.ts
@@ -0,0 +1 @@
+export { MoveToFolderCtrl } from './MoveToFolderCtrl';
diff --git a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.html b/public/app/features/manage-dashboards/components/MoveToFolderModal/template.html
similarity index 100%
rename from public/app/features/dashboard/move_to_folder_modal/move_to_folder.html
rename to public/app/features/manage-dashboards/components/MoveToFolderModal/template.html
diff --git a/public/app/features/manage-dashboards/components/UploadDashboard/index.ts b/public/app/features/manage-dashboards/components/UploadDashboard/index.ts
new file mode 100644
index 00000000000..828b4f76982
--- /dev/null
+++ b/public/app/features/manage-dashboards/components/UploadDashboard/index.ts
@@ -0,0 +1 @@
+export { uploadDashboardDirective } from './uploadDashboardDirective';
diff --git a/public/app/features/dashboard/upload.ts b/public/app/features/manage-dashboards/components/UploadDashboard/uploadDashboardDirective.ts
similarity index 96%
rename from public/app/features/dashboard/upload.ts
rename to public/app/features/manage-dashboards/components/UploadDashboard/uploadDashboardDirective.ts
index ec4ad9a03cb..0c38a1247f1 100644
--- a/public/app/features/dashboard/upload.ts
+++ b/public/app/features/manage-dashboards/components/UploadDashboard/uploadDashboardDirective.ts
@@ -11,7 +11,7 @@ const template = `
`;
/** @ngInject */
-function uploadDashboardDirective(timer, $location) {
+export function uploadDashboardDirective(timer, $location) {
return {
restrict: 'E',
template: template,
diff --git a/public/app/features/manage-dashboards/index.ts b/public/app/features/manage-dashboards/index.ts
index 046740904e1..9d7c2bbc811 100644
--- a/public/app/features/manage-dashboards/index.ts
+++ b/public/app/features/manage-dashboards/index.ts
@@ -1,7 +1,15 @@
-import coreModule from 'app/core/core_module';
+// Services
+export { ValidationSrv } from './services/ValidationSrv';
+// Components
+export * from './components/MoveToFolderModal';
+export * from './components/UploadDashboard';
+
+// Controllers
import { DashboardListCtrl } from './DashboardListCtrl';
import { SnapshotListCtrl } from './SnapshotListCtrl';
+import coreModule from 'app/core/core_module';
+
coreModule.controller('DashboardListCtrl', DashboardListCtrl);
coreModule.controller('SnapshotListCtrl', SnapshotListCtrl);
diff --git a/public/app/features/dashboard/partials/dashboard_import.html b/public/app/features/manage-dashboards/partials/dashboard_import.html
similarity index 100%
rename from public/app/features/dashboard/partials/dashboard_import.html
rename to public/app/features/manage-dashboards/partials/dashboard_import.html
diff --git a/public/app/features/dashboard/validation_srv.ts b/public/app/features/manage-dashboards/services/ValidationSrv.ts
similarity index 100%
rename from public/app/features/dashboard/validation_srv.ts
rename to public/app/features/manage-dashboards/services/ValidationSrv.ts
diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx
index ea0e5c09388..ceeaa6c2ad5 100644
--- a/public/app/features/org/OrgDetailsPage.test.tsx
+++ b/public/app/features/org/OrgDetailsPage.test.tsx
@@ -6,7 +6,14 @@ import { NavModel, Organization } from '../../types';
const setup = (propOverrides?: object) => {
const props: Props = {
organization: {} as Organization,
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Org details'
+ }
+ } as NavModel,
loadOrganization: jest.fn(),
setOrganizationName: jest.fn(),
updateOrganization: jest.fn(),
diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx
index b011901aa71..ee644f0006f 100644
--- a/public/app/features/org/OrgDetailsPage.tsx
+++ b/public/app/features/org/OrgDetailsPage.tsx
@@ -1,13 +1,12 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
-import PageHeader from '../../core/components/PageHeader/PageHeader';
-import PageLoader from '../../core/components/PageLoader/PageLoader';
+import Page from 'app/core/components/Page/Page';
import OrgProfile from './OrgProfile';
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
import { loadOrganization, setOrganizationName, updateOrganization } from './state/actions';
import { NavModel, Organization, StoreState } from 'app/types';
-import { getNavModel } from '../../core/selectors/navModel';
+import { getNavModel } from 'app/core/selectors/navModel';
export interface Props {
navModel: NavModel;
@@ -35,22 +34,22 @@ export class OrgDetailsPage extends PureComponent {
const isLoading = Object.keys(organization).length === 0;
return (
-
-
-
- {isLoading &&
}
- {!isLoading && (
-
-
this.onOrgNameChange(name)}
- onSubmit={this.onUpdateOrganization}
- orgName={organization.name}
- />
-
+
+
+
+ {!isLoading && (
+
+ this.onOrgNameChange(name)}
+ onSubmit={this.onUpdateOrganization}
+ orgName={organization.name}
+ />
+
+
+ )}
- )}
-
-
+
+
);
}
}
diff --git a/public/app/features/org/OrgProfile.tsx b/public/app/features/org/OrgProfile.tsx
index 22dfa7bb1ce..d00f89bcff3 100644
--- a/public/app/features/org/OrgProfile.tsx
+++ b/public/app/features/org/OrgProfile.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
export interface Props {
orgName: string;
@@ -6,7 +6,7 @@ export interface Props {
onOrgNameChange: (orgName: string) => void;
}
-const OrgProfile: SFC
= ({ onSubmit, onOrgNameChange, orgName }) => {
+const OrgProfile: FC = ({ onSubmit, onOrgNameChange, orgName }) => {
return (
Organization profile
diff --git a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
index 582d626d315..9e13a73901e 100644
--- a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
+++ b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
@@ -1,38 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
-
+
+
`;
exports[`Render should render organization and preferences 1`] = `
-
-
-
+
+
`;
diff --git a/public/app/features/panel/all.ts b/public/app/features/panel/all.ts
index d461b491897..8bc2822a77c 100644
--- a/public/app/features/panel/all.ts
+++ b/public/app/features/panel/all.ts
@@ -4,3 +4,5 @@ import './solo_panel_ctrl';
import './query_ctrl';
import './panel_editor_tab';
import './query_editor_row';
+import './repeat_option';
+import './panellinks/module';
diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts
index 5557b477b8f..0b3bbc3080f 100644
--- a/public/app/features/panel/metrics_panel_ctrl.ts
+++ b/public/app/features/panel/metrics_panel_ctrl.ts
@@ -1,18 +1,18 @@
import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
-import config from 'app/core/config';
import { PanelCtrl } from 'app/features/panel/panel_ctrl';
import { getExploreUrl } from 'app/core/utils/explore';
import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/utils/panel';
+import { ContextSrv } from 'app/core/services/context_srv';
class MetricsPanelCtrl extends PanelCtrl {
scope: any;
datasource: any;
$q: any;
$timeout: any;
- contextSrv: any;
+ contextSrv: ContextSrv;
datasourceSrv: any;
timeSrv: any;
templateSrv: any;
@@ -231,7 +231,7 @@ class MetricsPanelCtrl extends PanelCtrl {
getAdditionalMenuItems() {
const items = [];
- if (config.exploreEnabled && this.contextSrv.isEditor && this.datasource) {
+ if (this.contextSrv.hasAccessToExplore() && this.datasource) {
items.push({
text: 'Explore',
click: 'ctrl.explore();',
diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts
deleted file mode 100644
index 70b3571e30d..00000000000
--- a/public/app/features/panel/metrics_tab.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-// Libraries
-import _ from 'lodash';
-
-// Services & utils
-import coreModule from 'app/core/core_module';
-import { Emitter } from 'app/core/utils/emitter';
-
-// Types
-import { DashboardModel } from '../dashboard/dashboard_model';
-import { PanelModel } from '../dashboard/panel_model';
-import { DataQuery } from 'app/types';
-
-export interface AngularQueryComponentScope {
- panel: PanelModel;
- dashboard: DashboardModel;
- events: Emitter;
- refresh: () => void;
- render: () => void;
- removeQuery: (query: DataQuery) => void;
- addQuery: (query?: DataQuery) => void;
- moveQuery: (query: DataQuery, direction: number) => void;
-}
-
-/** @ngInject */
-export function metricsTabDirective() {
- 'use strict';
- return {
- restrict: 'E',
- scope: true,
- templateUrl: 'public/app/features/panel/partials/metrics_tab.html',
- };
-}
-
-coreModule.directive('metricsTab', metricsTabDirective);
diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts
index 432d22fecdf..2f1ef72cedd 100644
--- a/public/app/features/panel/panel_ctrl.ts
+++ b/public/app/features/panel/panel_ctrl.ts
@@ -5,6 +5,7 @@ import Remarkable from 'remarkable';
import config from 'app/core/config';
import { profiler } from 'app/core/core';
import { Emitter } from 'app/core/core';
+import getFactors from 'app/core/utils/factors';
import {
duplicatePanel,
copyPanel as copyPanelUtil,
@@ -12,7 +13,7 @@ import {
sharePanel as sharePanelUtil,
} from 'app/features/dashboard/utils/panel';
-import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, PANEL_HEADER_HEIGHT, PANEL_BORDER } from 'app/core/constants';
+import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, PANEL_HEADER_HEIGHT, PANEL_BORDER } from 'app/core/constants';
export class PanelCtrl {
panel: any;
@@ -32,6 +33,7 @@ export class PanelCtrl {
events: Emitter;
timing: any;
loading: boolean;
+ maxPanelsPerRowOptions: number[];
constructor($scope, $injector) {
this.$injector = $injector;
@@ -92,6 +94,7 @@ export class PanelCtrl {
if (!this.editModeInitiated) {
this.editModeInitiated = true;
this.events.emit('init-edit-mode', null);
+ this.maxPanelsPerRowOptions = getFactors(GRID_COLUMN_COUNT);
}
}
@@ -287,17 +290,4 @@ export class PanelCtrl {
html += '
';
return sanitize(html);
}
-
- openInspector() {
- const modalScope = this.$scope.$new();
- modalScope.panel = this.panel;
- modalScope.dashboard = this.dashboard;
- modalScope.panelInfoHtml = this.getInfoContent({ mode: 'inspector' });
-
- modalScope.inspector = $.extend(true, {}, this.inspector);
- this.publishAppEvent('show-modal', {
- src: 'public/app/features/dashboard/partials/inspector.html',
- scope: modalScope,
- });
- }
}
diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts
index f503aa4386d..1fd0b129720 100644
--- a/public/app/features/panel/panel_directive.ts
+++ b/public/app/features/panel/panel_directive.ts
@@ -192,11 +192,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
scope.$watchGroup(['ctrl.error', 'ctrl.panel.description'], updatePanelCornerInfo);
scope.$watchCollection('ctrl.panel.links', updatePanelCornerInfo);
- cornerInfoElem.on('click', () => {
- infoDrop.close();
- scope.$apply(ctrl.openInspector.bind(ctrl));
- });
-
elem.on('mouseenter', mouseEnter);
elem.on('mouseleave', mouseLeave);
diff --git a/public/app/features/dashboard/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts
similarity index 100%
rename from public/app/features/dashboard/panellinks/link_srv.ts
rename to public/app/features/panel/panellinks/link_srv.ts
diff --git a/public/app/features/dashboard/panellinks/module.html b/public/app/features/panel/panellinks/module.html
similarity index 100%
rename from public/app/features/dashboard/panellinks/module.html
rename to public/app/features/panel/panellinks/module.html
diff --git a/public/app/features/dashboard/panellinks/module.ts b/public/app/features/panel/panellinks/module.ts
similarity index 100%
rename from public/app/features/dashboard/panellinks/module.ts
rename to public/app/features/panel/panellinks/module.ts
diff --git a/public/app/features/dashboard/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts
similarity index 100%
rename from public/app/features/dashboard/panellinks/specs/link_srv.test.ts
rename to public/app/features/panel/panellinks/specs/link_srv.test.ts
diff --git a/public/app/features/panel/partials/general_tab.html b/public/app/features/panel/partials/general_tab.html
index d6c2d4804a0..ceae445f3ed 100644
--- a/public/app/features/panel/partials/general_tab.html
+++ b/public/app/features/panel/partials/general_tab.html
@@ -1,6 +1,6 @@
-
+
-
+
-
-
-
+
+
+
Repeat
@@ -32,18 +32,23 @@
- Min width
-
+
+
-
-
-
+
diff --git a/public/app/features/panel/partials/metrics_tab.html b/public/app/features/panel/partials/metrics_tab.html
deleted file mode 100644
index 5e9f23ba2ef..00000000000
--- a/public/app/features/panel/partials/metrics_tab.html
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/app/features/panel/partials/query_editor_row.html b/public/app/features/panel/partials/query_editor_row.html
index 34a86813d1d..fc2e3602630 100644
--- a/public/app/features/panel/partials/query_editor_row.html
+++ b/public/app/features/panel/partials/query_editor_row.html
@@ -1,44 +1,2 @@
-
diff --git a/public/app/features/panel/query_editor_row.ts b/public/app/features/panel/query_editor_row.ts
index a44c1e8be6d..fa25ce832be 100644
--- a/public/app/features/panel/query_editor_row.ts
+++ b/public/app/features/panel/query_editor_row.ts
@@ -3,89 +3,26 @@ import angular from 'angular';
const module = angular.module('grafana.directives');
export class QueryRowCtrl {
- collapsedText: string;
- canCollapse: boolean;
- getCollapsedText: any;
target: any;
queryCtrl: any;
panelCtrl: any;
panel: any;
- collapsed: any;
- hideEditorRowActions: boolean;
+ hasTextEditMode: boolean;
constructor() {
this.panelCtrl = this.queryCtrl.panelCtrl;
this.target = this.queryCtrl.target;
this.panel = this.panelCtrl.panel;
- this.hideEditorRowActions = this.panelCtrl.hideEditorRowActions;
- if (!this.target.refId) {
- this.target.refId = this.panel.getNextQueryLetter();
+ if (this.hasTextEditMode) {
+ // expose this function to react parent component
+ this.panelCtrl.toggleEditorMode = this.queryCtrl.toggleEditorMode.bind(this.queryCtrl);
}
- this.toggleCollapse(true);
- if (this.target.isNew) {
- delete this.target.isNew;
- this.toggleCollapse(false);
+ if (this.queryCtrl.getCollapsedText) {
+ // expose this function to react parent component
+ this.panelCtrl.getCollapsedText = this.queryCtrl.getCollapsedText.bind(this.queryCtrl);
}
-
- if (this.panel.targets.length < 4) {
- this.collapsed = false;
- }
- }
-
- toggleHideQuery() {
- this.target.hide = !this.target.hide;
- this.panelCtrl.refresh();
- }
-
- toggleCollapse(init) {
- if (!this.canCollapse) {
- return;
- }
-
- if (!this.panelCtrl.__collapsedQueryCache) {
- this.panelCtrl.__collapsedQueryCache = {};
- }
-
- if (init) {
- this.collapsed = this.panelCtrl.__collapsedQueryCache[this.target.refId] !== false;
- } else {
- this.collapsed = !this.collapsed;
- this.panelCtrl.__collapsedQueryCache[this.target.refId] = this.collapsed;
- }
-
- try {
- this.collapsedText = this.queryCtrl.getCollapsedText();
- } catch (e) {
- const err = e.message || e.toString();
- this.collapsedText = 'Error: ' + err;
- }
- }
-
- toggleEditorMode() {
- if (this.canCollapse && this.collapsed) {
- this.collapsed = false;
- }
-
- this.queryCtrl.toggleEditorMode();
- }
-
- removeQuery() {
- if (this.panelCtrl.__collapsedQueryCache) {
- delete this.panelCtrl.__collapsedQueryCache[this.target.refId];
- }
-
- this.panelCtrl.removeQuery(this.target);
- }
-
- duplicateQuery() {
- const clone = angular.copy(this.target);
- this.panelCtrl.addQuery(clone);
- }
-
- moveQuery(direction) {
- this.panelCtrl.moveQuery(this.target, direction);
}
}
diff --git a/public/app/features/dashboard/repeat_option/repeat_option.ts b/public/app/features/panel/repeat_option.ts
similarity index 100%
rename from public/app/features/dashboard/repeat_option/repeat_option.ts
rename to public/app/features/panel/repeat_option.ts
diff --git a/public/app/features/panel/specs/metrics_panel_ctrl.test.ts b/public/app/features/panel/specs/metrics_panel_ctrl.test.ts
index 913a2461fd0..8b9607d39ad 100644
--- a/public/app/features/panel/specs/metrics_panel_ctrl.test.ts
+++ b/public/app/features/panel/specs/metrics_panel_ctrl.test.ts
@@ -1,7 +1,6 @@
jest.mock('app/core/core', () => ({}));
jest.mock('app/core/config', () => {
return {
- exploreEnabled: true,
panels: {
test: {
id: 'test',
@@ -16,46 +15,45 @@ import { PanelModel } from 'app/features/dashboard/panel_model';
import { MetricsPanelCtrl } from '../metrics_panel_ctrl';
describe('MetricsPanelCtrl', () => {
- let ctrl;
-
- beforeEach(() => {
- ctrl = setupController();
- });
-
describe('when getting additional menu items', () => {
- let additionalItems;
-
- describe('and has no datasource set', () => {
- beforeEach(() => {
- additionalItems = ctrl.getAdditionalMenuItems();
- });
-
+ describe('and has no datasource set but user has access to explore', () => {
it('should not return any items', () => {
- expect(additionalItems.length).toBe(0);
+ const ctrl = setupController({ hasAccessToExplore: true });
+
+ expect(ctrl.getAdditionalMenuItems().length).toBe(0);
});
});
- describe('and has datasource set that supports explore and user has powers', () => {
- beforeEach(() => {
- ctrl.contextSrv = { isEditor: true };
- ctrl.datasource = { meta: { explore: true } };
- additionalItems = ctrl.getAdditionalMenuItems();
- });
-
+ describe('and has datasource set that supports explore and user does not have access to explore', () => {
it('should not return any items', () => {
- expect(additionalItems.length).toBe(1);
+ const ctrl = setupController({ hasAccessToExplore: false });
+ ctrl.datasource = { meta: { explore: true } };
+
+ expect(ctrl.getAdditionalMenuItems().length).toBe(0);
+ });
+ });
+
+ describe('and has datasource set that supports explore and user has access to explore', () => {
+ it('should return one item', () => {
+ const ctrl = setupController({ hasAccessToExplore: true });
+ ctrl.datasource = { meta: { explore: true } };
+
+ expect(ctrl.getAdditionalMenuItems().length).toBe(1);
});
});
});
});
-function setupController() {
+function setupController({ hasAccessToExplore } = { hasAccessToExplore: false }) {
const injectorStub = {
get: type => {
switch (type) {
case '$q': {
return q;
}
+ case 'contextSrv': {
+ return { hasAccessToExplore: () => hasAccessToExplore };
+ }
default: {
return jest.fn();
}
diff --git a/public/app/features/playlist/playlist_srv.ts b/public/app/features/playlist/playlist_srv.ts
index 9d3b635a1e5..0a80ce0cdf0 100644
--- a/public/app/features/playlist/playlist_srv.ts
+++ b/public/app/features/playlist/playlist_srv.ts
@@ -4,12 +4,13 @@ import appEvents from 'app/core/app_events';
import _ from 'lodash';
import { toUrlParams } from 'app/core/utils/url';
-class PlaylistSrv {
+export class PlaylistSrv {
private cancelPromise: any;
- private dashboards: any;
+ private dashboards: Array<{ uri: string }>;
private index: number;
- private interval: any;
+ private interval: number;
private startUrl: string;
+ private numberOfLoops = 0;
isPlaying: boolean;
/** @ngInject */
@@ -20,8 +21,15 @@ class PlaylistSrv {
const playedAllDashboards = this.index > this.dashboards.length - 1;
if (playedAllDashboards) {
- window.location.href = this.startUrl;
- return;
+ this.numberOfLoops++;
+
+ // This does full reload of the playlist to keep memory in check due to existing leaks but at the same time
+ // we do not want page to flicker after each full loop.
+ if (this.numberOfLoops >= 3) {
+ window.location.href = this.startUrl;
+ return;
+ }
+ this.index = 0;
}
const dash = this.dashboards[this.index];
@@ -46,8 +54,8 @@ class PlaylistSrv {
this.index = 0;
this.isPlaying = true;
- this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
- this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
+ return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
+ return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
this.dashboards = dashboards;
this.interval = kbn.interval_to_ms(playlist.interval);
this.next();
diff --git a/public/app/features/playlist/specs/playlist_srv.test.ts b/public/app/features/playlist/specs/playlist_srv.test.ts
new file mode 100644
index 00000000000..e6b7671c964
--- /dev/null
+++ b/public/app/features/playlist/specs/playlist_srv.test.ts
@@ -0,0 +1,103 @@
+import { PlaylistSrv } from '../playlist_srv';
+
+const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }];
+
+const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance
}] => {
+ const mockBackendSrv = {
+ get: jest.fn(url => {
+ switch (url) {
+ case '/api/playlists/1':
+ return Promise.resolve({ interval: '1s' });
+ case '/api/playlists/1/dashboards':
+ return Promise.resolve(dashboards);
+ default:
+ throw new Error(`Unexpected url=${url}`);
+ }
+ }),
+ };
+
+ const mockLocation = {
+ url: jest.fn(),
+ search: () => ({}),
+ };
+
+ const mockTimeout = jest.fn();
+ (mockTimeout as any).cancel = jest.fn();
+
+ return [new PlaylistSrv(mockLocation, mockTimeout, mockBackendSrv), mockLocation];
+};
+
+const mockWindowLocation = (): [jest.MockInstance, () => void] => {
+ const oldLocation = window.location;
+ const hrefMock = jest.fn();
+
+ // JSDom defines window in a way that you cannot tamper with location so this seems to be the only way to change it.
+ // https://github.com/facebook/jest/issues/5124#issuecomment-446659510
+ delete window.location;
+ window.location = {} as any;
+
+ // Only mocking href as that is all this test needs, but otherwise there is lots of things missing, so keep that
+ // in mind if this is reused.
+ Object.defineProperty(window.location, 'href', {
+ set: hrefMock,
+ get: hrefMock,
+ });
+ const unmock = () => {
+ window.location = oldLocation;
+ };
+ return [hrefMock, unmock];
+};
+
+describe('PlaylistSrv', () => {
+ let srv: PlaylistSrv;
+ let mockLocationService: { url: jest.MockInstance };
+ let hrefMock: jest.MockInstance;
+ let unmockLocation: () => void;
+ const initialUrl = 'http://localhost/playlist';
+
+ beforeEach(() => {
+ [srv, mockLocationService] = createPlaylistSrv();
+ [hrefMock, unmockLocation] = mockWindowLocation();
+
+ // This will be cached in the srv when start() is called
+ hrefMock.mockReturnValue(initialUrl);
+ });
+
+ afterEach(() => {
+ unmockLocation();
+ });
+
+ it('runs all dashboards in cycle and reloads page after 3 cycles', async () => {
+ await srv.start(1);
+
+ for (let i = 0; i < 6; i++) {
+ expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
+ srv.next();
+ }
+
+ expect(hrefMock).toHaveBeenCalledTimes(2);
+ expect(hrefMock).toHaveBeenLastCalledWith(initialUrl);
+ });
+
+ it('keeps the refresh counter value after restarting', async () => {
+ await srv.start(1);
+
+ // 1 complete loop
+ for (let i = 0; i < 3; i++) {
+ expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
+ srv.next();
+ }
+
+ srv.stop();
+ await srv.start(1);
+
+ // Another 2 loops
+ for (let i = 0; i < 4; i++) {
+ expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
+ srv.next();
+ }
+
+ expect(hrefMock).toHaveBeenCalledTimes(3);
+ expect(hrefMock).toHaveBeenLastCalledWith(initialUrl);
+ });
+});
diff --git a/public/app/features/plugins/PluginList.tsx b/public/app/features/plugins/PluginList.tsx
index fd490fcfe1e..bf970c37392 100644
--- a/public/app/features/plugins/PluginList.tsx
+++ b/public/app/features/plugins/PluginList.tsx
@@ -1,4 +1,4 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import classNames from 'classnames';
import PluginListItem from './PluginListItem';
import { Plugin } from 'app/types';
@@ -9,7 +9,7 @@ interface Props {
layoutMode: LayoutMode;
}
-const PluginList: SFC = props => {
+const PluginList: FC = props => {
const { plugins, layoutMode } = props;
const listStyle = classNames({
diff --git a/public/app/features/plugins/PluginListItem.tsx b/public/app/features/plugins/PluginListItem.tsx
index 05eac614fd5..bbc1daa3355 100644
--- a/public/app/features/plugins/PluginListItem.tsx
+++ b/public/app/features/plugins/PluginListItem.tsx
@@ -1,11 +1,11 @@
-import React, { SFC } from 'react';
+import React, { FC } from 'react';
import { Plugin } from 'app/types';
interface Props {
plugin: Plugin;
}
-const PluginListItem: SFC = props => {
+const PluginListItem: FC = props => {
const { plugin } = props;
return (
diff --git a/public/app/features/plugins/PluginListPage.test.tsx b/public/app/features/plugins/PluginListPage.test.tsx
index 31b2f128436..31956f41cc1 100644
--- a/public/app/features/plugins/PluginListPage.test.tsx
+++ b/public/app/features/plugins/PluginListPage.test.tsx
@@ -6,7 +6,14 @@ import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector
const setup = (propOverrides?: object) => {
const props: Props = {
- navModel: {} as NavModel,
+ navModel: {
+ main: {
+ text: 'Configuration'
+ },
+ node: {
+ text: 'Plugins'
+ }
+ } as NavModel,
plugins: [] as Plugin[],
searchQuery: '',
setPluginsSearchQuery: jest.fn(),
diff --git a/public/app/features/plugins/PluginListPage.tsx b/public/app/features/plugins/PluginListPage.tsx
index a2fcb90ce54..3bc0ee545b4 100644
--- a/public/app/features/plugins/PluginListPage.tsx
+++ b/public/app/features/plugins/PluginListPage.tsx
@@ -1,15 +1,14 @@
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 OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
-import PageLoader from 'app/core/components/PageLoader/PageLoader';
import PluginList from './PluginList';
import { NavModel, Plugin } from 'app/types';
import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
-import { getNavModel } from '../../core/selectors/navModel';
+import { getNavModel } from 'app/core/selectors/navModel';
import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors';
-import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
export interface Props {
navModel: NavModel;
@@ -48,23 +47,22 @@ export class PluginListPage extends PureComponent {
};
return (
-