diff --git a/.bra.toml b/.bra.toml
index 15961e1e3fd..aa7a1680adc 100644
--- a/.bra.toml
+++ b/.bra.toml
@@ -4,6 +4,7 @@ init_cmds = [
["./bin/grafana-server", "cfg:app_mode=development"]
]
watch_all = true
+follow_symlinks = true
watch_dirs = [
"$WORKDIR/pkg",
"$WORKDIR/public/views",
diff --git a/.circleci/config.yml b/.circleci/config.yml
index e631e0a8d33..60b3ae91ccc 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -83,13 +83,14 @@ jobs:
- 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 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=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...'
+ command: 'gometalinter --enable-gc --vendor --deadline 10m --disable-all --enable=deadcode --enable=goconst --enable=ineffassign --enable=structcheck --enable=unconvert --enable=varcheck ./...'
- run:
name: run go vet
command: 'go vet ./pkg/...'
@@ -125,7 +126,7 @@ jobs:
build-all:
docker:
- - image: grafana/build-container:1.1.0
+ - image: grafana/build-container:1.2.0
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -157,18 +158,23 @@ jobs:
name: sha-sum packages
command: 'go run build.go sha-dist'
- run:
- name: Build Grafana.com publisher
+ name: Build Grafana.com master publisher
command: 'go build -o scripts/publish scripts/build/publish.go'
+ - run:
+ name: Build Grafana.com release publisher
+ command: 'cd scripts/build/release_publisher && go build -o release_publisher .'
- persist_to_workspace:
root: .
paths:
- dist/grafana*
- scripts/*.sh
- scripts/publish
+ - scripts/build/release_publisher/release_publisher
+ - scripts/build/publish.sh
build:
docker:
- - image: grafana/build-container:1.1.0
+ - image: grafana/build-container:1.2.0
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -200,6 +206,10 @@ jobs:
- run: docker info
- run: cp dist/grafana-latest.linux-x64.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: 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:
@@ -224,16 +234,28 @@ jobs:
- 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}"
build-enterprise:
docker:
- - image: grafana/build-container:v0.1
+ - image: grafana/build-container:1.2.0
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
- run:
- name: build, test and package grafana enterprise
- command: './scripts/build/build_enterprise.sh'
+ name: prepare build tools
+ command: '/tmp/bootstrap.sh'
+ - run:
+ name: checkout enterprise
+ command: './scripts/build/prepare-enterprise.sh'
+ - run:
+ name: test enterprise
+ command: 'go test ./pkg/extensions/...'
+ - run:
+ name: build and package enterprise
+ command: './scripts/build/build.sh -enterprise'
- run:
name: sign packages
command: './scripts/build/sign_packages.sh'
@@ -248,28 +270,98 @@ jobs:
paths:
- enterprise-dist/grafana-enterprise*
+ build-all-enterprise:
+ docker:
+ - image: grafana/build-container:1.2.0
+ working_directory: /go/src/github.com/grafana/grafana
+ steps:
+ - checkout
+ - run:
+ name: prepare build tools
+ command: '/tmp/bootstrap.sh'
+ - run:
+ name: checkout enterprise
+ command: './scripts/build/prepare-enterprise.sh'
+ - restore_cache:
+ key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }}
+ - run:
+ name: download phantomjs binaries
+ command: './scripts/build/download-phantomjs.sh'
+ - save_cache:
+ key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }}
+ paths:
+ - /tmp/phantomjs
+ - run:
+ name: test enterprise
+ command: 'go test ./pkg/extensions/...'
+ - run:
+ name: build and package grafana
+ command: './scripts/build/build-all.sh -enterprise'
+ - run:
+ name: sign packages
+ command: './scripts/build/sign_packages.sh'
+ - run:
+ name: verify signed packages
+ command: |
+ mkdir -p ~/.rpmdb/pubkeys
+ curl -s https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana > ~/.rpmdb/pubkeys/grafana.key
+ ./scripts/build/verify_signed_packages.sh dist/*.rpm
+ - run:
+ name: sha-sum packages
+ command: 'go run build.go sha-dist'
+ - run:
+ name: move enterprise packages into their own folder
+ command: 'mv dist enterprise-dist'
+ - persist_to_workspace:
+ root: .
+ paths:
+ - enterprise-dist/grafana-enterprise*
+
deploy-enterprise-master:
docker:
- - image: circleci/python:2.7-stretch
+ - image: grafana/grafana-ci-deploy:1.0.0
steps:
- attach_workspace:
at: .
- run:
- name: install awscli
- command: 'sudo pip install awscli'
+ name: gcp credentials
+ command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+ - run:
+ name: sign in to gcp
+ command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
- run:
name: deploy to s3
command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master'
+ - run:
+ name: deploy to gcp
+ command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/master'
+
+
+ deploy-enterprise-release:
+ docker:
+ - image: grafana/grafana-ci-deploy:1.0.0
+ steps:
+ - attach_workspace:
+ at: .
+ - run:
+ name: gcp credentials
+ command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+ - run:
+ name: sign in to gcp
+ command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
+ - run:
+ name: deploy to s3
+ command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release'
+ - run:
+ name: deploy to gcp
+ command: '/opt/google-cloud-sdk/bin/gsutil cp ./enterprise-dist/* gs://$GCP_BUCKET_NAME/enterprise/release'
deploy-master:
docker:
- - image: circleci/python:2.7-stretch
+ - image: grafana/grafana-ci-deploy:1.0.0
steps:
- attach_workspace:
at: .
- - run:
- name: install awscli
- command: 'sudo pip install awscli'
- run:
name: deploy to s3
command: |
@@ -279,6 +371,15 @@ jobs:
- run:
name: Trigger Windows build
command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} master'
+ - run:
+ name: gcp credentials
+ command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+ - run:
+ name: sign in to gcp
+ command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
+ - run:
+ name: deploy to gcp
+ command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://$GCP_BUCKET_NAME/oss/master'
- run:
name: Publish to Grafana.com
command: |
@@ -287,19 +388,25 @@ jobs:
deploy-release:
docker:
- - image: circleci/python:2.7-stretch
+ - image: grafana/grafana-ci-deploy:1.0.0
steps:
- attach_workspace:
at: .
- - run:
- name: install awscli
- command: 'sudo pip install awscli'
- run:
name: deploy to s3
command: 'aws s3 sync ./dist s3://$BUCKET_NAME/release'
- run:
- name: Trigger Windows build
- command: './scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} ${CIRCLE_SHA1} release'
+ name: gcp credentials
+ command: 'echo ${GCP_GRAFANA_UPLOAD_KEY} > /tmp/gcpkey.json'
+ - run:
+ name: sign in to gcp
+ command: '/opt/google-cloud-sdk/bin/gcloud auth activate-service-account --key-file=/tmp/gcpkey.json'
+ - run:
+ name: deploy to gcp
+ command: '/opt/google-cloud-sdk/bin/gsutil cp ./dist/* gs://R/oss/release'
+ - run:
+ name: Deploy to Grafana.com
+ command: './scripts/build/publish.sh'
workflows:
version: 2
@@ -307,7 +414,7 @@ workflows:
jobs:
- build-all:
filters: *filter-only-master
- - build-enterprise:
+ - build-all-enterprise:
filters: *filter-only-master
- codespell:
filters: *filter-only-master
@@ -334,6 +441,7 @@ workflows:
- grafana-docker-master:
requires:
- build-all
+ - build-all-enterprise
- test-backend
- test-frontend
- codespell
@@ -350,13 +458,15 @@ workflows:
- gometalinter
- mysql-integration-test
- postgres-integration-test
- - build-enterprise
+ - build-all-enterprise
filters: *filter-only-master
release:
jobs:
- build-all:
filters: *filter-only-release
+ - build-all-enterprise:
+ filters: *filter-only-release
- codespell:
filters: *filter-only-release
- gometalinter:
@@ -379,6 +489,17 @@ workflows:
- mysql-integration-test
- postgres-integration-test
filters: *filter-only-release
+ - deploy-enterprise-release:
+ requires:
+ - build-all
+ - build-all-enterprise
+ - test-backend
+ - test-frontend
+ - codespell
+ - gometalinter
+ - mysql-integration-test
+ - postgres-integration-test
+ filters: *filter-only-release
- grafana-docker-release:
requires:
- build-all
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
deleted file mode 100644
index 769ba2a519b..00000000000
--- a/.github/CONTRIBUTING.md
+++ /dev/null
@@ -1,22 +0,0 @@
-Follow the setup guide in README.md
-
-### Rebuild frontend assets on source change
-```
-yarn watch
-```
-
-### Rerun tests on source change
-```
-yarn jest
-```
-
-### Run tests for backend assets before commit
-```
-test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)"
-```
-
-### Run tests for frontend assets before commit
-```
-yarn test
-go test -v ./pkg/...
-```
diff --git a/.gitignore b/.gitignore
index 08525d92519..05ae4907e89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ awsconfig
/dist
/public/build
/public/views/index.html
+/public/views/error.html
/emails/dist
/public_gen
/public/vendor/npm
@@ -54,6 +55,7 @@ profile.cov
/pkg/cmd/grafana-server/grafana-server
/pkg/cmd/grafana-server/debug
/pkg/extensions
+/public/app/extensions
debug.test
/examples/*/dist
/packaging/**/*.rpm
@@ -68,8 +70,9 @@ debug.test
/vendor/**/*.yml
/vendor/**/*_test.go
/vendor/**/.editorconfig
-/vendor/**/appengine*
*.orig
/devenv/bulk-dashboards/*.json
/devenv/bulk_alerting_dashboards/*.json
+
+/scripts/build/release_publisher/release_publisher
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39479054af3..97537ec34f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,22 +2,94 @@
### New Features
-* **Annotations**: Enable template variables in tagged annotations queries [#9735](https://github.com/grafana/grafana/issues/9735)
+* **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat)
+* **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset)
+* **MySQL**: Graphical query builder [#13762](https://github.com/grafana/grafana/issues/13762), thx [svenklemm](https://github.com/svenklemm)
+* **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
+* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
+* **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669)
### Minor
+* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
+* **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm)
+* **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
+* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
+* **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
+* **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876)
+* **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945)
+
+### Breaking changes
+
+* Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
+
+# 5.3.3 (unreleased)
+
+* **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769)
+
+# 5.3.2 (2018-10-24)
+
+* **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm)
+* **Postgres**: Fix template variables error [#13692](https://github.com/grafana/grafana/issues/13692), thx [@svenklemm](https://github.com/svenklemm)
+* **Cloudwatch**: Fix service panic because of race conditions [#13674](https://github.com/grafana/grafana/issues/13674), thx [@mtanda](https://github.com/mtanda)
+* **Cloudwatch**: Fix check for invalid percentile statistics [#13633](https://github.com/grafana/grafana/issues/13633), thx [@apalaniuk](https://github.com/apalaniuk)
+* **Stackdriver/Cloudwatch**: Allow user to change unit in graph panel if cloudwatch/stackdriver datasource response doesn't include unit [#13718](https://github.com/grafana/grafana/issues/13718), thx [@mtanda](https://github.com/mtanda)
+* **Stackdriver**: stackdriver user-metrics duplicated response when multiple resource types [#13691](https://github.com/grafana/grafana/issues/13691)
+* **Variables**: Fix text box template variable doesn't work properly without a default value [#13666](https://github.com/grafana/grafana/issues/13666)
+* **Variables**: Fix variable dependency check when using `${var}` format [#13600](https://github.com/grafana/grafana/issues/13600)
+* **Dashboard**: Fix kiosk=1 url parameter should put dashboard in kiosk mode [#13764](https://github.com/grafana/grafana/pull/13764)
+* **LDAP**: Fix super admins can also be admins of orgs [#13710](https://github.com/grafana/grafana/issues/13710), thx [@adrien-f](https://github.com/adrien-f)
+* **Provisioning**: Fix deleting provisioned dashboard folder should cleanup provisioning meta data [#13280](https://github.com/grafana/grafana/issues/13280)
+
+### Minor
+
+* **Docker**: adds curl back into the docker image for utility. [#13794](https://github.com/grafana/grafana/pull/13794)
+
+# 5.3.1 (2018-10-16)
+
+* **Render**: Fix PhantomJS render of graph panel when legend displayed as table to the right [#13616](https://github.com/grafana/grafana/issues/13616)
+* **Stackdriver**: Filter option disappears after removing initial filter [#13607](https://github.com/grafana/grafana/issues/13607)
+* **Elasticsearch**: Fix no limit size in terms aggregation for alerting queries [#13172](https://github.com/grafana/grafana/issues/13172), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
+* **InfluxDB**: Fix for annotation issue that caused text to be shown twice [#13553](https://github.com/grafana/grafana/issues/13553)
+* **Variables**: Fix nesting variables leads to exception and missing refresh [#13628](https://github.com/grafana/grafana/issues/13628)
+* **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641), thx [@olshansky](https://github.com/olshansky)
+* **Graph**: Fix graph time formatting for Last 24h ranges [#13650](https://github.com/grafana/grafana/issues/13650)
+* **Playlist**: Fix cannot add dashboards with long names to playlist [#13464](https://github.com/grafana/grafana/issues/13464), thx [@neufeldtech](https://github.com/neufeldtech)
+* **HTTP API**: Fix /api/org/users so that query and limit querystrings works
+
+# 5.3.0 (2018-10-10)
+
+* **Stackdriver**: Filter wildcards and regex matching are not yet supported [#13495](https://github.com/grafana/grafana/issues/13495)
+* **Stackdriver**: Support the distribution metric type for heatmaps [#13559](https://github.com/grafana/grafana/issues/13559)
+* **Cloudwatch**: Automatically set graph yaxis unit [#13575](https://github.com/grafana/grafana/issues/13575), thx [@mtanda](https://github.com/mtanda)
+
+# 5.3.0-beta3 (2018-10-03)
+
+* **Stackdriver**: Fix for missing ngInject [#13511](https://github.com/grafana/grafana/pull/13511)
+* **Permissions**: Fix for broken permissions selector [#13507](https://github.com/grafana/grafana/issues/13507)
+* **Alerting**: Alert reminders deduping not working as expected when running multiple Grafana instances [#13492](https://github.com/grafana/grafana/issues/13492)
+
+# 5.3.0-beta2 (2018-10-01)
+
+### New Features
+
+* **Annotations**: Enable template variables in tagged annotations queries [#9735](https://github.com/grafana/grafana/issues/9735)
+* **Stackdriver**: Support for Google Stackdriver Datasource [#13289](https://github.com/grafana/grafana/pull/13289)
+
+### Minor
+
+* **Provisioning**: Dashboard Provisioning now support symlinks that changes target [#12534](https://github.com/grafana/grafana/issues/12534), thx [@auhlig](https://github.com/auhlig)
* **OAuth**: Allow oauth email attribute name to be configurable [#12986](https://github.com/grafana/grafana/issues/12986), thx [@bobmshannon](https://github.com/bobmshannon)
* **Tags**: Default sort order for GetDashboardTags [#11681](https://github.com/grafana/grafana/pull/11681), thx [@Jonnymcc](https://github.com/Jonnymcc)
* **Prometheus**: Label completion queries respect dashboard time range [#12251](https://github.com/grafana/grafana/pull/12251), thx [@mtanda](https://github.com/mtanda)
* **Prometheus**: Allow to display annotations based on Prometheus series value [#10159](https://github.com/grafana/grafana/issues/10159), thx [@mtanda](https://github.com/mtanda)
* **Prometheus**: Adhoc-filtering for Prometheus dashboards [#13212](https://github.com/grafana/grafana/issues/13212)
* **Singlestat**: Fix gauge display accuracy for percents [#13270](https://github.com/grafana/grafana/issues/13270), thx [@tianon](https://github.com/tianon)
-
-# 5.3.0 (unreleased)
-
-### Minor
-
+* **Dashboard**: Prevent auto refresh from starting when loading dashboard with absolute time range [#12030](https://github.com/grafana/grafana/issues/12030)
+* **Templating**: New templating variable type `Text box` that allows free text input [#3173](https://github.com/grafana/grafana/issues/3173)
* **Alerting**: Link to view full size image in Microsoft Teams alert notifier [#13121](https://github.com/grafana/grafana/issues/13121), thx [@holiiveira](https://github.com/holiiveira)
+* **Alerting**: Fixes a bug where all alerts would send reminders after upgrade & restart [#13402](https://github.com/grafana/grafana/pull/13402)
+* **Alerting**: Concurrent render limit for graphs used in notifications [#13401](https://github.com/grafana/grafana/pull/13401)
* **Postgres/MySQL/MSSQL**: Add support for replacing $__interval and $__interval_ms in alert queries [#11555](https://github.com/grafana/grafana/issues/11555), thx [@svenklemm](https://github.com/svenklemm)
# 5.3.0-beta1 (2018-09-06)
@@ -36,7 +108,7 @@
* **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476)
* **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos)
* **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
-* **Cloudwatch**: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
+* **CloudWatch**: GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
* **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm)
* **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000000..8b2ba090fe1
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,56 @@
+
+# Contributing
+
+Grafana uses GitHub to manage contributions.
+Contributions take the form of pull requests that will be reviewed by the core team.
+
+* If you are a new contributor see: [Steps to Contribute](#steps-to-contribute)
+
+* If you have a trivial fix or improvement, go ahead and create a pull request.
+
+* If you plan to do something more involved, discuss your idea on the respective [issue](https://github.com/grafana/grafana/issues) or create a [new issue](https://github.com/grafana/grafana/issues/new) if it does not exist. This will avoid unnecessary work and surely give you and us a good deal of inspiration.
+
+
+## Steps to Contribute
+
+Should you wish to work on a GitHub issue, check first if it is not already assigned to someone. If it is free, you claim it by commenting on the issue that you want to work on it. This is to prevent duplicated efforts from contributors on the same issue.
+
+Please 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 good for getting started. If you have questions about one of the issues, with or without the tag, please comment on them and one of the core team or the original poster will clarify it.
+
+
+
+## Setup
+
+Follow the setup guide in README.md
+
+### Rebuild frontend assets on source change
+```
+yarn watch
+```
+
+### Rerun tests on source change
+```
+yarn jest
+```
+
+### Run tests for backend assets before commit
+```
+test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)"
+```
+
+### Run tests for frontend assets before commit
+```
+yarn test
+go test -v ./pkg/...
+```
+
+
+## Pull Request Checklist
+
+* Branch from the master branch and, if needed, rebase to the current master branch before submitting your pull request. If it doesn't merge cleanly with master you may be asked to rebase your changes.
+
+* Commits should be as small as possible, while ensuring that each commit is correct independently (i.e., each commit should compile and pass tests).
+
+* If your patch is not getting reviewed or you need a specific person to review it, you can @-reply a reviewer asking for a review in the pull request or a comment.
+
+* Add tests relevant to the fixed bug or new feature.
diff --git a/Gopkg.lock b/Gopkg.lock
index bd247d691dd..4286add847d 100644
--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -19,6 +19,12 @@
packages = ["."]
revision = "7677a1d7c1137cd3dd5ba7a076d0c898a1ef4520"
+[[projects]]
+ branch = "master"
+ name = "github.com/VividCortex/mysqlerr"
+ packages = ["."]
+ revision = "6c6b55f8796f578c870b7e19bafb16103bc40095"
+
[[projects]]
name = "github.com/aws/aws-sdk-go"
packages = [
@@ -258,7 +264,7 @@
branch = "master"
name = "github.com/hashicorp/yamux"
packages = ["."]
- revision = "2658be15c5f05e76244154714161f17e3e77de2e"
+ revision = "7221087c3d281fda5f794e28c2ea4c6e4d5c4558"
[[projects]]
name = "github.com/inconshreveable/log15"
@@ -501,6 +507,8 @@
branch = "master"
name = "golang.org/x/crypto"
packages = [
+ "ed25519",
+ "ed25519/internal/edwards25519",
"md4",
"pbkdf2"
]
@@ -664,6 +672,16 @@
revision = "e6179049628164864e6e84e973cfb56335748dea"
version = "v2.3.2"
+[[projects]]
+ name = "gopkg.in/square/go-jose.v2"
+ packages = [
+ ".",
+ "cipher",
+ "json"
+ ]
+ revision = "ef984e69dd356202fd4e4910d4d9c24468bdf0b8"
+ version = "v2.1.9"
+
[[projects]]
name = "gopkg.in/yaml.v2"
packages = ["."]
@@ -673,6 +691,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
- inputs-digest = "81a37e747b875cf870c1b9486fa3147e704dea7db8ba86f7cb942d3ddc01d3e3"
+ inputs-digest = "6f7f271afd27f78b7d8ebe27436fee72c9925fb82a978bdc57fde44e01f3ca51"
solver-name = "gps-cdcl"
solver-version = 1
diff --git a/Gopkg.toml b/Gopkg.toml
index 6c91ec37221..e3cbdeabb5d 100644
--- a/Gopkg.toml
+++ b/Gopkg.toml
@@ -203,3 +203,11 @@ ignored = [
[[constraint]]
name = "github.com/denisenkom/go-mssqldb"
revision = "270bc3860bb94dd3a3ffd047377d746c5e276726"
+
+[[constraint]]
+ name = "github.com/VividCortex/mysqlerr"
+ branch = "master"
+
+[[constraint]]
+ name = "gopkg.in/square/go-jose.v2"
+ version = "2.1.9"
diff --git a/Gruntfile.js b/Gruntfile.js
index 2d5990b5f58..de3e68d4a92 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -9,12 +9,17 @@ module.exports = function (grunt) {
destDir: 'dist',
tempDir: 'tmp',
platform: process.platform.replace('win32', 'windows'),
+ enterprise: false,
};
if (grunt.option('platform')) {
config.platform = grunt.option('platform');
}
+ if (grunt.option('enterprise')) {
+ config.enterprise = true;
+ }
+
if (grunt.option('arch')) {
config.arch = grunt.option('arch');
} else {
diff --git a/Makefile b/Makefile
index c9e51d897f3..fcb740d2fac 100644
--- a/Makefile
+++ b/Makefile
@@ -5,8 +5,7 @@ all: deps build
deps-go:
go run build.go setup
-deps-js:
- yarn install --pure-lockfile --no-progress
+deps-js: node_modules
deps: deps-js
@@ -43,3 +42,10 @@ test: test-go test-js
run:
./bin/grafana-server
+
+clean:
+ rm -rf node_modules
+ rm -rf public/build
+
+node_modules: package.json yarn.lock
+ yarn install --pure-lockfile --no-progress
diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md
index 4e2e080ebe6..168b21dbd88 100644
--- a/PLUGIN_DEV.md
+++ b/PLUGIN_DEV.md
@@ -6,8 +6,8 @@ upgrading Grafana please check here before creating an issue.
## Links
-- [Datasource plugin written in typescript](https://github.com/grafana/typescript-template-datasource)
-- [Simple json dataource plugin](https://github.com/grafana/simple-json-datasource)
+- [Datasource plugin written in TypeScript](https://github.com/grafana/typescript-template-datasource)
+- [Simple JSON datasource plugin](https://github.com/grafana/simple-json-datasource)
- [Plugin development guide](http://docs.grafana.org/plugins/developing/development/)
- [Webpack Grafana plugin template project](https://github.com/CorpGlory/grafana-plugin-template-webpack)
diff --git a/README.md b/README.md
index 133d9e50d07..5882ea8a6a3 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
### Dependencies
-- Go 1.11
+- Go (Latest Stable)
- NodeJS LTS
### Building the backend
@@ -69,15 +69,27 @@ bra run
Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
-### Building a docker image (on linux/amd64)
+### Building a Docker image
-This builds a docker image from your local sources:
+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.
+
+Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev`
+
+#### Building on linux/amd64 (fast)
1. Build the frontend `go run build.go build-frontend`
2. Build the docker image `make build-docker-dev`
The resulting image will be tagged as `grafana/grafana:dev`
+#### Building anywhere (slower)
+
+Choose this option to build on platforms other than linux/amd64 and/or not have to setup the Grafana development environment.
+
+1. `make build-docker-full` or `docker build -t grafana/grafana:dev .`
+
+The resulting image will be tagged as `grafana/grafana:dev`
+
### Dev config
Create a custom.ini in the conf directory to override default configuration options.
@@ -113,18 +125,6 @@ GRAFANA_TEST_DB=mysql go test ./pkg/...
GRAFANA_TEST_DB=postgres go test ./pkg/...
```
-## Building custom docker image
-
-You can build a custom image using Docker, which doesn't require installing any dependencies besides docker itself.
-```bash
-git clone https://github.com/grafana/grafana
-cd grafana
-docker build -t grafana:dev .
-docker run -d --name=grafana -p 3000:3000 grafana:dev
-```
-
-Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
-
## Contribute
If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
@@ -138,5 +138,5 @@ plugin development.
## License
-Grafana is distributed under Apache 2.0 License.
+Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE.md).
diff --git a/UPGRADING_DEPENDENCIES.md b/UPGRADING_DEPENDENCIES.md
new file mode 100644
index 00000000000..f3d2adbd71a
--- /dev/null
+++ b/UPGRADING_DEPENDENCIES.md
@@ -0,0 +1,89 @@
+# Guide to Upgrading Dependencies
+
+Upgrading Go or Node.js requires making changes in many different files. See below for a list and explanation for each.
+
+## Go
+
+- CircleCi
+- `grafana/build-container`
+- Appveyor
+- Dockerfile
+
+## Node.js
+
+- CircleCI
+- `grafana/build-container`
+- Appveyor
+- Dockerfile
+
+## Go Dependencies
+
+Updated using `dep`.
+
+- `Gopkg.toml`
+- `Gopkg.lock`
+
+## Node.js Dependencies
+
+Updated using `yarn`.
+
+- `package.json`
+
+## Where to make changes
+
+### CircleCI
+
+Our builds run on CircleCI through our build script.
+
+#### Files
+
+- `.circleci/config.yml`.
+
+#### Dependencies
+
+- nodejs
+- golang
+- grafana/build-container (our custom docker build container)
+
+### grafana/build-container
+
+The main build step (in CircleCI) is built using a custom build container that comes pre-baked with some of the neccesary dependencies.
+
+Link: [grafana-build-container](https://github.com/grafana/grafana-build-container)
+
+#### Dependencies
+
+- fpm
+- nodejs
+- golang
+- crosscompiling (several compilers)
+
+### Appveyor
+
+Master and release builds trigger test runs on Appveyors build environment so that tests will run on Windows.
+
+#### Files:
+
+- `appveyor.yml`
+
+#### Dependencies
+
+- nodejs
+- golang
+
+### Dockerfile
+
+There is a Docker build for Grafana in the root of the project that allows anyone to build Grafana just using Docker.
+
+#### Files
+
+- `Dockerfile`
+
+#### Dependencies
+
+- nodejs
+- golang
+
+### Local developer environments
+
+Please send out a notice in the grafana-dev slack channel when updating Go or Node.js to make it easier for everyone to update their local developer environments.
\ No newline at end of file
diff --git a/appveyor.yml b/appveyor.yml
index 52f23162033..4bbd3668e19 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -5,7 +5,7 @@ os: Windows Server 2012 R2
clone_folder: c:\gopath\src\github.com\grafana\grafana
environment:
- nodejs_version: "6"
+ nodejs_version: "8"
GOPATH: C:\gopath
GOVERSION: 1.11
diff --git a/build.go b/build.go
index 69fbf3bada8..b136754efbc 100644
--- a/build.go
+++ b/build.go
@@ -403,6 +403,10 @@ func gruntBuildArg(task string) []string {
if phjsToRelease != "" {
args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease))
}
+ if enterprise {
+ args = append(args, "--enterprise")
+ }
+
args = append(args, fmt.Sprintf("--platform=%v", goos))
return args
@@ -467,6 +471,7 @@ func ldflags() string {
b.WriteString(fmt.Sprintf(" -X main.version=%s", version))
b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha()))
b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp()))
+ b.WriteString(fmt.Sprintf(" -X main.buildBranch=%s", getGitBranch()))
return b.String()
}
@@ -514,6 +519,14 @@ func setBuildEnv() {
}
}
+func getGitBranch() string {
+ v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD")
+ if err != nil {
+ return "master"
+ }
+ return string(v)
+}
+
func getGitSha() string {
v, err := runError("git", "rev-parse", "--short", "HEAD")
if err != nil {
diff --git a/conf/defaults.ini b/conf/defaults.ini
index eb8debc0094..481bb002582 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -554,3 +554,10 @@ container_name =
# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
server_url =
callback_url =
+
+[panels]
+enable_alpha = false
+
+[enterprise]
+license_path =
+
diff --git a/conf/sample.ini b/conf/sample.ini
index f393c66a20e..61eb1d695e8 100644
--- a/conf/sample.ini
+++ b/conf/sample.ini
@@ -435,7 +435,7 @@ log_queries =
;sampler_param = 1
#################################### Grafana.com integration ##########################
-# Url used to to import dashboards directly from Grafana.com
+# Url used to import dashboards directly from Grafana.com
[grafana_com]
;url = https://grafana.com
@@ -475,3 +475,8 @@ log_queries =
# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
;server_url =
;callback_url =
+
+[enterprise]
+# Path to a valid Grafana Enterprise license.jwt file
+;license_path =
+
diff --git a/devenv/dev-dashboards/panel_tests_graph.json b/devenv/dev-dashboards/panel_tests_graph.json
index 8a1770f0fa6..ba677764a43 100644
--- a/devenv/dev-dashboards/panel_tests_graph.json
+++ b/devenv/dev-dashboards/panel_tests_graph.json
@@ -927,6 +927,123 @@
"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,
@@ -939,7 +1056,7 @@
"h": 7,
"w": 24,
"x": 0,
- "y": 44
+ "y": 51
},
"id": 20,
"legend": {
@@ -1024,7 +1141,7 @@
"h": 7,
"w": 12,
"x": 0,
- "y": 51
+ "y": 58
},
"id": 16,
"legend": {
@@ -1127,7 +1244,7 @@
"h": 7,
"w": 12,
"x": 12,
- "y": 51
+ "y": 58
},
"id": 17,
"legend": {
@@ -1266,7 +1383,7 @@
"h": 7,
"w": 12,
"x": 0,
- "y": 58
+ "y": 65
},
"id": 18,
"legend": {
@@ -1370,7 +1487,7 @@
"h": 7,
"w": 12,
"x": 12,
- "y": 58
+ "y": 65
},
"id": 19,
"legend": {
@@ -1554,5 +1671,5 @@
"timezone": "browser",
"title": "Panel Tests - Graph",
"uid": "5SdHCadmz",
- "version": 3
+ "version": 1
}
diff --git a/devenv/dev-dashboards/panel_tests_slow_queries_and_annotations.json b/devenv/dev-dashboards/panel_tests_slow_queries_and_annotations.json
new file mode 100644
index 00000000000..08bf6dce9d0
--- /dev/null
+++ b/devenv/dev-dashboards/panel_tests_slow_queries_and_annotations.json
@@ -0,0 +1,1166 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ },
+ {
+ "datasource": "-- Grafana --",
+ "enable": true,
+ "hide": false,
+ "iconColor": "rgba(255, 96, 96, 1)",
+ "limit": 100,
+ "matchAny": false,
+ "name": "annotations",
+ "showIn": 0,
+ "tags": [
+ "asd"
+ ],
+ "type": "tags"
+ }
+ ]
+ },
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "gdev-testdata",
+ "fill": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 13,
+ "x": 0,
+ "y": 0
+ },
+ "id": 6,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "30s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 11,
+ "x": 13,
+ "y": 0
+ },
+ "id": 7,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "30s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 0,
+ "y": 7
+ },
+ "id": 8,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "30s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 8,
+ "y": 7
+ },
+ "id": 18,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "30s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 7,
+ "w": 8,
+ "x": 16,
+ "y": 7
+ },
+ "id": 17,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "30s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 0,
+ "y": 14
+ },
+ "id": 10,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 8,
+ "y": 14
+ },
+ "id": 9,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 16,
+ "y": 14
+ },
+ "id": 11,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 0,
+ "y": 19
+ },
+ "id": 14,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 8,
+ "y": 19
+ },
+ "id": 15,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 16,
+ "y": 19
+ },
+ "id": 12,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 6,
+ "w": 16,
+ "x": 0,
+ "y": 24
+ },
+ "id": 13,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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": 1,
+ "gridPos": {
+ "h": 6,
+ "w": 8,
+ "x": 16,
+ "y": 24
+ },
+ "id": 16,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "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": "slow_query",
+ "stringInput": "5s"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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
+ }
+ }
+ ],
+ "schemaVersion": 16,
+ "style": "dark",
+ "tags": [],
+ "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 - Slow Queries & Annotations",
+ "uid": "xtY_uCAiz",
+ "version": 11
+}
diff --git a/devenv/docker/blocks/influxdb/docker-compose.yaml b/devenv/docker/blocks/influxdb/docker-compose.yaml
index 3434f5d09b9..e1727807d41 100644
--- a/devenv/docker/blocks/influxdb/docker-compose.yaml
+++ b/devenv/docker/blocks/influxdb/docker-compose.yaml
@@ -6,7 +6,7 @@
- "8083:8083"
- "8086:8086"
volumes:
- - ./blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
+ - ./docker/blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
fake-influxdb-data:
image: grafana/fake-data-gen
diff --git a/devenv/docker/ha_test/.gitignore b/devenv/docker/ha_test/.gitignore
new file mode 100644
index 00000000000..0f4e139e204
--- /dev/null
+++ b/devenv/docker/ha_test/.gitignore
@@ -0,0 +1 @@
+grafana/provisioning/dashboards/alerts/alert-*
\ No newline at end of file
diff --git a/devenv/docker/ha_test/README.md b/devenv/docker/ha_test/README.md
new file mode 100644
index 00000000000..bc93727ceae
--- /dev/null
+++ b/devenv/docker/ha_test/README.md
@@ -0,0 +1,137 @@
+# Grafana High Availability (HA) test setup
+
+A set of docker compose services which together creates a Grafana HA test setup with capability of easily
+scaling up/down number of Grafana instances.
+
+Included services
+
+* Grafana
+* Mysql - Grafana configuration database and session storage
+* Prometheus - Monitoring of Grafana and used as datasource of provisioned alert rules
+* Nginx - Reverse proxy for Grafana and Prometheus. Enables browsing Grafana/Prometheus UI using a hostname
+
+## Prerequisites
+
+### Build grafana docker container
+
+Build a Grafana docker container from current branch and commit and tag it as grafana/grafana:dev.
+
+```bash
+$ cd
+$ make build-docker-full
+```
+
+### Virtual host names
+
+#### Alternative 1 - Use dnsmasq
+
+```bash
+$ sudo apt-get install dnsmasq
+$ echo 'address=/loc/127.0.0.1' | sudo tee /etc/dnsmasq.d/dnsmasq-loc.conf > /dev/null
+$ sudo /etc/init.d/dnsmasq restart
+$ ping whatever.loc
+PING whatever.loc (127.0.0.1) 56(84) bytes of data.
+64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.076 ms
+--- whatever.loc ping statistics ---
+1 packet transmitted, 1 received, 0% packet loss, time 1998ms
+```
+
+#### Alternative 2 - Manually update /etc/hosts
+
+Update your `/etc/hosts` to be able to access Grafana and/or Prometheus UI using a hostname.
+
+```bash
+$ cat /etc/hosts
+127.0.0.1 grafana.loc
+127.0.0.1 prometheus.loc
+```
+
+## Start services
+
+```bash
+$ docker-compose up -d
+```
+
+Browse
+* http://grafana.loc/
+* http://prometheus.loc/
+
+Check for any errors
+
+```bash
+$ docker-compose logs | grep error
+```
+
+### Scale Grafana instances up/down
+
+Scale number of Grafana instances to ``
+
+```bash
+$ docker-compose up --scale grafana= -d
+# for example 3 instances
+$ docker-compose up --scale grafana=3 -d
+```
+
+## Test alerting
+
+### Create notification channels
+
+Creates default notification channels, if not already exists
+
+```bash
+$ ./alerts.sh setup
+```
+
+### Slack notifications
+
+Disable
+
+```bash
+$ ./alerts.sh slack -d
+```
+
+Enable and configure url
+
+```bash
+$ ./alerts.sh slack -u https://hooks.slack.com/services/...
+```
+
+Enable, configure url and enable reminders
+
+```bash
+$ ./alerts.sh slack -u https://hooks.slack.com/services/... -r -e 10m
+```
+
+### Provision alert dashboards with alert rules
+
+Provision 1 dashboard/alert rule (default)
+
+```bash
+$ ./alerts.sh provision
+```
+
+Provision 10 dashboards/alert rules
+
+```bash
+$ ./alerts.sh provision -a 10
+```
+
+Provision 10 dashboards/alert rules and change condition to `gt > 100`
+
+```bash
+$ ./alerts.sh provision -a 10 -c 100
+```
+
+### Pause/unpause all alert rules
+
+Pause
+
+```bash
+$ ./alerts.sh pause
+```
+
+Unpause
+
+```bash
+$ ./alerts.sh unpause
+```
diff --git a/devenv/docker/ha_test/alerts.sh b/devenv/docker/ha_test/alerts.sh
new file mode 100755
index 00000000000..a05a4581739
--- /dev/null
+++ b/devenv/docker/ha_test/alerts.sh
@@ -0,0 +1,156 @@
+#!/bin/bash
+
+requiresJsonnet() {
+ if ! type "jsonnet" > /dev/null; then
+ echo "you need you install jsonnet to run this script"
+ echo "follow the instructions on https://github.com/google/jsonnet"
+ exit 1
+ fi
+}
+
+setup() {
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' http://admin:admin@grafana.loc/api/alert-notifications/1)
+ if [ $STATUS -eq 200 ]; then
+ echo "Email already exists, skipping..."
+ else
+ curl -H "Content-Type: application/json" \
+ -d '{
+ "name": "Email",
+ "type": "email",
+ "isDefault": false,
+ "sendReminder": false,
+ "uploadImage": true,
+ "settings": {
+ "addresses": "user@test.com"
+ }
+ }' \
+ http://admin:admin@grafana.loc/api/alert-notifications
+ fi
+
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' http://admin:admin@grafana.loc/api/alert-notifications/2)
+ if [ $STATUS -eq 200 ]; then
+ echo "Slack already exists, skipping..."
+ else
+ curl -H "Content-Type: application/json" \
+ -d '{
+ "name": "Slack",
+ "type": "slack",
+ "isDefault": false,
+ "sendReminder": false,
+ "uploadImage": true
+ }' \
+ http://admin:admin@grafana.loc/api/alert-notifications
+ fi
+}
+
+slack() {
+ enabled=true
+ url=''
+ remind=false
+ remindEvery='10m'
+
+ while getopts ":e:u:dr" o; do
+ case "${o}" in
+ e)
+ remindEvery=${OPTARG}
+ ;;
+ u)
+ url=${OPTARG}
+ ;;
+ d)
+ enabled=false
+ ;;
+ r)
+ remind=true
+ ;;
+ esac
+ done
+ shift $((OPTIND-1))
+
+ curl -X PUT \
+ -H "Content-Type: application/json" \
+ -d '{
+ "id": 2,
+ "name": "Slack",
+ "type": "slack",
+ "isDefault": '$enabled',
+ "sendReminder": '$remind',
+ "frequency": "'$remindEvery'",
+ "uploadImage": true,
+ "settings": {
+ "url": "'$url'"
+ }
+ }' \
+ http://admin:admin@grafana.loc/api/alert-notifications/2
+}
+
+provision() {
+ alerts=1
+ condition=65
+ while getopts ":a:c:" o; do
+ case "${o}" in
+ a)
+ alerts=${OPTARG}
+ ;;
+ c)
+ condition=${OPTARG}
+ ;;
+ esac
+ done
+ shift $((OPTIND-1))
+
+ requiresJsonnet
+
+ rm -rf grafana/provisioning/dashboards/alerts/alert-*.json
+ jsonnet -m grafana/provisioning/dashboards/alerts grafana/provisioning/alerts.jsonnet --ext-code alerts=$alerts --ext-code condition=$condition
+}
+
+pause() {
+ curl -H "Content-Type: application/json" \
+ -d '{"paused":true}' \
+ http://admin:admin@grafana.loc/api/admin/pause-all-alerts
+}
+
+unpause() {
+ curl -H "Content-Type: application/json" \
+ -d '{"paused":false}' \
+ http://admin:admin@grafana.loc/api/admin/pause-all-alerts
+}
+
+usage() {
+ echo -e "Usage: ./alerts.sh COMMAND [OPTIONS]\n"
+ echo -e "Commands"
+ echo -e " setup\t\t creates default alert notification channels"
+ echo -e " slack\t\t configure slack notification channel"
+ echo -e " [-d]\t\t\t disable notifier, default enabled"
+ echo -e " [-u]\t\t\t url"
+ echo -e " [-r]\t\t\t send reminders"
+ echo -e " [-e ]\t\t default 10m\n"
+ echo -e " provision\t provision alerts"
+ echo -e " [-a ]\t default 1"
+ echo -e " [-c ]\t default 65\n"
+ echo -e " pause\t\t pause all alerts"
+ echo -e " unpause\t unpause all alerts"
+}
+
+main() {
+ local cmd=$1
+
+ if [[ $cmd == "setup" ]]; then
+ setup
+ elif [[ $cmd == "slack" ]]; then
+ slack "${@:2}"
+ elif [[ $cmd == "provision" ]]; then
+ provision "${@:2}"
+ elif [[ $cmd == "pause" ]]; then
+ pause
+ elif [[ $cmd == "unpause" ]]; then
+ unpause
+ fi
+
+ if [[ -z "$cmd" ]]; then
+ usage
+ fi
+}
+
+main "$@"
diff --git a/devenv/docker/ha_test/docker-compose.yaml b/devenv/docker/ha_test/docker-compose.yaml
new file mode 100644
index 00000000000..ce8630d88a4
--- /dev/null
+++ b/devenv/docker/ha_test/docker-compose.yaml
@@ -0,0 +1,78 @@
+version: "2.1"
+
+services:
+ nginx-proxy:
+ image: jwilder/nginx-proxy
+ ports:
+ - "80:80"
+ volumes:
+ - /var/run/docker.sock:/tmp/docker.sock:ro
+
+ db:
+ image: mysql
+ environment:
+ MYSQL_ROOT_PASSWORD: rootpass
+ MYSQL_DATABASE: grafana
+ MYSQL_USER: grafana
+ MYSQL_PASSWORD: password
+ ports:
+ - 3306
+ healthcheck:
+ test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
+ timeout: 10s
+ retries: 10
+
+ # db:
+ # image: postgres:9.3
+ # environment:
+ # POSTGRES_DATABASE: grafana
+ # POSTGRES_USER: grafana
+ # POSTGRES_PASSWORD: password
+ # ports:
+ # - 5432
+ # healthcheck:
+ # test: ["CMD-SHELL", "pg_isready -d grafana -U grafana"]
+ # timeout: 10s
+ # retries: 10
+
+ grafana:
+ image: grafana/grafana:dev
+ volumes:
+ - ./grafana/provisioning/:/etc/grafana/provisioning/
+ environment:
+ - VIRTUAL_HOST=grafana.loc
+ - GF_SERVER_ROOT_URL=http://grafana.loc
+ - GF_DATABASE_NAME=grafana
+ - GF_DATABASE_USER=grafana
+ - GF_DATABASE_PASSWORD=password
+ - GF_DATABASE_TYPE=mysql
+ - GF_DATABASE_HOST=db:3306
+ - GF_SESSION_PROVIDER=mysql
+ - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(db:3306)/grafana?allowNativePasswords=true
+ # - GF_DATABASE_TYPE=postgres
+ # - GF_DATABASE_HOST=db:5432
+ # - 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
+ ports:
+ - 3000
+ depends_on:
+ db:
+ condition: service_healthy
+
+ prometheus:
+ image: prom/prometheus:v2.4.2
+ volumes:
+ - ./prometheus/:/etc/prometheus/
+ environment:
+ - VIRTUAL_HOST=prometheus.loc
+ ports:
+ - 9090
+
+ # mysqld-exporter:
+ # image: prom/mysqld-exporter
+ # environment:
+ # - DATA_SOURCE_NAME=grafana:password@(mysql:3306)/
+ # ports:
+ # - 9104
diff --git a/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet b/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet
new file mode 100644
index 00000000000..86ded7e79d6
--- /dev/null
+++ b/devenv/docker/ha_test/grafana/provisioning/alerts.jsonnet
@@ -0,0 +1,202 @@
+local numAlerts = std.extVar('alerts');
+local condition = std.extVar('condition');
+local arr = std.range(1, numAlerts);
+
+local alertDashboardTemplate = {
+ "editable": true,
+ "gnetId": null,
+ "graphTooltip": 0,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "alert": {
+ "conditions": [
+ {
+ "evaluator": {
+ "params": [
+ 65
+ ],
+ "type": "gt"
+ },
+ "operator": {
+ "type": "and"
+ },
+ "query": {
+ "params": [
+ "A",
+ "5m",
+ "now"
+ ]
+ },
+ "reducer": {
+ "params": [],
+ "type": "avg"
+ },
+ "type": "query"
+ }
+ ],
+ "executionErrorState": "alerting",
+ "frequency": "10s",
+ "handler": 1,
+ "name": "bulk alerting",
+ "noDataState": "no_data",
+ "notifications": [
+ {
+ "id": 2
+ }
+ ]
+ },
+ "aliasColors": {},
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "Prometheus",
+ "fill": 1,
+ "gridPos": {
+ "h": 9,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "id": 2,
+ "legend": {
+ "avg": false,
+ "current": false,
+ "max": false,
+ "min": false,
+ "show": true,
+ "total": false,
+ "values": false
+ },
+ "lines": true,
+ "linewidth": 1,
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "$$hashKey": "object:117",
+ "expr": "go_goroutines",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "refId": "A"
+ }
+ ],
+ "thresholds": [
+ {
+ "colorMode": "critical",
+ "fill": true,
+ "line": true,
+ "op": "gt",
+ "value": 50
+ }
+ ],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Panel Title",
+ "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
+ }
+ ]
+ }
+ ],
+ "schemaVersion": 16,
+ "style": "dark",
+ "tags": [],
+ "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": "New dashboard",
+ "uid": null,
+ "version": 0
+};
+
+
+{
+ ['alert-' + std.toString(x) + '.json']:
+ alertDashboardTemplate + {
+ panels: [
+ alertDashboardTemplate.panels[0] +
+ {
+ alert+: {
+ name: 'Alert rule ' + x,
+ conditions: [
+ alertDashboardTemplate.panels[0].alert.conditions[0] +
+ {
+ evaluator+: {
+ params: [condition]
+ }
+ },
+ ],
+ },
+ },
+ ],
+ uid: 'alert-' + x,
+ title: 'Alert ' + x
+ },
+ for x in arr
+}
\ No newline at end of file
diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml
new file mode 100644
index 00000000000..60b6cd4bb04
--- /dev/null
+++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts.yaml
@@ -0,0 +1,8 @@
+apiVersion: 1
+
+providers:
+ - name: 'Alerts'
+ folder: 'Alerts'
+ type: file
+ options:
+ path: /etc/grafana/provisioning/dashboards/alerts
diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json
new file mode 100644
index 00000000000..53e33c37b1f
--- /dev/null
+++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json
@@ -0,0 +1,172 @@
+{
+ "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": {
+ "Active alerts": "#bf1b00"
+ },
+ "bars": false,
+ "dashLength": 10,
+ "dashes": false,
+ "datasource": "Prometheus",
+ "fill": 1,
+ "gridPos": {
+ "h": 12,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 2,
+ "interval": "",
+ "legend": {
+ "alignAsTable": true,
+ "avg": false,
+ "current": true,
+ "max": false,
+ "min": false,
+ "rightSide": true,
+ "show": true,
+ "total": false,
+ "values": true
+ },
+ "lines": true,
+ "linewidth": 2,
+ "links": [],
+ "nullPointMode": "null",
+ "percentage": false,
+ "pointradius": 5,
+ "points": false,
+ "renderer": "flot",
+ "seriesOverrides": [
+ {
+ "alias": "Active grafana instances",
+ "dashes": true,
+ "fill": 0
+ }
+ ],
+ "spaceLength": 10,
+ "stack": false,
+ "steppedLine": false,
+ "targets": [
+ {
+ "expr": "sum(increase(grafana_alerting_notification_sent_total[1m])) by(job)",
+ "format": "time_series",
+ "instant": false,
+ "interval": "1m",
+ "intervalFactor": 1,
+ "legendFormat": "Notifications sent",
+ "refId": "A"
+ },
+ {
+ "expr": "min(grafana_alerting_active_alerts) without(instance)",
+ "format": "time_series",
+ "interval": "1m",
+ "intervalFactor": 1,
+ "legendFormat": "Active alerts",
+ "refId": "B"
+ },
+ {
+ "expr": "count(up{job=\"grafana\"})",
+ "format": "time_series",
+ "intervalFactor": 1,
+ "legendFormat": "Active grafana instances",
+ "refId": "C"
+ }
+ ],
+ "thresholds": [],
+ "timeFrom": null,
+ "timeShift": null,
+ "title": "Notifications sent vs active alerts",
+ "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": "0",
+ "show": true
+ },
+ {
+ "format": "short",
+ "label": null,
+ "logBase": 1,
+ "max": null,
+ "min": null,
+ "show": true
+ }
+ ],
+ "yaxis": {
+ "align": false,
+ "alignLevel": 3
+ }
+ }
+ ],
+ "schemaVersion": 16,
+ "style": "dark",
+ "tags": [],
+ "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": "",
+ "title": "Overview",
+ "uid": "xHy7-hAik",
+ "version": 6
+}
\ No newline at end of file
diff --git a/devenv/docker/ha_test/grafana/provisioning/datasources/datasources.yaml b/devenv/docker/ha_test/grafana/provisioning/datasources/datasources.yaml
new file mode 100644
index 00000000000..8d59793be16
--- /dev/null
+++ b/devenv/docker/ha_test/grafana/provisioning/datasources/datasources.yaml
@@ -0,0 +1,11 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ jsonData:
+ timeInterval: 10s
+ queryTimeout: 30s
+ httpMethod: POST
\ No newline at end of file
diff --git a/devenv/docker/ha_test/prometheus/prometheus.yml b/devenv/docker/ha_test/prometheus/prometheus.yml
new file mode 100644
index 00000000000..ea97ba8ba05
--- /dev/null
+++ b/devenv/docker/ha_test/prometheus/prometheus.yml
@@ -0,0 +1,39 @@
+# my global config
+global:
+ scrape_interval: 10s # By default, scrape targets every 15 seconds.
+ evaluation_interval: 10s # By default, scrape targets every 15 seconds.
+ # scrape_timeout is set to the global default (10s).
+
+# Load and evaluate rules in this file every 'evaluation_interval' seconds.
+#rule_files:
+# - "alert.rules"
+# - "first.rules"
+# - "second.rules"
+
+# alerting:
+# alertmanagers:
+# - scheme: http
+# static_configs:
+# - targets:
+# - "127.0.0.1:9093"
+
+scrape_configs:
+ - job_name: 'prometheus'
+ static_configs:
+ - targets: ['localhost:9090']
+
+ - job_name: 'grafana'
+ dns_sd_configs:
+ - names:
+ - 'grafana'
+ type: 'A'
+ port: 3000
+ refresh_interval: 10s
+
+ # - job_name: 'mysql'
+ # dns_sd_configs:
+ # - names:
+ # - 'mysqld-exporter'
+ # type: 'A'
+ # port: 9104
+ # refresh_interval: 10s
\ No newline at end of file
diff --git a/docs/sources/administration/permissions.md b/docs/sources/administration/permissions.md
index 1d1a70607c8..0d374f03647 100644
--- a/docs/sources/administration/permissions.md
+++ b/docs/sources/administration/permissions.md
@@ -55,7 +55,7 @@ This admin flag makes a user a `Super Admin`. This means they can access the `Se
{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
For dashboards and dashboard folders there is a **Permissions** page that make it possible to
-remove the default role based permssions for Editors and Viewers. It's here you can add and assign permissions to specific **Users** and **Teams**.
+remove the default role based permissions for Editors and Viewers. It's here you can add and assign permissions to specific **Users** and **Teams**.
You can assign & remove permissions for **Organization Roles**, **Users** and **Teams**.
@@ -102,7 +102,7 @@ Permissions for a dashboard:
Result: You cannot override to a lower permission. `user1` has Admin permission as the highest permission always wins.
-- **View**: Can only view existing dashboars/folders.
+- **View**: Can only view existing dashboards/folders.
- You cannot override permissions for users with **Org Admin Role**
- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.
diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md
index 16d425d289a..9149aa42130 100644
--- a/docs/sources/administration/provisioning.md
+++ b/docs/sources/administration/provisioning.md
@@ -123,7 +123,7 @@ datasources:
withCredentials:
# mark as default datasource. Max one per org
isDefault:
- # fields that will be converted to json and stored in json_data
+ # fields that will be converted to json and stored in jsonData
jsonData:
graphiteVersion: "1.1"
tlsAuth: true
@@ -147,7 +147,7 @@ Please refer to each datasource documentation for specific provisioning examples
#### Json Data
-Since not all datasources have the same configuration settings we only have the most common ones as fields. The rest should be stored as a json blob in the `json_data` field. Here are the most common settings that the core datasources use.
+Since not all datasources have the same configuration settings we only have the most common ones as fields. The rest should be stored as a json blob in the `jsonData` field. Here are the most common settings that the core datasources use.
| Name | Type | Datasource | Description |
| ---- | ---- | ---- | ---- |
@@ -156,9 +156,9 @@ Since not all datasources have the same configuration settings we only have the
| tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
| graphiteVersion | string | Graphite | Graphite version |
| timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source |
-| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) |
-| timeField | string | Elastic | Which field that should be used as timestamp |
-| interval | string | Elastic | Index date time format |
+| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) |
+| timeField | string | Elasticsearch | Which field that should be used as timestamp |
+| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
| assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
| defaultRegion | string | Cloudwatch | AWS region |
@@ -166,8 +166,12 @@ Since not all datasources have the same configuration settings we only have the
| tsdbVersion | string | OpenTSDB | Version |
| tsdbResolution | string | OpenTSDB | Resolution |
| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
+| encrypt | string | MSSQL | Connection SSL encryption handling. 'disable', 'false' or 'true' |
| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
+| maxOpenConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of open connections to the database (Grafana v5.4+) |
+| maxIdleConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of connections in the idle connection pool (Grafana v5.4+) |
+| connMaxLifetime | number | MySQL, PostgreSQL & MSSQL | Maximum amount of time in seconds a connection may be reused (Grafana v5.4+) |
#### Secure Json Data
@@ -217,7 +221,7 @@ Note: The JSON shown in input field and when using `Copy JSON to Clipboard` and/
{{< docs-imagebox img="/img/docs/v51/provisioning_cannot_save_dashboard.png" max-width="500px" class="docs-image--no-shadow" >}}
-### Reuseable Dashboard Urls
+### Reusable Dashboard Urls
If the dashboard in the json file contains an [uid](/reference/dashboard/#json-fields), Grafana will force insert/update on that uid. This allows you to migrate dashboards betweens Grafana instances and provisioning Grafana from configuration without breaking the urls given since the new dashboard url uses the uid as identifier.
When Grafana starts, it will update/insert all dashboards available in the configured folders. If you modify the file, the dashboard will also be updated.
diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md
index a5b7f4264e0..b232ee78f27 100644
--- a/docs/sources/alerting/notifications.md
+++ b/docs/sources/alerting/notifications.md
@@ -128,7 +128,7 @@ Example json body:
In DingTalk PC Client:
-1. Click "more" icon on left bottom of the panel.
+1. Click "more" icon on upper right of the panel.
2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage".
@@ -140,7 +140,7 @@ In DingTalk PC Client:
6. There will be a Webhook URL in the panel, looks like this: https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx. Copy this URL to the grafana Dingtalk setting page and then click "finish".
-Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `text` message type is supported.
+Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `link` message type is supported.
### Kafka
diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md
index 5bb5c4cd753..6fa6531fc98 100644
--- a/docs/sources/auth/generic-oauth.md
+++ b/docs/sources/auth/generic-oauth.md
@@ -17,6 +17,9 @@ can find examples using Okta, BitBucket, OneLogin and Azure.
This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`.
+You may have to set the `root_url` option of `[server]` for the callback URL to be
+correct. For example in case you are serving Grafana behind a proxy.
+
Example config:
```bash
diff --git a/docs/sources/auth/github.md b/docs/sources/auth/github.md
index 263b3cc5d4d..b4ffc0fc2d4 100644
--- a/docs/sources/auth/github.md
+++ b/docs/sources/auth/github.md
@@ -46,6 +46,9 @@ team_ids =
allowed_organizations =
```
+You may have to set the `root_url` option of `[server]` for the callback URL to be
+correct. For example in case you are serving Grafana behind a proxy.
+
Restart the Grafana back-end. You should now see a GitHub login button
on the login page. You can now login or sign up with your GitHub
accounts.
diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md
index 32910167f16..56fc3b131a5 100644
--- a/docs/sources/auth/gitlab.md
+++ b/docs/sources/auth/gitlab.md
@@ -58,6 +58,9 @@ api_url = https://gitlab.com/api/v4
allowed_groups =
```
+You may have to set the `root_url` option of `[server]` for the callback URL to be
+correct. For example in case you are serving Grafana behind a proxy.
+
Restart the Grafana backend for your changes to take effect.
If you use your own instance of GitLab instead of `gitlab.com`, adjust
@@ -97,12 +100,12 @@ display name, especially if the display name contains spaces or special
characters. Make sure you always use the group or subgroup name as it appears
in the URL of the group or subgroup.
-Here's a complete example with `alloed_sign_up` enabled, and access limited to
+Here's a complete example with `allow_sign_up` enabled, and access limited to
the `example` and `foo/bar` groups:
```ini
[auth.gitlab]
-enabled = false
+enabled = true
allow_sign_up = true
client_id = GITLAB_APPLICATION_ID
client_secret = GITLAB_SECRET
diff --git a/docs/sources/auth/google.md b/docs/sources/auth/google.md
index eeb78044d3e..f7faf1a1097 100644
--- a/docs/sources/auth/google.md
+++ b/docs/sources/auth/google.md
@@ -45,6 +45,9 @@ allowed_domains = mycompany.com mycompany.org
allow_sign_up = true
```
+You may have to set the `root_url` option of `[server]` for the callback URL to be
+correct. For example in case you are serving Grafana behind a proxy.
+
Restart the Grafana back-end. You should now see a Google login button
on the login page. You can now login or sign up with your Google
accounts. The `allowed_domains` option is optional, and domains were separated by space.
diff --git a/docs/sources/auth/ldap.md b/docs/sources/auth/ldap.md
index 82db8214fb7..4a884a60d15 100644
--- a/docs/sources/auth/ldap.md
+++ b/docs/sources/auth/ldap.md
@@ -181,6 +181,7 @@ group_search_filter = "(member:1.2.840.113556.1.4.1941:=CN=%s,[user container/OU
group_search_filter = "(|(member:1.2.840.113556.1.4.1941:=CN=%s,[user container/OU])(member:1.2.840.113556.1.4.1941:=CN=%s,[another user container/OU]))"
group_search_filter_user_attribute = "cn"
```
+For more information on AD searches see [Microsoft's Search Filter Syntax](https://docs.microsoft.com/en-us/windows/desktop/adsi/search-filter-syntax) documentation.
For troubleshooting, by changing `member_of` in `[servers.attributes]` to "dn" it will show you more accurate group memberships when [debug is enabled](#troubleshooting).
diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md
index 20010a9ac09..a372600ac46 100644
--- a/docs/sources/auth/overview.md
+++ b/docs/sources/auth/overview.md
@@ -58,7 +58,7 @@ If you change your organization name in the Grafana UI this setting needs to be
### Basic authentication
Basic auth is enabled by default and works with the built in Grafana user password authentication system and LDAP
-authenticaten integration.
+authentication integration.
To disable basic auth:
diff --git a/docs/sources/contribute/cla.md b/docs/sources/contribute/cla.md
index ffb2aaef1b9..a073a9a4eae 100644
--- a/docs/sources/contribute/cla.md
+++ b/docs/sources/contribute/cla.md
@@ -101,4 +101,4 @@ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU [OR US]
-This CLA agreement is based on the [Harmony Contributor Aggrement Template (combined)](http://www.harmonyagreements.org/agreements.html), [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/)
+This CLA agreement is based on the [Harmony Contributor Agreement Template (combined)](http://www.harmonyagreements.org/agreements.html), [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/)
diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md
index 7adc6ebe4fb..be36d108475 100644
--- a/docs/sources/features/datasources/cloudwatch.md
+++ b/docs/sources/features/datasources/cloudwatch.md
@@ -46,7 +46,7 @@ Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGu
## IAM Policies
Grafana needs permissions granted via IAM to be able to read CloudWatch metrics
-and EC2 tags/instances. You can attach these permissions to IAM roles and
+and EC2 tags/instances/regions. You can attach these permissions to IAM roles and
utilize Grafana's built-in support for assuming roles.
Here is a minimal policy example:
@@ -65,11 +65,12 @@ Here is a minimal policy example:
"Resource": "*"
},
{
- "Sid": "AllowReadingTagsFromEC2",
+ "Sid": "AllowReadingTagsInstancesRegionsFromEC2",
"Effect": "Allow",
"Action": [
"ec2:DescribeTags",
- "ec2:DescribeInstances"
+ "ec2:DescribeInstances",
+ "ec2:DescribeRegions"
],
"Resource": "*"
}
diff --git a/docs/sources/features/datasources/mssql.md b/docs/sources/features/datasources/mssql.md
index debf771ffb0..cd191f14273 100644
--- a/docs/sources/features/datasources/mssql.md
+++ b/docs/sources/features/datasources/mssql.md
@@ -32,6 +32,10 @@ Name | Description
*Database* | Name of your MSSQL database.
*User* | Database user's login/username
*Password* | Database user's password
+*Encrypt* | This option determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server, default `false` (Grafana v5.4+).
+*Max open* | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+).
+*Max idle* | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+).
+*Max lifetime* | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+).
### Min time interval
@@ -69,8 +73,8 @@ Make sure the user does not get any unwanted privileges from the public role.
### Known Issues
-MSSQL 2008 and 2008 R2 engine cannot handle login records when SSL encryption is not disabled. Due to this you may receive an `Login error: EOF` error when trying to create your datasource.
-To fix MSSQL 2008 R2 issue, install MSSQL 2008 R2 Service Pack 2. To fix MSSQL 2008 issue, install Microsoft MSSQL 2008 Service Pack 3 and Cumulative update package 3 for MSSQL 2008 SP3.
+If you're using an older version of Microsoft SQL Server like 2008 and 2008R2 you may need to disable encryption to be able to connect.
+If possible, we recommend you to use the latest service pack available for optimal compatibility.
## Query Editor
@@ -225,7 +229,7 @@ When above query are used in a graph panel the result will be two series named `
{{< docs-imagebox img="/img/docs/v51/mssql_time_series_two.png" class="docs-image--no-shadow docs-image--right" >}}
-**Example with multiple `value` culumns:**
+**Example with multiple `value` columns:**
```sql
SELECT
@@ -585,6 +589,10 @@ datasources:
url: localhost:1433
database: grafana
user: grafana
+ jsonData:
+ maxOpenConns: 0 # Grafana v5.4+
+ maxIdleConns: 2 # Grafana v5.4+
+ connMaxLifetime: 14400 # Grafana v5.4+
secureJsonData:
password: "Password!"
diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md
index d713a4b42b7..bc4e4df6cf9 100644
--- a/docs/sources/features/datasources/mysql.md
+++ b/docs/sources/features/datasources/mysql.md
@@ -35,6 +35,9 @@ Name | Description
*Database* | Name of your MySQL database.
*User* | Database user's login/username
*Password* | Database user's password
+*Max open* | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+).
+*Max idle* | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+).
+*Max lifetime* | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours. This should always be lower than configured [wait_timeout](https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_wait_timeout) in MySQL (Grafana v5.4+).
### Min time interval
@@ -59,7 +62,7 @@ Identifier | Description
The database user you specify when you add the data source should only be granted SELECT permissions on
the specified database & tables you want to query. Grafana does not validate that the query is safe. The query
could include any SQL statement. For example, statements like `USE otherdb;` and `DROP TABLE user;` would be
-executed. To protect against this we **Highly** recommmend you create a specific mysql user with restricted permissions.
+executed. To protect against this we **Highly** recommend you create a specific mysql user with restricted permissions.
Example:
@@ -70,6 +73,58 @@ Example:
You can use wildcards (`*`) in place of database or table if you want to grant access to more databases and tables.
+## Query Editor
+
+> Only available in Grafana v5.4+.
+
+{{< docs-imagebox img="/img/docs/v54/mysql_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v54/mysql_query.gif" >}}
+
+You find the MySQL query editor in the metrics tab in a panel's edit mode. You enter edit mode by clicking the
+panel title, then edit.
+
+The query editor has a link named `Generated SQL` that shows up after a query has been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed.
+
+### Select table, time column and metric column (FROM)
+
+When you enter edit mode for the first time or add a new query Grafana will try to prefill the query builder with the first table that has a timestamp column and a numeric column.
+
+In the FROM field, Grafana will suggest tables that are in the configured database. To select a table or view in another database that your database user has access to you can manually enter a fully qualified name (database.table) like `otherDb.metrics`.
+
+The Time column field refers to the name of the column holding your time values. Selecting a value for the Metric column field is optional. If a value is selected, the Metric column field will be used as the series name.
+
+The metric column suggestions will only contain columns with a text datatype (text, tinytext, mediumtext, longtext, varchar, char).
+If you want to use a column with a different datatype as metric column you may enter the column name with a cast: `CAST(numericColumn as CHAR)`.
+You may also enter arbitrary SQL expressions in the metric column field that evaluate to a text datatype like
+`CONCAT(column1, " ", CAST(numericColumn as CHAR))`.
+
+### Columns and Aggregation functions (SELECT)
+
+In the `SELECT` row you can specify what columns and functions you want to use.
+In the column field you may write arbitrary expressions instead of a column name like `column1 * column2 / column3`.
+
+If you use aggregate functions you need to group your resultset. The editor will automatically add a `GROUP BY time` if you add an aggregate function.
+
+You may add further value columns by clicking the plus button and selecting `Column` from the menu. Multiple value columns will be plotted as separate series in the graph panel.
+
+### Filter data (WHERE)
+To add a filter click the plus icon to the right of the `WHERE` condition. You can remove filters by clicking on
+the filter and selecting `Remove`. A filter for the current selected timerange is automatically added to new queries.
+
+### Group By
+To group by time or any other columns click the plus icon at the end of the GROUP BY row. The suggestion dropdown will only show text columns of your currently selected table but you may manually enter any column.
+You can remove the group by clicking on the item and then selecting `Remove`.
+
+If you add any grouping, all selected columns need to have an aggregate function applied. The query builder will automatically add aggregate functions to all columns without aggregate functions when you add groupings.
+
+#### Gap Filling
+
+Grafana can fill in missing values when you group by time. The time function accepts two arguments. The first argument is the time window that you would like to group by, and the second argument is the value you want Grafana to fill missing items with.
+
+### Text Editor Mode (RAW)
+You can switch to the raw query editor mode by clicking the hamburger icon and selecting `Switch editor mode` or by clicking `Edit SQL` below the query.
+
+> If you use the raw query editor, be sure your query at minimum has `ORDER BY time` and a filter on the returned time range.
+
## Macros
To simplify syntax and to allow for dynamic parts, like date range filters, the query can contain macros.
@@ -316,4 +371,8 @@ datasources:
database: grafana
user: grafana
password: password
+ jsonData:
+ maxOpenConns: 0 # Grafana v5.4+
+ maxIdleConns: 2 # Grafana v5.4+
+ connMaxLifetime: 14400 # Grafana v5.4+
```
diff --git a/docs/sources/features/datasources/opentsdb.md b/docs/sources/features/datasources/opentsdb.md
index 1f6f022a18c..d2cd0b1dc0e 100644
--- a/docs/sources/features/datasources/opentsdb.md
+++ b/docs/sources/features/datasources/opentsdb.md
@@ -84,7 +84,7 @@ Some examples are mentioned below to make nested template queries work successfu
Query | Description
------------ | -------------
*tag_values(cpu, hostname, env=$env)* | Return tag values for cpu metric, selected env tag value and tag key hostname
-*tag_values(cpu, hostanme, env=$env, region=$region)* | Return tag values for cpu metric, selected env tag value, selected region tag value and tag key hostname
+*tag_values(cpu, hostname, env=$env, region=$region)* | Return tag values for cpu metric, selected env tag value, selected region tag value and tag key hostname
For details on OpenTSDB metric queries checkout the official [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html)
diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md
index 7076ff033b3..52f8804f27f 100644
--- a/docs/sources/features/datasources/postgres.md
+++ b/docs/sources/features/datasources/postgres.md
@@ -31,6 +31,9 @@ Name | Description
*User* | Database user's login/username
*Password* | Database user's password
*SSL Mode* | This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server.
+*Max open* | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+).
+*Max idle* | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+).
+*Max lifetime* | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+).
*Version* | This option determines which functions are available in the query builder (only available in Grafana 5.3+).
*TimescaleDB* | TimescaleDB is a time-series database built as a PostgreSQL extension. If enabled, Grafana will use `time_bucket` in the `$__timeGroup` macro and display TimescaleDB specific aggregate functions in the query builder (only available in Grafana 5.3+).
@@ -374,6 +377,9 @@ datasources:
password: "Password!"
jsonData:
sslmode: "disable" # disable/require/verify-ca/verify-full
+ maxOpenConns: 0 # Grafana v5.4+
+ maxIdleConns: 2 # Grafana v5.4+
+ connMaxLifetime: 14400 # Grafana v5.4+
postgresVersion: 903 # 903=9.3, 904=9.4, 905=9.5, 906=9.6, 1000=10
timescaledb: false
```
diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md
new file mode 100644
index 00000000000..d19dbe4ea50
--- /dev/null
+++ b/docs/sources/features/datasources/stackdriver.md
@@ -0,0 +1,253 @@
++++
+title = "Using Stackdriver in Grafana"
+description = "Guide for using Stackdriver in Grafana"
+keywords = ["grafana", "stackdriver", "google", "guide"]
+type = "docs"
+aliases = ["/datasources/stackdriver"]
+[menu.docs]
+name = "Stackdriver"
+parent = "datasources"
+weight = 11
++++
+
+# Using Google Stackdriver in Grafana
+
+> Only available in Grafana v5.3+.
+> The datasource is currently a beta feature and is subject to change.
+
+Grafana ships with built-in support for Google Stackdriver. Just add it as a datasource and you are ready to build dashboards for your Stackdriver metrics.
+
+## Adding the data source to Grafana
+
+1. Open the side menu by clicking the Grafana icon in the top header.
+2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`.
+3. Click the `+ Add data source` button in the top header.
+4. Select `Stackdriver` from the _Type_ dropdown.
+5. Upload or paste in the Service Account Key file. See below for steps on how to create a Service Account Key file.
+
+> NOTE: If you're not seeing the `Data Sources` link in your side menu it means that your current user does not have the `Admin` role for the current organization.
+
+| Name | Description |
+| --------------------- | ----------------------------------------------------------------------------------- |
+| _Name_ | The datasource name. This is how you refer to the datasource in panels & queries. |
+| _Default_ | Default datasource means that it will be pre-selected for new panels. |
+| _Service Account Key_ | Service Account Key File for a GCP Project. Instructions below on how to create it. |
+
+## Authentication
+
+There are two ways to authenticate the Stackdriver plugin - either by uploading a Google JWT file, or by automatically retrieving credentials from Google metadata server. The latter option is only available when running Grafana on GCE virtual machine.
+
+### Using a Google Service Account Key File
+
+To authenticate with the Stackdriver API, you need to create a Google Cloud Platform (GCP) Service Account for the Project you want to show data for. A Grafana datasource integrates with one GCP Project. If you want to visualize data from multiple GCP Projects then you need to create one datasource per GCP Project.
+
+#### Enable APIs
+
+The following APIs need to be enabled first:
+
+* [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com)
+* [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com)
+
+Click on the links above and click the `Enable` button:
+
+{{< docs-imagebox img="/img/docs/v53/stackdriver_enable_api.png" class="docs-image--no-shadow" caption="Enable GCP APIs" >}}
+
+#### Create a GCP Service Account for a Project
+
+1. Navigate to the [APIs & Services Credentials page](https://console.cloud.google.com/apis/credentials).
+2. Click on the `Create credentials` dropdown/button and choose the `Service account key` option.
+
+ {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_button.png" class="docs-image--no-shadow" caption="Create service account button" >}}
+
+3. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option:
+
+ {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_key.png" class="docs-image--no-shadow" caption="Create service account key" >}}
+
+4. Some new fields will appear. Fill in a name for the service account in the `Service account name` field and then choose the `Monitoring Viewer` role from the `Role` dropdown:
+
+ {{< docs-imagebox img="/img/docs/v53/stackdriver_service_account_choose_role.png" class="docs-image--no-shadow" caption="Choose role" >}}
+
+5. Click the Create button. A JSON key file will be created and downloaded to your computer. Store this file in a secure place as it allows access to your Stackdriver data.
+6. Upload it to Grafana on the datasource Configuration page. You can either upload the file or paste in the contents of the file.
+
+ {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_upload_key.png" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}}
+
+7. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file!
+
+ {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_key_uploaded.png" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}}
+
+### Using GCE Default Service Account
+
+If Grafana is running on a Google Compute Engine (GCE) virtual machine, it is possible for Grafana to automatically retrieve default credentials from the metadata server. This has the advantage of not needing to generate a private key file for the service account and also not having to upload the file to Grafana. However for this to work, there are a few preconditions that need to be met.
+
+1. First of all, you need to create a Service Account that can be used by the GCE virtual machine. See detailed instructions on how to do that [here](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount).
+2. Make sure the GCE virtual machine instance is being run as the service account that you just created. See instructions [here](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#using).
+3. Allow access to the `Stackdriver Monitoring API` scope. See instructions [here](changeserviceaccountandscopes).
+
+Read more about creating and enabling service accounts for GCE VM instances [here](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances).
+
+## Metric Query Editor
+
+{{< docs-imagebox img="/img/docs/v53/stackdriver_query_editor.png" max-width= "400px" class="docs-image--right" >}}
+
+The Stackdriver query editor allows you to select metrics, group/aggregate by labels and by time, and use filters to specify which time series you want in the results.
+
+Begin by choosing a `Service` and then a metric from the `Metric` dropdown. Use the plus and minus icons in the filter and group by sections to add/remove filters or group by clauses.
+
+Stackdriver metrics can be of different kinds (GAUGE, DELTA, CUMULATIVE) and these kinds have support for different aggregation options (reducers and aligners). The Grafana query editor shows the list of available aggregation methods for a selected metric and sets a default reducer and aligner when you select the metric. Units for the Y-axis are also automatically selected by the query editor.
+
+### Filter
+
+To add a filter, click the plus icon and choose a field to filter by and enter a filter value e.g. `instance_name = grafana-1`. You can remove the filter by clicking on the filter name and select `--remove filter--`.
+
+#### Simple wildcards
+
+When the operator is set to `=` or `!=` it is possible to add wildcards to the filter value field. E.g `us-*` will capture all values that starts with "us-" and `*central-a` will capture all values that ends with "central-a". `*-central-*` captures all values that has the substring of -central-. Simple wildcards are less expensive than regular expressions.
+
+#### Regular expressions
+
+When the operator is set to `=~` or `!=~` it is possible to add regular expressions to the filter value field. E.g `us-central[1-3]-[af]` would match all values that starts with "us-central", is followed by a number in the range of 1 to 3, a dash and then either an "a" or an "f". Leading and trailing slashes are not needed when creating regular expressions.
+
+### Aggregation
+
+The aggregation field lets you combine time series based on common statistics. Read more about this option [here](https://cloud.google.com/monitoring/charts/metrics-selector#aggregation-options).
+
+The `Aligner` field allows you to align multiple time series after the same group by time interval. Read more about how it works [here](https://cloud.google.com/monitoring/charts/metrics-selector#alignment).
+
+#### Alignment Period/Group by Time
+
+The `Alignment Period` groups a metric by time if an aggregation is chosen. The default is to use the GCP Stackdriver default groupings (which allows you to compare graphs in Grafana with graphs in the Stackdriver UI).
+The option is called `Stackdriver auto` and the defaults are:
+
+* 1m for time ranges < 23 hours
+* 5m for time ranges >= 23 hours and < 6 days
+* 1h for time ranges >= 6 days
+
+The other automatic option is `Grafana auto`. This will automatically set the group by time depending on the time range chosen and the width of the graph panel. Read more about the details [here](http://docs.grafana.org/reference/templating/#the-interval-variable).
+
+It is also possible to choose fixed time intervals to group by, like `1h` or `1d`.
+
+### Group By
+
+Group by resource or metric labels to reduce the number of time series and to aggregate the results by a group by. E.g. Group by instance_name to see an aggregated metric for a Compute instance.
+
+### Alias Patterns
+
+The Alias By field allows you to control the format of the legend keys. The default is to show the metric name and labels. This can be long and hard to read. Using the following patterns in the alias field, you can format the legend key the way you want it.
+
+#### Metric Type Patterns
+
+| Alias Pattern | Description | Example Result |
+| -------------------- | ---------------------------- | ------------------------------------------------- |
+| `{{metric.type}}` | returns the full Metric Type | `compute.googleapis.com/instance/cpu/utilization` |
+| `{{metric.name}}` | returns the metric name part | `instance/cpu/utilization` |
+| `{{metric.service}}` | returns the service part | `compute` |
+
+#### Label Patterns
+
+In the Group By dropdown, you can see a list of metric and resource labels for a metric. These can be included in the legend key using alias patterns.
+
+| Alias Pattern Format | Description | Alias Pattern Example | Example Result |
+| ------------------------ | -------------------------------- | -------------------------------- | ---------------- |
+| `{{metric.label.xxx}}` | returns the metric label value | `{{metric.label.instance_name}}` | `grafana-1-prod` |
+| `{{resource.label.xxx}}` | returns the resource label value | `{{resource.label.zone}}` | `us-east1-b` |
+
+Example Alias By: `{{metric.type}} - {{metric.labels.instance_name}}`
+
+Example Result: `compute.googleapis.com/instance/cpu/usage_time - server1-prod`
+
+It is also possible to resolve the name of the Monitored Resource Type.
+
+| Alias Pattern Format | Description | Example Result |
+| ------------------------ | ------------------------------------------------| ---------------- |
+| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` |
+
+Example Alias By: `{{resource.type}} - {{metric.type}}`
+
+Example Result: `gce_instance - compute.googleapis.com/instance/cpu/usage_time`
+
+## Templating
+
+Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place.
+Variables are shown as dropdown select boxes at the top of the dashboard. These dropdowns makes it easy to change the data
+being displayed in your dashboard.
+
+Checkout the [Templating]({{< relref "reference/templating.md" >}}) documentation for an introduction to the templating feature and the different
+types of template variables.
+
+### Query Variable
+
+Writing variable queries is not supported yet.
+
+### Using variables in queries
+
+There are two syntaxes:
+
+* `$` Example: `metric.label.$metric_label`
+* `[[varname]]` Example: `metric.label.[[metric_label]]`
+
+Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the _Multi-value_ or _Include all value_ options are enabled, Grafana converts the labels from plain text to a regex compatible string, which means you have to use `=~` instead of `=`.
+
+## Annotations
+
+{{< docs-imagebox img="/img/docs/v53/stackdriver_annotations_query_editor.png" max-width= "400px" class="docs-image--right" >}}
+
+[Annotations]({{< relref "reference/annotations.md" >}}) allows you to overlay rich event information on top of graphs. You add annotation
+queries via the Dashboard menu / Annotations view. Annotation rendering is expensive so it is important to limit the number of rows returned. There is no support for showing Stackdriver annotations and events yet but it works well with [custom metrics](https://cloud.google.com/monitoring/custom-metrics/) in Stackdriver.
+
+With the query editor for annotations, you can select a metric and filters. The `Title` and `Text` fields support templating and can use data returned from the query. For example, the Title field could have the following text:
+
+`{{metric.type}} has value: {{metric.value}}`
+
+Example Result: `monitoring.googleapis.com/uptime_check/http_status has this value: 502`
+
+### Patterns for the Annotation Query Editor
+
+| Alias Pattern Format | Description | Alias Pattern Example | Example Result |
+| ------------------------ | -------------------------------- | -------------------------------- | ------------------------------------------------- |
+| `{{metric.value}}` | value of the metric/point | `{{metric.value}}` | `555` |
+| `{{metric.type}}` | returns the full Metric Type | `{{metric.type}}` | `compute.googleapis.com/instance/cpu/utilization` |
+| `{{metric.name}}` | returns the metric name part | `{{metric.name}}` | `instance/cpu/utilization` |
+| `{{metric.service}}` | returns the service part | `{{metric.service}}` | `compute` |
+| `{{metric.label.xxx}}` | returns the metric label value | `{{metric.label.instance_name}}` | `grafana-1-prod` |
+| `{{resource.label.xxx}}` | returns the resource label value | `{{resource.label.zone}}` | `us-east1-b` |
+
+## Configure the Datasource with Provisioning
+
+It's now possible to configure datasources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources)
+
+Here is a provisioning example using the JWT (Service Account key file) authentication type.
+
+```yaml
+apiVersion: 1
+
+datasources:
+ - name: Stackdriver
+ type: stackdriver
+ access: proxy
+ jsonData:
+ tokenUri: https://oauth2.googleapis.com/token
+ clientEmail: stackdriver@myproject.iam.gserviceaccount.com
+ authenticationType: jwt
+ defaultProject: my-project-name
+ secureJsonData:
+ privateKey: |
+ -----BEGIN PRIVATE KEY-----
+ POSEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCb1u1Srw8ICYHS
+ ...
+ yA+23427282348234=
+ -----END PRIVATE KEY-----
+```
+
+Here is a provisioning example using GCE Default Service Account authentication.
+
+```yaml
+apiVersion: 1
+
+datasources:
+ - name: Stackdriver
+ type: stackdriver
+ access: proxy
+ jsonData:
+ authenticationType: gce
+```
diff --git a/docs/sources/features/panels/alertlist.md b/docs/sources/features/panels/alertlist.md
index 58aa2c0966a..a1ea8f0f600 100644
--- a/docs/sources/features/panels/alertlist.md
+++ b/docs/sources/features/panels/alertlist.md
@@ -22,6 +22,6 @@ The alert list panel allows you to display your dashboards alerts. The list can
1. **Show**: Lets you choose between current state or recent state changes.
2. **Max Items**: Max items set the maximum of items in a list.
-3. **Sort Order**: Lets you sort your list alphabeticaly(asc/desc) or by importance.
+3. **Sort Order**: Lets you sort your list alphabetically(asc/desc) or by importance.
4. **Alerts From** This Dashboard`: Shows alerts only from the dashboard the alert list is in.
5. **State Filter**: Here you can filter your list by one or more parameters.
diff --git a/docs/sources/features/panels/heatmap.md b/docs/sources/features/panels/heatmap.md
index 56ffe29f20f..aa87fbef1df 100644
--- a/docs/sources/features/panels/heatmap.md
+++ b/docs/sources/features/panels/heatmap.md
@@ -80,7 +80,7 @@ the upper or lower bound of the interval.
There are a number of datasources supporting histogram over time like Elasticsearch (by using a Histogram bucket
aggregation) or Prometheus (with [histogram](https://prometheus.io/docs/concepts/metric_types/#histogram) metric type
and *Format as* option set to Heatmap). But generally, any datasource could be used if it meets the requirements:
-returns series with names representing bucket bound or returns sereis sorted by the bound in ascending order.
+returns series with names representing bucket bound or returns series sorted by the bound in ascending order.
With Elasticsearch you control the size of the buckets using the Histogram interval (Y-Axis) and the Date Histogram interval (X-axis).
diff --git a/docs/sources/guides/whats-new-in-v2-5.md b/docs/sources/guides/whats-new-in-v2-5.md
index 90270ea1121..08d51ba5bd7 100644
--- a/docs/sources/guides/whats-new-in-v2-5.md
+++ b/docs/sources/guides/whats-new-in-v2-5.md
@@ -25,7 +25,7 @@ correctly in UTC mode.
This release brings a fully featured query editor for Elasticsearch. You will now be able to visualize
-logs or any kind of data stored in Elasticserarch. The query editor allows you to build both simple
+logs or any kind of data stored in Elasticsearch. The query editor allows you to build both simple
and complex queries for logs or metrics.
- Compute metrics from your documents, supported Elasticsearch aggregations:
diff --git a/docs/sources/guides/whats-new-in-v2.md b/docs/sources/guides/whats-new-in-v2.md
index 499849c8d83..28d068b1cd6 100644
--- a/docs/sources/guides/whats-new-in-v2.md
+++ b/docs/sources/guides/whats-new-in-v2.md
@@ -34,7 +34,7 @@ Organizations via a role. That role can be:
There are currently no permissions on individual dashboards.
-Read more about Grafanas new user model on the [Admin section](../reference/admin/)
+Read more about Grafana's new user model on the [Admin section](../reference/admin/)
## Dashboard Snapshot sharing
diff --git a/docs/sources/guides/whats-new-in-v3-1.md b/docs/sources/guides/whats-new-in-v3-1.md
index 1e8ef87297b..ab6c5281275 100644
--- a/docs/sources/guides/whats-new-in-v3-1.md
+++ b/docs/sources/guides/whats-new-in-v3-1.md
@@ -21,7 +21,7 @@ The export feature is now accessed from the share menu.
Dashboards exported from Grafana 3.1 are now more portable and easier for others to import than before.
The export process extracts information data source types used by panels and adds these to a new `inputs`
section in the dashboard json. So when you or another person tries to import the dashboard they will be asked to
-select data source and optional metrix prefix options.
+select data source and optional metric prefix options.
@@ -53,7 +53,7 @@ Grafana url to share with a colleague without having to use the Share modal.
## Internal metrics
-Do you want metrics about viewing metrics? Ofc you do! In this release we added support for sending metrics about Grafana to graphite.
+Do you want metrics about viewing metrics? Of course you do! In this release we added support for sending metrics about Grafana to graphite.
You can configure interval and server in the config file.
## Logging
diff --git a/docs/sources/guides/whats-new-in-v3.md b/docs/sources/guides/whats-new-in-v3.md
index d82a833ec90..dbd9b685a2b 100644
--- a/docs/sources/guides/whats-new-in-v3.md
+++ b/docs/sources/guides/whats-new-in-v3.md
@@ -197,7 +197,7 @@ you can install it manually from [Grafana.com](https://grafana.com)
## Plugin showcase
Discovering and installing plugins is very quick and easy with Grafana 3.0 and [Grafana.com](https://grafana.com). Here
-are a couple that I incurage you try!
+are a couple that I encourage you try!
#### [Clock Panel](https://grafana.com/plugins/grafana-clock-panel)
Support's both current time and count down mode.
diff --git a/docs/sources/guides/whats-new-in-v4-2.md b/docs/sources/guides/whats-new-in-v4-2.md
index e36e762bb76..7a00023172a 100644
--- a/docs/sources/guides/whats-new-in-v4-2.md
+++ b/docs/sources/guides/whats-new-in-v4-2.md
@@ -45,7 +45,7 @@ We might add more global built in variables in the future and if we do we will p
### Dedupe alert notifications when running multiple servers
-In this release we will dedupe alert notificiations when you are running multiple servers.
+In this release we will dedupe alert notifications when you are running multiple servers.
This makes it possible to run alerting on multiple servers and only get one notification.
We currently solve this with sql transactions which puts some limitations for how many servers you can use to execute the same rules.
diff --git a/docs/sources/guides/whats-new-in-v4-5.md b/docs/sources/guides/whats-new-in-v4-5.md
index a5cd3ca982d..c6cfcf64720 100644
--- a/docs/sources/guides/whats-new-in-v4-5.md
+++ b/docs/sources/guides/whats-new-in-v4-5.md
@@ -45,7 +45,7 @@ More information [here](https://community.grafana.com/t/using-grafanas-query-ins
### Enhancements
* **GitHub OAuth**: Support for GitHub organizations with 100+ teams. [#8846](https://github.com/grafana/grafana/issues/8846), thx [@skwashd](https://github.com/skwashd)
-* **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboad time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055)
+* **Graphite**: Calls to Graphite api /metrics/find now include panel or dashboard time range (from & until) in most cases, [#8055](https://github.com/grafana/grafana/issues/8055)
* **Graphite**: Added new graphite 1.0 functions, available if you set version to 1.0.x in data source settings. New Functions: mapSeries, reduceSeries, isNonNull, groupByNodes, offsetToZero, grep, weightedAverage, removeEmptySeries, aggregateLine, averageOutsidePercentile, delay, exponentialMovingAverage, fallbackSeries, integralByInterval, interpolate, invert, linearRegression, movingMin, movingMax, movingSum, multiplySeriesWithWildcards, pow, powSeries, removeBetweenPercentile, squareRoot, timeSlice, closes [#8261](https://github.com/grafana/grafana/issues/8261)
- **Elasticsearch**: Ad-hoc filters now use query phrase match filters instead of term filters, works on non keyword/raw fields [#9095](https://github.com/grafana/grafana/issues/9095).
@@ -53,7 +53,7 @@ More information [here](https://community.grafana.com/t/using-grafanas-query-ins
* **InfluxDB/Elasticsearch**: The panel & data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit.
-This option is now rennamed (and moved to Options sub section above your queries):
+This option is now renamed (and moved to Options sub section above your queries):

Datas source selection & options & help are now above your metric queries.
diff --git a/docs/sources/guides/whats-new-in-v4-6.md b/docs/sources/guides/whats-new-in-v4-6.md
index ee0c4ea7a04..91fa74084a8 100644
--- a/docs/sources/guides/whats-new-in-v4-6.md
+++ b/docs/sources/guides/whats-new-in-v4-6.md
@@ -61,7 +61,7 @@ This makes exploring and filtering Prometheus data much easier.
### Minor Changes
* **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319)
-* **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250)
+* **Dataproxy**: Allow Grafana to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250)
* **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367)
* **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739)
* **Slack**: Allow images to be uploaded to slack when Token is present [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8)
diff --git a/docs/sources/guides/whats-new-in-v5-3.md b/docs/sources/guides/whats-new-in-v5-3.md
index 4a2674c9b39..5dcadc0813d 100644
--- a/docs/sources/guides/whats-new-in-v5-3.md
+++ b/docs/sources/guides/whats-new-in-v5-3.md
@@ -12,6 +12,80 @@ weight = -9
# What's New in Grafana v5.3
+Grafana v5.3 brings new features, many enhancements and bug fixes. This article will detail the major new features and enhancements.
+
+- [Google Stackdriver]({{< relref "#google-stackdriver" >}}) as a core datasource!
+- [TV mode]({{< relref "#tv-and-kiosk-mode" >}}) is improved and more accessible
+- [Alerting]({{< relref "#notification-reminders" >}}) with notification reminders
+- [Postgres]({{< relref "#postgres-query-builder" >}}) gets a new query builder!
+- [OAuth]({{< relref "#improved-oauth-support-for-gitlab" >}}) support for Gitlab is improved
+- [Annotations]({{< relref "#annotations" >}}) with template variable filtering
+- [Variables]({{< relref "#variables" >}}) with free text support
+
+## Google Stackdriver
+
+{{< docs-imagebox img="/img/docs/v53/stackdriver-with-heatmap.png" max-width= "600px" class="docs-image--no-shadow docs-image--right" >}}
+
+Grafana v5.3 ships with built-in support for [Google Stackdriver](https://cloud.google.com/stackdriver/) and enables you to visualize your Stackdriver metrics in Grafana.
+
+Getting started with the plugin is easy. Simply create a GCE Service account that has access to the Stackdriver API scope, download the Service Account key file from Google and upload it on the Stackdriver datasource config page in Grafana and you should have a secure server-to-server authentication setup. Like other core plugins, Stackdriver has built-in support for alerting. It also comes with support for heatmaps and basic variables.
+
+If you're already accustomed to the Stackdriver Metrics Explorer UI, you'll notice that there are a lot of similarities to the query editor in Grafana. It is possible to add filters using wildcards and regular expressions. You can do Group By, Primary Aggregation and Alignment.
+
+Alias By allows you to format the legend the way you want, and it's a feature that is not yet present in the Metrics Explorer. Two other features that are only supported in the Grafana plugin are the abilities to manually set the Alignment Period in the query editor and to add Annotations queries.
+
+The Grafana Stackdriver plugin comes with support for automatic unit detection. Grafana will try to map the Stackdriver unit type to a corresponding unit type in Grafana, and if successful the panel Y-axes will be updated accordingly to display the correct unit of measure. This is the first core plugin to provide support for unit detection, and it is our intention to provide support for this in other core plugins in the near future.
+
+The datasource is still in the `beta` phase, meaning it's currently in active development and is still missing one important feature - templating queries.
+Please try it out, but be aware of that it might be subject to changes and possible bugs. We would love to hear your feedback.
+
+Please read [Using Google Stackdriver in Grafana](/features/datasources/stackdriver/) for more detailed information on how to get started and use it.
+
+## TV and Kiosk Mode
+
+{{< docs-imagebox img="/img/docs/v53/tv_mode_still.png" max-width="600px" class="docs-image--no-shadow docs-image--right" animated-gif="/img/docs/v53/tv_mode.gif" >}}
+
+We've improved the TV & kiosk mode to make it easier to use. There's now an icon in the top bar that will let you cycle through the different view modes.
+
+1. In the first view mode, the sidebar and most of the buttons in the top bar will be hidden.
+2. In the second view mode, the top bar is completely hidden so that only the dashboard itself is shown.
+3. Hit the escape key to go back to the default view mode.
+
+When switching view modes, the url will be updated to reflect the view mode selected. This allows a dashboard to be opened with a
+certain view mode enabled. Additionally, this also enables [playlists](/reference/playlist) to be started with a certain view mode enabled.
+
+
+
+## Notification Reminders
+
+Do you use Grafana alerting and have some notifications that are more important than others? Then it's possible to set reminders so that you continue to be alerted until the problem is fixed. This is done on the notification channel itself and will affect all alerts that use that channel.
+For additional examples of why reminders might be useful for you, see [multiple series](/alerting/rules/#multiple-series).
+
+Learn how to enable and configure reminders [here](/alerting/notifications/#send-reminders).
+
+## Postgres Query Builder
+
+Grafana 5.3 comes with a new graphical query builder for Postgres. This brings Postgres integration more in line with some of the other datasources and makes it easier for both advanced users and beginners to work with timeseries in Postgres. Learn more about it in the [documentation](/features/datasources/postgres/#query-editor).
+
+{{< docs-imagebox img="/img/docs/v53/postgres_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v53/postgres_query.gif" >}}
+
+## Improved OAuth Support for Gitlab
+
+Grafana 5.3 comes with a new OAuth integration for Gitlab that enables configuration to only allow users that are a member of certain Gitlab groups to authenticate. This makes it possible to use Gitlab OAuth with Grafana in a shared environment without giving everyone access to Grafana.
+Learn how to enable and configure it in the [documentation](/auth/gitlab/).
+
+## Annotations
+
+Grafana 5.3 brings improved support for [native annotations](/reference/annotations/#native-annotations) and makes it possible to use template variables when filtering by tags.
+Learn more about it in the [documentation](/reference/annotations/#query-by-tag).
+
+{{< docs-imagebox img="/img/docs/v53/annotation_tag_filter_variable.png" max-width="600px" >}}
+
+## Variables
+
+Grafana 5.3 ships with a brand new variable type named `Text box` which makes it easier and more convenient to provide free text input to a variable.
+This new variable type will display as a free text input field with an optional prefilled default value.
+
## Changelog
Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list
diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md
index 032fd508dd0..2d70a6d2017 100644
--- a/docs/sources/http_api/alerting.md
+++ b/docs/sources/http_api/alerting.md
@@ -227,7 +227,7 @@ Content-Type: application/json
## Create alert notification
-You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page.
+You can find the full list of [supported notifiers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page.
`POST /api/alert-notifications`
@@ -290,7 +290,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"sendReminder": true,
"frequency": "15m",
"settings": {
- "addresses: "carl@grafana.com;dev@grafana.com"
+ "addresses": "carl@grafana.com;dev@grafana.com"
}
}
```
diff --git a/docs/sources/http_api/dashboard_versions.md b/docs/sources/http_api/dashboard_versions.md
index 3d0ec27a3a3..0be22674997 100644
--- a/docs/sources/http_api/dashboard_versions.md
+++ b/docs/sources/http_api/dashboard_versions.md
@@ -291,7 +291,7 @@ Content-Type: text/html; charset=UTF-8
```
-The response is a textual respresentation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab.
+The response is a textual representation of the diff, with the dashboard values being in JSON, similar to the diffs seen on sites like GitHub or GitLab.
Status Codes:
diff --git a/docs/sources/index.md b/docs/sources/index.md
index da977b73e0c..e9a900d93f1 100644
--- a/docs/sources/index.md
+++ b/docs/sources/index.md
@@ -60,9 +60,9 @@ aliases = ["v1.1", "guides/reference/admin"]
Provisioning
A guide to help you automate your Grafana setup & configuration.
- }}" class="nav-cards__item nav-cards__item--guide">
- What's new in v5.2
- Article on all the new cool features and enhancements in v5.2
+ }}" class="nav-cards__item nav-cards__item--guide">
+ What's new in v5.3
+ Article on all the new cool features and enhancements in v5.3
}}" class="nav-cards__item nav-cards__item--guide">
Screencasts
@@ -88,9 +88,13 @@ aliases = ["v1.1", "guides/reference/admin"]
Prometheus
- }}" class="nav-cards__item nav-cards__item--ds">
-
- OpenTSDB
+ }}" class="nav-cards__item nav-cards__item--ds">
+
+ Google Stackdriver
+
+ }}" class="nav-cards__item nav-cards__item--ds">
+
+ Cloudwatch
}}" class="nav-cards__item nav-cards__item--ds">
@@ -100,8 +104,12 @@ aliases = ["v1.1", "guides/reference/admin"]
Postgres
- }}" class="nav-cards__item nav-cards__item--ds">
-
- Cloudwatch
+ }}" class="nav-cards__item nav-cards__item--ds">
+
+ Microsoft SQL Server
+
+ }}" class="nav-cards__item nav-cards__item--ds">
+
+ OpenTSDB
diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md
index 5a838e8a321..8d156e739bf 100644
--- a/docs/sources/installation/configuration.md
+++ b/docs/sources/installation/configuration.md
@@ -127,10 +127,13 @@ Another way is put a webserver like Nginx or Apache in front of Grafana and have
### protocol
-`http` or `https`
+`http`,`https` or `socket`
> **Note** Grafana versions earlier than 3.0 are vulnerable to [POODLE](https://en.wikipedia.org/wiki/POODLE). So we strongly recommend to upgrade to 3.x or use a reverse proxy for ssl termination.
+### socket
+Path where the socket should be created when `protocol=socket`. Please make sure that Grafana has appropriate permissions.
+
### domain
This setting is only used in as a part of the `root_url` setting (see below). Important if you
diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md
index 13fa3440170..7ed44572533 100644
--- a/docs/sources/installation/debian.md
+++ b/docs/sources/installation/debian.md
@@ -28,7 +28,7 @@ installation.
```bash
wget
sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_5.1.4_amd64.deb
+sudo dpkg -i grafana__amd64.deb
```
Example:
@@ -100,6 +100,8 @@ This will start the `grafana-server` process as the `grafana` user,
which was created during the package installation. The default HTTP port
is `3000` and default user and group is `admin`.
+Default login and password `admin`/ `admin`
+
To configure the Grafana server to start at boot time:
```bash
diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md
index ba0d6199ba4..52353ede8c2 100644
--- a/docs/sources/installation/docker.md
+++ b/docs/sources/installation/docker.md
@@ -87,7 +87,7 @@ docker run \
## Building a custom Grafana image with pre-installed plugins
-In the [grafana-docker](https://github.com/grafana/grafana-docker/) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments.
+In the [grafana-docker](https://github.com/grafana/grafana/tree/master/packaging/docker) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments.
Example of how to build and run:
```bash
@@ -103,6 +103,21 @@ docker run \
grafana:latest-with-plugins
```
+## Installing Plugins from other sources
+
+> Only available in Grafana v5.3.1+
+
+It's possible to install plugins from custom url:s by specifying the url like this: `GF_INSTALL_PLUGINS=;`
+
+```bash
+docker run \
+ -d \
+ -p 3000:3000 \
+ --name=grafana \
+ -e "GF_INSTALL_PLUGINS=http://plugin-domain.com/my-custom-plugin.zip;custom-plugin" \
+ grafana/grafana
+```
+
## Configuring AWS Credentials for CloudWatch Support
```bash
diff --git a/docs/sources/installation/mac.md b/docs/sources/installation/mac.md
index fbc00c01737..336e46c895d 100644
--- a/docs/sources/installation/mac.md
+++ b/docs/sources/installation/mac.md
@@ -60,6 +60,8 @@ Then start Grafana using:
brew services start grafana
```
+Default login and password `admin`/ `admin`
+
### Configuration
diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md
index 24c301c5763..5bf3b7ed745 100644
--- a/docs/sources/installation/rpm.md
+++ b/docs/sources/installation/rpm.md
@@ -115,6 +115,8 @@ This will start the `grafana-server` process as the `grafana` user,
which is created during package installation. The default HTTP port is
`3000`, and default user and group is `admin`.
+Default login and password `admin`/ `admin`
+
To configure the Grafana server to start at boot time:
```bash
diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md
index 572081a1c54..b17d625a76e 100644
--- a/docs/sources/installation/windows.md
+++ b/docs/sources/installation/windows.md
@@ -31,6 +31,9 @@ on windows. Edit `custom.ini` and uncomment the `http_port`
configuration option (`;` is the comment character in ini files) and change it to something like `8080` or similar.
That port should not require extra Windows privileges.
+Default login and password `admin`/ `admin`
+
+
Start Grafana by executing `grafana-server.exe`, located in the `bin` directory, preferably from the
command line. If you want to run Grafana as windows service, download
[NSSM](https://nssm.cc/). It is very easy to add Grafana as a Windows
diff --git a/docs/sources/plugins/developing/development.md b/docs/sources/plugins/developing/development.md
index f2e70a50c6a..48410b06732 100644
--- a/docs/sources/plugins/developing/development.md
+++ b/docs/sources/plugins/developing/development.md
@@ -10,7 +10,7 @@ weight = 1
# Developer Guide
-You can extend Grafana by writing your own plugins and then share then with other users in [our plugin repository](https://grafana.com/plugins).
+You can extend Grafana by writing your own plugins and then share them with other users in [our plugin repository](https://grafana.com/plugins).
## Short version
@@ -33,7 +33,7 @@ There are two blog posts about authoring a plugin that might also be of interest
## What languages?
Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since
-we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages.
+we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo if you choose one of those languages.
## Buildscript
@@ -60,7 +60,6 @@ and [apps]({{< relref "apps.md" >}}) plugins in the documentation.
The Grafana SDK is quite small so far and can be found here:
- [SDK file in Grafana](https://github.com/grafana/grafana/blob/master/public/app/plugins/sdk.ts)
-- [SDK Readme](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md)
The SDK contains three different plugin classes: PanelCtrl, MetricsPanelCtrl and QueryCtrl. For plugins of the panel type, the module.js file should export one of these. There are some extra classes for [data sources]({{< relref "datasources.md" >}}).
diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md
index ea75b9797e8..eed05f05fa6 100644
--- a/docs/sources/project/building_from_source.md
+++ b/docs/sources/project/building_from_source.md
@@ -13,7 +13,7 @@ dev environment. Grafana ships with its own required backend server; also comple
## Dependencies
-- [Go 1.11](https://golang.org/dl/)
+- [Go (Latest Stable)](https://golang.org/dl/)
- [Git](https://git-scm.com/downloads)
- [NodeJS LTS](https://nodejs.org/download/)
- node-gyp is the Node.js native addon build tool and it requires extra dependencies: python 2.7, make and GCC. These are already installed for most Linux distros and MacOS. See the Building On Windows section or the [node-gyp installation instructions](https://github.com/nodejs/node-gyp#installation) for more details.
diff --git a/docs/sources/reference/annotations.md b/docs/sources/reference/annotations.md
index 3bb50f4badf..8732c8c709b 100644
--- a/docs/sources/reference/annotations.md
+++ b/docs/sources/reference/annotations.md
@@ -47,7 +47,9 @@ can still show them if you add a new **Annotation Query** and filter by tags. Bu
You can create new annotation queries that fetch annotations from the native annotation store via the `-- Grafana --` data source and by setting *Filter by* to `Tags`. Specify at least
one tag. For example create an annotation query name `outages` and specify a tag named `outage`. This query will show all annotations you create (from any dashboard or via API) that have the `outage` tag. By default, if you add multiple tags in the annotation query, Grafana will only show annotations that have all the tags you supplied. You can invert the behavior by enabling `Match any` which means that Grafana will show annotations that contains at least one of the tags you supplied.
-In 5.4+ it's possible to use template variables in the tag query. So if you have a dashboard showing stats for different services and an template variable that dictates which services to show, you can now use the same template variable in your annotation query to only show annotations for those services.
+In Grafana v5.3+ it's possible to use template variables in the tag query. So if you have a dashboard showing stats for different services and a template variable that dictates which services to show, you can now use the same template variable in your annotation query to only show annotations for those services.
+
+{{< docs-imagebox img="/img/docs/v53/annotation_tag_filter_variable.png" max-width="600px" >}}
## Querying other data sources
diff --git a/docs/sources/reference/templating.md b/docs/sources/reference/templating.md
index 31251fd6389..403dabba8ae 100644
--- a/docs/sources/reference/templating.md
+++ b/docs/sources/reference/templating.md
@@ -90,6 +90,7 @@ Type | Description
*Custom* | Define the variable options manually using a comma separated list.
*Constant* | Define a hidden constant. Useful for metric path prefixes for dashboards you want to share. During dashboard export, constant variables will be made into an import option.
*Ad hoc filters* | Very special kind of variable that only works with some data sources, InfluxDB & Elasticsearch currently. It allows you to add key/value filters that will automatically be added to all metric queries that use the specified data source.
+*Text box* | This variable type will display as a free text input field with an optional default value.
### Query options
diff --git a/docs/sources/tutorials/ha_setup.md b/docs/sources/tutorials/ha_setup.md
index 5fdb091a348..f141392e223 100644
--- a/docs/sources/tutorials/ha_setup.md
+++ b/docs/sources/tutorials/ha_setup.md
@@ -26,9 +26,9 @@ Grafana will now persist all long term data in the database. How to configure th
## User sessions
-The second thing to consider is how to deal with user sessions and how to configure your load balancer infront of Grafana.
+The second thing to consider is how to deal with user sessions and how to configure your load balancer in front of Grafana.
Grafana supports two ways of storing session data: locally on disk or in a database/cache-server.
-If you want to store sessions on disk you can use `sticky sessions` in your load balanacer. If you prefer to store session data in a database/cache-server
+If you want to store sessions on disk you can use `sticky sessions` in your load balancer. If you prefer to store session data in a database/cache-server
you can use any stateless routing strategy in your load balancer (ex round robin or least connections).
### Sticky sessions
diff --git a/docs/sources/tutorials/index.md b/docs/sources/tutorials/index.md
index cb11940c6dd..90410e901d3 100644
--- a/docs/sources/tutorials/index.md
+++ b/docs/sources/tutorials/index.md
@@ -1,5 +1,6 @@
+++
title = "Tutorials"
+type = "docs"
[menu.docs]
identifier = "tutorials"
weight = 6
@@ -11,7 +12,11 @@ This section of the docs contains a series for tutorials and stack setup guides.
## Articles
-- [How to integrate Hubot with Grafana](hubot_howto.md)
+- [Running Grafana behind a reverse proxy]({{< relref "behind_proxy.md" >}})
+- [API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization]({{< relref "api_org_token_howto.md" >}})
+- [How to Use IIS with URL Rewrite as a Reverse Proxy for Grafana on Windows]({{< relref "iis.md" >}})
+- [How to integrate Hubot with Grafana]({{< relref "hubot_howto.md" >}})
+- [How to setup Grafana for high availability]({{< relref "ha_setup.md" >}})
## External links
diff --git a/docs/versions.json b/docs/versions.json
index caefbe198d6..48962a783ae 100644
--- a/docs/versions.json
+++ b/docs/versions.json
@@ -1,5 +1,6 @@
[
- { "version": "v5.2", "path": "/", "archived": false, "current": true },
+ { "version": "v5.3", "path": "/", "archived": false, "current": true },
+ { "version": "v5.2", "path": "/v5.2", "archived": true },
{ "version": "v5.1", "path": "/v5.1", "archived": true },
{ "version": "v5.0", "path": "/v5.0", "archived": true },
{ "version": "v4.6", "path": "/v4.6", "archived": true },
diff --git a/latest.json b/latest.json
index bce09c3283b..992af0a8336 100644
--- a/latest.json
+++ b/latest.json
@@ -1,4 +1,4 @@
{
- "stable": "5.2.4",
- "testing": "5.2.4"
+ "stable": "5.3.2",
+ "testing": "5.3.2"
}
diff --git a/package.json b/package.json
index 1e7ed02c87b..68b7647fb58 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
- "version": "5.3.0-pre1",
+ "version": "5.4.0-pre1",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"
@@ -12,11 +12,12 @@
"devDependencies": {
"@types/d3": "^4.10.1",
"@types/enzyme": "^3.1.13",
- "@types/jest": "^21.1.4",
+ "@types/jest": "^23.3.2",
"@types/node": "^8.0.31",
"@types/react": "^16.4.14",
"@types/react-custom-scrollbars": "^4.0.5",
"@types/react-dom": "^16.0.7",
+ "@types/react-select": "^2.0.4",
"angular-mocks": "1.6.6",
"autoprefixer": "^6.4.0",
"axios": "^0.17.1",
@@ -46,6 +47,7 @@
"grunt-contrib-copy": "~1.0.0",
"grunt-contrib-cssmin": "~1.0.2",
"grunt-exec": "^1.0.1",
+ "grunt-newer": "^1.3.0",
"grunt-notify": "^0.4.5",
"grunt-postcss": "^0.8.0",
"grunt-sass": "^2.0.0",
@@ -79,14 +81,14 @@
"style-loader": "^0.21.0",
"systemjs": "0.20.19",
"systemjs-plugin-css": "^0.1.36",
- "ts-jest": "^23.1.4",
+ "ts-jest": "^23.10.4",
"ts-loader": "^5.1.0",
"tslib": "^1.9.3",
"tslint": "^5.8.0",
"tslint-loader": "^3.5.3",
"typescript": "^3.0.3",
"uglifyjs-webpack-plugin": "^1.2.7",
- "webpack": "^4.8.0",
+ "webpack": "4.19.1",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-cleanup-plugin": "^0.5.1",
"webpack-cli": "^2.1.4",
@@ -157,8 +159,9 @@
"react-highlight-words": "^0.10.0",
"react-popper": "^0.7.5",
"react-redux": "^5.0.7",
- "react-select": "^1.1.0",
+ "react-select": "2.1.0",
"react-sizeme": "^2.3.6",
+ "react-table": "^6.8.6",
"react-transition-group": "^2.2.1",
"redux": "^4.0.0",
"redux-logger": "^3.0.6",
diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile
index 890d6a4fb11..dc8972b0ba0 100644
--- a/packaging/docker/Dockerfile
+++ b/packaging/docker/Dockerfile
@@ -25,7 +25,7 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi
WORKDIR $GF_PATHS_HOME
-RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates && \
+RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates curl && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh
new file mode 100755
index 00000000000..2f59e436d95
--- /dev/null
+++ b/packaging/docker/build-enterprise.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -e
+
+_grafana_tag=$1
+_docker_repo=${2:-grafana/grafana-enterprise}
+
+docker build \
+ --tag "${_docker_repo}:${_grafana_tag}"\
+ --no-cache=true \
+ .
+
+docker push "${_docker_repo}:${_grafana_tag}"
diff --git a/packaging/release_process.md b/packaging/release_process.md
deleted file mode 100644
index 6037a9c499c..00000000000
--- a/packaging/release_process.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# New Grafana Release Processes
-
-## Building release packages
-
-1) Update package.json so that it has the right version.
-2) Create a git tag for the release: `git tag -a v3.0.4 -m "3.0.4 release"`
-3) Push branch & tag to github!
-2) Packages from master a built automatically by circle CI for this repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer)
-
-### Non master branch
-
-When building from non master branch create a new branch in repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer)
-and configure circle.yml to deploy that branch as well, https://github.com/grafana/grafana-packer/blob/master/circle.yml#L25,
-you also need to update https://github.com/grafana/grafana-packer/blob/v3.1.x/deploy.sh#L7.
-
-### Windows build
-
-Sign into ci.appveyor.com and the Grafana project's build history page. Builds for windows take a long time (around 20min)
-and fail quite often for random reasons so I usually continue with the release process without a windows build already built.
-
-1) Click on the green build that has the correct version and tag
-2) Click on `DEPLOYMENTS`
-3) Click on `NEW DEPLOYMENT`
-4) Select GrafanaBuildS3
-4) Select the build you want to deploy.
-
-The deployment should be quick (just uploads the release zip file to S3)
-
-
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 39b332aeb9f..f1fe940e416 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -10,10 +10,10 @@ import (
)
func (hs *HTTPServer) registerRoutes() {
- reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})
- reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
- reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
- reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
+ reqSignedIn := middleware.ReqSignedIn
+ reqGrafanaAdmin := middleware.ReqGrafanaAdmin
+ reqEditorRole := middleware.ReqEditorRole
+ reqOrgAdmin := middleware.ReqOrgAdmin
redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
quota := middleware.Quota
@@ -22,66 +22,66 @@ func (hs *HTTPServer) registerRoutes() {
r := hs.RouteRegister
// not logged in views
- r.Get("/", reqSignedIn, Index)
+ 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("/login", LoginView)
- r.Get("/invite/:code", Index)
+ r.Get("/login", hs.LoginView)
+ r.Get("/invite/:code", hs.Index)
// authed views
- r.Get("/profile/", reqSignedIn, Index)
- r.Get("/profile/password", reqSignedIn, Index)
- r.Get("/profile/switch-org/:id", reqSignedIn, ChangeActiveOrgAndRedirectToHome)
- r.Get("/org/", reqSignedIn, Index)
- r.Get("/org/new", reqSignedIn, Index)
- r.Get("/datasources/", reqSignedIn, Index)
- r.Get("/datasources/new", reqSignedIn, Index)
- r.Get("/datasources/edit/*", reqSignedIn, Index)
- r.Get("/org/users", reqSignedIn, Index)
- r.Get("/org/users/new", reqSignedIn, Index)
- r.Get("/org/users/invite", reqSignedIn, Index)
- r.Get("/org/teams", reqSignedIn, Index)
- r.Get("/org/teams/*", reqSignedIn, Index)
- r.Get("/org/apikeys/", reqSignedIn, Index)
- r.Get("/dashboard/import/", reqSignedIn, Index)
- r.Get("/configuration", reqGrafanaAdmin, Index)
- r.Get("/admin", reqGrafanaAdmin, Index)
- r.Get("/admin/settings", reqGrafanaAdmin, Index)
- r.Get("/admin/users", reqGrafanaAdmin, Index)
- r.Get("/admin/users/create", reqGrafanaAdmin, Index)
- r.Get("/admin/users/edit/:id", reqGrafanaAdmin, Index)
- r.Get("/admin/orgs", reqGrafanaAdmin, Index)
- r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index)
- r.Get("/admin/stats", reqGrafanaAdmin, Index)
+ r.Get("/profile/", reqSignedIn, hs.Index)
+ r.Get("/profile/password", reqSignedIn, hs.Index)
+ r.Get("/profile/switch-org/:id", reqSignedIn, hs.ChangeActiveOrgAndRedirectToHome)
+ r.Get("/org/", reqSignedIn, hs.Index)
+ r.Get("/org/new", reqSignedIn, hs.Index)
+ r.Get("/datasources/", reqSignedIn, hs.Index)
+ r.Get("/datasources/new", reqSignedIn, hs.Index)
+ r.Get("/datasources/edit/*", reqSignedIn, hs.Index)
+ r.Get("/org/users", reqSignedIn, hs.Index)
+ r.Get("/org/users/new", reqSignedIn, hs.Index)
+ r.Get("/org/users/invite", reqSignedIn, hs.Index)
+ r.Get("/org/teams", reqSignedIn, hs.Index)
+ r.Get("/org/teams/*", reqSignedIn, hs.Index)
+ r.Get("/org/apikeys/", reqSignedIn, hs.Index)
+ r.Get("/dashboard/import/", reqSignedIn, hs.Index)
+ r.Get("/configuration", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin/settings", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin/users", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin/users/create", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin/users/edit/:id", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index)
+ r.Get("/admin/stats", reqGrafanaAdmin, hs.Index)
- r.Get("/styleguide", reqSignedIn, Index)
+ r.Get("/styleguide", reqSignedIn, hs.Index)
- r.Get("/plugins", reqSignedIn, Index)
- r.Get("/plugins/:id/edit", reqSignedIn, Index)
- r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
+ r.Get("/plugins", reqSignedIn, hs.Index)
+ r.Get("/plugins/:id/edit", reqSignedIn, hs.Index)
+ r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index)
- r.Get("/d/:uid/:slug", reqSignedIn, Index)
- r.Get("/d/:uid", reqSignedIn, Index)
- r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, Index)
- r.Get("/dashboard/script/*", reqSignedIn, Index)
- r.Get("/dashboard-solo/snapshot/*", Index)
- r.Get("/d-solo/:uid/:slug", reqSignedIn, Index)
- r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, Index)
- r.Get("/dashboard-solo/script/*", reqSignedIn, Index)
- r.Get("/import/dashboard", reqSignedIn, Index)
- r.Get("/dashboards/", reqSignedIn, Index)
- r.Get("/dashboards/*", reqSignedIn, Index)
+ r.Get("/d/:uid/:slug", reqSignedIn, hs.Index)
+ r.Get("/d/:uid", reqSignedIn, hs.Index)
+ r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, hs.Index)
+ r.Get("/dashboard/script/*", reqSignedIn, hs.Index)
+ r.Get("/dashboard-solo/snapshot/*", hs.Index)
+ r.Get("/d-solo/:uid/:slug", reqSignedIn, hs.Index)
+ r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, hs.Index)
+ r.Get("/dashboard-solo/script/*", reqSignedIn, hs.Index)
+ r.Get("/import/dashboard", reqSignedIn, hs.Index)
+ r.Get("/dashboards/", reqSignedIn, hs.Index)
+ r.Get("/dashboards/*", reqSignedIn, hs.Index)
- r.Get("/explore", reqEditorRole, Index)
+ r.Get("/explore", reqEditorRole, hs.Index)
- r.Get("/playlists/", reqSignedIn, Index)
- r.Get("/playlists/*", reqSignedIn, Index)
- r.Get("/alerting/", reqSignedIn, Index)
- r.Get("/alerting/*", reqSignedIn, Index)
+ r.Get("/playlists/", reqSignedIn, hs.Index)
+ r.Get("/playlists/*", reqSignedIn, hs.Index)
+ r.Get("/alerting/", reqSignedIn, hs.Index)
+ r.Get("/alerting/*", reqSignedIn, hs.Index)
// sign up
- r.Get("/signup", Index)
+ 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))
@@ -91,15 +91,15 @@ func (hs *HTTPServer) registerRoutes() {
r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
// reset password
- r.Get("/user/password/send-reset-email", Index)
- r.Get("/user/password/reset", Index)
+ r.Get("/user/password/send-reset-email", hs.Index)
+ r.Get("/user/password/reset", hs.Index)
r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), Wrap(SendResetPasswordEmail))
r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), Wrap(ResetPassword))
// dashboard snapshots
- r.Get("/dashboard/snapshot/*", Index)
- r.Get("/dashboard/snapshots/", reqSignedIn, Index)
+ r.Get("/dashboard/snapshot/*", hs.Index)
+ r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index)
// api for dashboard snapshots
r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
@@ -234,13 +234,13 @@ func (hs *HTTPServer) registerRoutes() {
datasourceRoute.Get("/", Wrap(GetDataSources))
datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource))
datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource))
- datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceByID))
+ datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById))
datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName))
- datasourceRoute.Get("/:id", Wrap(GetDataSourceByID))
+ datasourceRoute.Get("/:id", Wrap(GetDataSourceById))
datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName))
}, reqOrgAdmin)
- apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIDByName), reqSignedIn)
+ apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIdByName), reqSignedIn)
apiRoute.Get("/plugins", Wrap(GetPluginList))
apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID))
@@ -251,7 +251,7 @@ func (hs *HTTPServer) registerRoutes() {
pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
}, reqOrgAdmin)
- apiRoute.Get("/frontend/settings/", GetFrontendSettings)
+ apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go
index 5becf90ca35..6cf164285bf 100644
--- a/pkg/api/avatar/avatar.go
+++ b/pkg/api/avatar/avatar.go
@@ -97,15 +97,6 @@ type CacheServer struct {
cache *gocache.Cache
}
-func (this *CacheServer) mustInt(r *http.Request, defaultValue int, keys ...string) (v int) {
- for _, k := range keys {
- if _, err := fmt.Sscanf(r.FormValue(k), "%d", &v); err == nil {
- defaultValue = v
- }
- }
- return defaultValue
-}
-
func (this *CacheServer) Handler(ctx *macaron.Context) {
urlPath := ctx.Req.URL.Path
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go
index d65598f6e5e..02248334b9c 100644
--- a/pkg/api/dashboard.go
+++ b/pkg/api/dashboard.go
@@ -6,6 +6,7 @@ import (
"os"
"path"
+ "github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -251,8 +252,8 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response {
return Error(403, err.Error(), err)
}
- if err == m.ErrDashboardContainsInvalidAlertData {
- return Error(500, "Invalid alert data. Cannot save dashboard", err)
+ if validationErr, ok := err.(alerting.ValidationError); ok {
+ return Error(422, validationErr.Error(), nil)
}
if err != nil {
diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go
index 283a9b5f12c..2726623c242 100644
--- a/pkg/api/dashboard_test.go
+++ b/pkg/api/dashboard_test.go
@@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/setting"
@@ -725,7 +726,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
{SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412},
{SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400},
{SaveError: m.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: 400},
- {SaveError: m.ErrDashboardContainsInvalidAlertData, ExpectedStatusCode: 500},
+ {SaveError: alerting.ValidationError{Reason: "Mu"}, ExpectedStatusCode: 422},
{SaveError: m.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500},
{SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500},
{SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400},
diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go
index f455d3dbd29..3bb2f236129 100644
--- a/pkg/api/dataproxy.go
+++ b/pkg/api/dataproxy.go
@@ -2,6 +2,7 @@ package api
import (
"fmt"
+ "github.com/pkg/errors"
"time"
"github.com/grafana/grafana/pkg/api/pluginproxy"
@@ -14,6 +15,20 @@ import (
const HeaderNameNoBackendCache = "X-Grafana-NoCache"
func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) {
+ userPermissionsQuery := m.GetDataSourcePermissionsForUserQuery{
+ User: c.SignedInUser,
+ }
+ if err := bus.Dispatch(&userPermissionsQuery); err != nil {
+ if err != bus.ErrHandlerNotFound {
+ return nil, err
+ }
+ } else {
+ permissionType, exists := userPermissionsQuery.Result[id]
+ if exists && permissionType != m.DsPermissionQuery {
+ return nil, errors.New("User not allowed to access datasource")
+ }
+ }
+
nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true"
cacheKey := fmt.Sprintf("ds-%d", id)
@@ -38,7 +53,9 @@ func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.Data
func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
- ds, err := hs.getDatasourceFromCache(c.ParamsInt64(":id"), c)
+ dsId := c.ParamsInt64(":id")
+ ds, err := hs.getDatasourceFromCache(dsId, c)
+
if err != nil {
c.JsonApiErr(500, "Unable to load datasource meta data", err)
return
@@ -51,7 +68,21 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
return
}
- proxyPath := c.Params("*")
+ // macaron does not include trailing slashes when resolving a wildcard path
+ proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*"))
+
proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath)
proxy.HandleRequest()
}
+
+// ensureProxyPathTrailingSlash Check for a trailing slash in original path and makes
+// sure that a trailing slash is added to proxy path, if not already exists.
+func ensureProxyPathTrailingSlash(originalPath, proxyPath string) string {
+ if len(proxyPath) > 1 {
+ if originalPath[len(originalPath)-1] == '/' && proxyPath[len(proxyPath)-1] != '/' {
+ return proxyPath + "/"
+ }
+ }
+
+ return proxyPath
+}
diff --git a/pkg/api/dataproxy_test.go b/pkg/api/dataproxy_test.go
new file mode 100644
index 00000000000..a1d7cf68a37
--- /dev/null
+++ b/pkg/api/dataproxy_test.go
@@ -0,0 +1,19 @@
+package api
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDataProxy(t *testing.T) {
+ Convey("Data proxy test", t, func() {
+ Convey("Should append trailing slash to proxy path if original path has a trailing slash", func() {
+ So(ensureProxyPathTrailingSlash("/api/datasources/proxy/6/api/v1/query_range/", "api/v1/query_range/"), ShouldEqual, "api/v1/query_range/")
+ })
+
+ Convey("Should not append trailing slash to proxy path if original path doesn't have a trailing slash", func() {
+ So(ensureProxyPathTrailingSlash("/api/datasources/proxy/6/api/v1/query_range", "api/v1/query_range"), ShouldEqual, "api/v1/query_range")
+ })
+ })
+}
diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go
index 23dbb221d71..e7614614076 100644
--- a/pkg/api/datasources.go
+++ b/pkg/api/datasources.go
@@ -20,8 +20,8 @@ func GetDataSources(c *m.ReqContext) Response {
result := make(dtos.DataSourceList, 0)
for _, ds := range query.Result {
dsItem := dtos.DataSourceListItemDTO{
- Id: ds.Id,
OrgId: ds.OrgId,
+ Id: ds.Id,
Name: ds.Name,
Url: ds.Url,
Type: ds.Type,
@@ -49,7 +49,7 @@ func GetDataSources(c *m.ReqContext) Response {
return JSON(200, &result)
}
-func GetDataSourceByID(c *m.ReqContext) Response {
+func GetDataSourceById(c *m.ReqContext) Response {
query := m.GetDataSourceByIdQuery{
Id: c.ParamsInt64(":id"),
OrgId: c.OrgId,
@@ -68,14 +68,14 @@ func GetDataSourceByID(c *m.ReqContext) Response {
return JSON(200, &dtos)
}
-func DeleteDataSourceByID(c *m.ReqContext) Response {
+func DeleteDataSourceById(c *m.ReqContext) Response {
id := c.ParamsInt64(":id")
if id <= 0 {
return Error(400, "Missing valid datasource id", nil)
}
- ds, err := getRawDataSourceByID(id, c.OrgId)
+ ds, err := getRawDataSourceById(id, c.OrgId)
if err != nil {
return Error(400, "Failed to delete datasource", nil)
}
@@ -186,7 +186,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
return nil
}
- ds, err := getRawDataSourceByID(cmd.Id, cmd.OrgId)
+ ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId)
if err != nil {
return err
}
@@ -206,7 +206,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
return nil
}
-func getRawDataSourceByID(id int64, orgID int64) (*m.DataSource, error) {
+func getRawDataSourceById(id int64, orgID int64) (*m.DataSource, error) {
query := m.GetDataSourceByIdQuery{
Id: id,
OrgId: orgID,
@@ -236,7 +236,7 @@ func GetDataSourceByName(c *m.ReqContext) Response {
}
// Get /api/datasources/id/:name
-func GetDataSourceIDByName(c *m.ReqContext) Response {
+func GetDataSourceIdByName(c *m.ReqContext) Response {
query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId}
if err := bus.Dispatch(&query); err != nil {
diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go
index 697d0a35a08..c037831f341 100644
--- a/pkg/api/dtos/alerting.go
+++ b/pkg/api/dtos/alerting.go
@@ -49,28 +49,30 @@ func formatShort(interval time.Duration) string {
func NewAlertNotification(notification *models.AlertNotification) *AlertNotification {
return &AlertNotification{
- Id: notification.Id,
- Name: notification.Name,
- Type: notification.Type,
- IsDefault: notification.IsDefault,
- Created: notification.Created,
- Updated: notification.Updated,
- Frequency: formatShort(notification.Frequency),
- SendReminder: notification.SendReminder,
- Settings: notification.Settings,
+ Id: notification.Id,
+ Name: notification.Name,
+ Type: notification.Type,
+ IsDefault: notification.IsDefault,
+ Created: notification.Created,
+ Updated: notification.Updated,
+ Frequency: formatShort(notification.Frequency),
+ SendReminder: notification.SendReminder,
+ DisableResolveMessage: notification.DisableResolveMessage,
+ Settings: notification.Settings,
}
}
type AlertNotification struct {
- Id int64 `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"`
- IsDefault bool `json:"isDefault"`
- SendReminder bool `json:"sendReminder"`
- Frequency string `json:"frequency"`
- Created time.Time `json:"created"`
- Updated time.Time `json:"updated"`
- Settings *simplejson.Json `json:"settings"`
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ IsDefault bool `json:"isDefault"`
+ SendReminder bool `json:"sendReminder"`
+ DisableResolveMessage bool `json:"disableResolveMessage"`
+ Frequency string `json:"frequency"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+ Settings *simplejson.Json `json:"settings"`
}
type AlertTestCommand struct {
@@ -100,11 +102,12 @@ type EvalMatch struct {
}
type NotificationTestCommand struct {
- Name string `json:"name"`
- Type string `json:"type"`
- SendReminder bool `json:"sendReminder"`
- Frequency string `json:"frequency"`
- Settings *simplejson.Json `json:"settings"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ SendReminder bool `json:"sendReminder"`
+ DisableResolveMessage bool `json:"disableResolveMessage"`
+ Frequency string `json:"frequency"`
+ Settings *simplejson.Json `json:"settings"`
}
type PauseAlertCommand struct {
diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go
index 77004899fc3..bd3ac76eec8 100644
--- a/pkg/api/dtos/index.go
+++ b/pkg/api/dtos/index.go
@@ -14,6 +14,7 @@ type IndexViewData struct {
NewGrafanaVersionExists bool
NewGrafanaVersion string
AppName string
+ AppNameBodyClass string
}
type PluginCss struct {
diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go
index 6e24e432535..880de338c8f 100644
--- a/pkg/api/folder_test.go
+++ b/pkg/api/folder_test.go
@@ -133,16 +133,6 @@ func TestFoldersApiEndpoint(t *testing.T) {
})
}
-func callGetFolderByUID(sc *scenarioContext) {
- sc.handlerFunc = GetFolderByUID
- sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
-}
-
-func callDeleteFolder(sc *scenarioContext) {
- sc.handlerFunc = DeleteFolder
- sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
-}
-
func callCreateFolder(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
}
diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go
index a58be38781e..1de65c7b2ce 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -11,7 +11,7 @@ import (
"github.com/grafana/grafana/pkg/util"
)
-func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
+func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
orgDataSources := make([]*m.DataSource, 0)
if c.OrgId != 0 {
@@ -22,7 +22,20 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
return nil, err
}
- orgDataSources = query.Result
+ dsFilterQuery := m.DatasourcesPermissionFilterQuery{
+ User: c.SignedInUser,
+ Datasources: query.Result,
+ }
+
+ if err := bus.Dispatch(&dsFilterQuery); err != nil {
+ if err != bus.ErrHandlerNotFound {
+ return nil, err
+ }
+
+ orgDataSources = query.Result
+ } else {
+ orgDataSources = dsFilterQuery.Result
+ }
}
datasources := make(map[string]interface{})
@@ -120,6 +133,10 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
panels := map[string]interface{}{}
for _, panel := range enabledPlugins.Panels {
+ if panel.State == "alpha" && !hs.Cfg.EnableAlphaPanels {
+ continue
+ }
+
panels[panel.Id] = map[string]interface{}{
"module": panel.Module,
"baseUrl": panel.BaseUrl,
@@ -183,8 +200,8 @@ func getPanelSort(id string) int {
return sort
}
-func GetFrontendSettings(c *m.ReqContext) {
- settings, err := getFrontendSettingsMap(c)
+func (hs *HTTPServer) GetFrontendSettings(c *m.ReqContext) {
+ settings, err := hs.getFrontendSettingsMap(c)
if err != nil {
c.JsonApiErr(400, "Failed to get frontend settings", err)
return
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 432d6a18369..858b3c5a8c5 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -28,6 +28,7 @@ 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/hooks"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/setting"
)
@@ -52,6 +53,7 @@ type HTTPServer struct {
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
+ HooksService *hooks.HooksService `inject:""`
}
func (hs *HTTPServer) Init() error {
@@ -184,7 +186,7 @@ func (hs *HTTPServer) applyRoutes() {
// then custom app proxy routes
hs.initAppPluginRoutes(hs.macaron)
// lastly not found route
- hs.macaron.NotFound(NotFoundHandler)
+ hs.macaron.NotFound(hs.NotFoundHandler)
}
func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
diff --git a/pkg/api/index.go b/pkg/api/index.go
index 1b73acd8829..fe7c9e79a17 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -17,8 +17,8 @@ const (
darkName = "dark"
)
-func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
- settings, err := getFrontendSettingsMap(c)
+func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
+ settings, err := hs.getFrontendSettingsMap(c)
if err != nil {
return nil, err
}
@@ -83,6 +83,7 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
NewGrafanaVersion: plugins.GrafanaLatestVersion,
NewGrafanaVersionExists: plugins.GrafanaHasUpdate,
AppName: setting.ApplicationName,
+ AppNameBodyClass: getAppNameBodyClass(setting.ApplicationName),
}
if setting.DisableGravatar {
@@ -350,11 +351,12 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
},
})
+ hs.HooksService.RunIndexDataHooks(&data)
return &data, nil
}
-func Index(c *m.ReqContext) {
- data, err := setIndexViewData(c)
+func (hs *HTTPServer) Index(c *m.ReqContext) {
+ data, err := hs.setIndexViewData(c)
if err != nil {
c.Handle(500, "Failed to get settings", err)
return
@@ -362,13 +364,13 @@ func Index(c *m.ReqContext) {
c.HTML(200, "index", data)
}
-func NotFoundHandler(c *m.ReqContext) {
+func (hs *HTTPServer) NotFoundHandler(c *m.ReqContext) {
if c.IsApiRequest() {
c.JsonApiErr(404, "Not found", nil)
return
}
- data, err := setIndexViewData(c)
+ data, err := hs.setIndexViewData(c)
if err != nil {
c.Handle(500, "Failed to get settings", err)
return
@@ -376,3 +378,14 @@ func NotFoundHandler(c *m.ReqContext) {
c.HTML(404, "index", data)
}
+
+func getAppNameBodyClass(name string) string {
+ switch name {
+ case setting.APP_NAME:
+ return "app-grafana"
+ case setting.APP_NAME_ENTERPRISE:
+ return "app-enterprise"
+ default:
+ return ""
+ }
+}
diff --git a/pkg/api/live/hub.go b/pkg/api/live/hub.go
index 37ab5667e55..9708bc515d1 100644
--- a/pkg/api/live/hub.go
+++ b/pkg/api/live/hub.go
@@ -37,9 +37,6 @@ func newHub() *hub {
}
}
-func (h *hub) removeConnection() {
-}
-
func (h *hub) run(ctx context.Context) {
for {
select {
diff --git a/pkg/api/login.go b/pkg/api/login.go
index 632d04e37f1..1083f89adfd 100644
--- a/pkg/api/login.go
+++ b/pkg/api/login.go
@@ -17,8 +17,8 @@ const (
ViewIndex = "index"
)
-func LoginView(c *m.ReqContext) {
- viewData, err := setIndexViewData(c)
+func (hs *HTTPServer) LoginView(c *m.ReqContext) {
+ viewData, err := hs.setIndexViewData(c)
if err != nil {
c.Handle(500, "Failed to get settings", err)
return
diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go
index 4e2ed36431e..e750662c764 100644
--- a/pkg/api/org_users.go
+++ b/pkg/api/org_users.go
@@ -45,7 +45,7 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
// GET /api/org/users
func GetOrgUsersForCurrentOrg(c *m.ReqContext) Response {
- return getOrgUsersHelper(c.OrgId, c.Params("query"), c.ParamsInt("limit"))
+ return getOrgUsersHelper(c.OrgId, c.Query("query"), c.QueryInt("limit"))
}
// GET /api/orgs/:orgId/users
@@ -102,26 +102,32 @@ func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response {
// DELETE /api/org/users/:userId
func RemoveOrgUserForCurrentOrg(c *m.ReqContext) Response {
- userID := c.ParamsInt64(":userId")
- return removeOrgUserHelper(c.OrgId, userID)
+ return removeOrgUserHelper(&m.RemoveOrgUserCommand{
+ UserId: c.ParamsInt64(":userId"),
+ OrgId: c.OrgId,
+ ShouldDeleteOrphanedUser: true,
+ })
}
// DELETE /api/orgs/:orgId/users/:userId
func RemoveOrgUser(c *m.ReqContext) Response {
- userID := c.ParamsInt64(":userId")
- orgID := c.ParamsInt64(":orgId")
- return removeOrgUserHelper(orgID, userID)
+ return removeOrgUserHelper(&m.RemoveOrgUserCommand{
+ UserId: c.ParamsInt64(":userId"),
+ OrgId: c.ParamsInt64(":orgId"),
+ })
}
-func removeOrgUserHelper(orgID int64, userID int64) Response {
- cmd := m.RemoveOrgUserCommand{OrgId: orgID, UserId: userID}
-
- if err := bus.Dispatch(&cmd); err != nil {
+func removeOrgUserHelper(cmd *m.RemoveOrgUserCommand) Response {
+ if err := bus.Dispatch(cmd); err != nil {
if err == m.ErrLastOrgAdmin {
return Error(400, "Cannot remove last organization admin", nil)
}
return Error(500, "Failed to remove user from organization", err)
}
+ if cmd.UserWasDeleted {
+ return Success("User deleted")
+ }
+
return Success("User removed from organization")
}
diff --git a/pkg/api/pluginproxy/access_token_provider.go b/pkg/api/pluginproxy/access_token_provider.go
new file mode 100644
index 00000000000..22407823ff9
--- /dev/null
+++ b/pkg/api/pluginproxy/access_token_provider.go
@@ -0,0 +1,171 @@
+package pluginproxy
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "sync"
+ "time"
+
+ "golang.org/x/oauth2"
+
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ "golang.org/x/oauth2/jwt"
+)
+
+var (
+ tokenCache = tokenCacheType{
+ cache: map[string]*jwtToken{},
+ }
+ oauthJwtTokenCache = oauthJwtTokenCacheType{
+ cache: map[string]*oauth2.Token{},
+ }
+)
+
+type tokenCacheType struct {
+ cache map[string]*jwtToken
+ sync.Mutex
+}
+
+type oauthJwtTokenCacheType struct {
+ cache map[string]*oauth2.Token
+ sync.Mutex
+}
+
+type accessTokenProvider struct {
+ route *plugins.AppPluginRoute
+ datasourceId int64
+ datasourceVersion int
+}
+
+type jwtToken struct {
+ ExpiresOn time.Time `json:"-"`
+ ExpiresOnString string `json:"expires_on"`
+ AccessToken string `json:"access_token"`
+}
+
+func newAccessTokenProvider(ds *models.DataSource, pluginRoute *plugins.AppPluginRoute) *accessTokenProvider {
+ return &accessTokenProvider{
+ datasourceId: ds.Id,
+ datasourceVersion: ds.Version,
+ route: pluginRoute,
+ }
+}
+
+func (provider *accessTokenProvider) getAccessToken(data templateData) (string, error) {
+ tokenCache.Lock()
+ defer tokenCache.Unlock()
+ if cachedToken, found := tokenCache.cache[provider.getAccessTokenCacheKey()]; found {
+ if cachedToken.ExpiresOn.After(time.Now().Add(time.Second * 10)) {
+ logger.Info("Using token from cache")
+ return cachedToken.AccessToken, nil
+ }
+ }
+
+ urlInterpolated, err := interpolateString(provider.route.TokenAuth.Url, data)
+ if err != nil {
+ return "", err
+ }
+
+ params := make(url.Values)
+ for key, value := range provider.route.TokenAuth.Params {
+ interpolatedParam, err := interpolateString(value, data)
+ if err != nil {
+ return "", err
+ }
+ params.Add(key, interpolatedParam)
+ }
+
+ getTokenReq, _ := http.NewRequest("POST", urlInterpolated, bytes.NewBufferString(params.Encode()))
+ getTokenReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ getTokenReq.Header.Add("Content-Length", strconv.Itoa(len(params.Encode())))
+
+ resp, err := client.Do(getTokenReq)
+ if err != nil {
+ return "", err
+ }
+
+ defer resp.Body.Close()
+
+ var token jwtToken
+ if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
+ return "", err
+ }
+
+ expiresOnEpoch, _ := strconv.ParseInt(token.ExpiresOnString, 10, 64)
+ token.ExpiresOn = time.Unix(expiresOnEpoch, 0)
+ tokenCache.cache[provider.getAccessTokenCacheKey()] = &token
+
+ logger.Info("Got new access token", "ExpiresOn", token.ExpiresOn)
+
+ return token.AccessToken, nil
+}
+
+func (provider *accessTokenProvider) getJwtAccessToken(ctx context.Context, data templateData) (string, error) {
+ oauthJwtTokenCache.Lock()
+ defer oauthJwtTokenCache.Unlock()
+ if cachedToken, found := oauthJwtTokenCache.cache[provider.getAccessTokenCacheKey()]; found {
+ if cachedToken.Expiry.After(time.Now().Add(time.Second * 10)) {
+ logger.Debug("Using token from cache")
+ return cachedToken.AccessToken, nil
+ }
+ }
+
+ conf := &jwt.Config{}
+
+ if val, ok := provider.route.JwtTokenAuth.Params["client_email"]; ok {
+ interpolatedVal, err := interpolateString(val, data)
+ if err != nil {
+ return "", err
+ }
+ conf.Email = interpolatedVal
+ }
+
+ if val, ok := provider.route.JwtTokenAuth.Params["private_key"]; ok {
+ interpolatedVal, err := interpolateString(val, data)
+ if err != nil {
+ return "", err
+ }
+ conf.PrivateKey = []byte(interpolatedVal)
+ }
+
+ if val, ok := provider.route.JwtTokenAuth.Params["token_uri"]; ok {
+ interpolatedVal, err := interpolateString(val, data)
+ if err != nil {
+ return "", err
+ }
+ conf.TokenURL = interpolatedVal
+ }
+
+ conf.Scopes = provider.route.JwtTokenAuth.Scopes
+
+ token, err := getTokenSource(conf, ctx)
+ if err != nil {
+ return "", err
+ }
+
+ oauthJwtTokenCache.cache[provider.getAccessTokenCacheKey()] = token
+
+ logger.Info("Got new access token", "ExpiresOn", token.Expiry)
+
+ return token.AccessToken, nil
+}
+
+var getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+ tokenSrc := conf.TokenSource(ctx)
+ token, err := tokenSrc.Token()
+ if err != nil {
+ return nil, err
+ }
+
+ return token, nil
+}
+
+func (provider *accessTokenProvider) getAccessTokenCacheKey() string {
+ return fmt.Sprintf("%v_%v_%v_%v", provider.datasourceId, provider.datasourceVersion, provider.route.Path, provider.route.Method)
+}
diff --git a/pkg/api/pluginproxy/access_token_provider_test.go b/pkg/api/pluginproxy/access_token_provider_test.go
new file mode 100644
index 00000000000..e75748e4660
--- /dev/null
+++ b/pkg/api/pluginproxy/access_token_provider_test.go
@@ -0,0 +1,94 @@
+package pluginproxy
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ . "github.com/smartystreets/goconvey/convey"
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/jwt"
+)
+
+func TestAccessToken(t *testing.T) {
+ Convey("Plugin with JWT token auth route", t, func() {
+ pluginRoute := &plugins.AppPluginRoute{
+ Path: "pathwithjwttoken1",
+ Url: "https://api.jwt.io/some/path",
+ Method: "GET",
+ JwtTokenAuth: &plugins.JwtTokenAuth{
+ Url: "https://login.server.com/{{.JsonData.tenantId}}/oauth2/token",
+ Scopes: []string{
+ "https://www.testapi.com/auth/monitoring.read",
+ "https://www.testapi.com/auth/cloudplatformprojects.readonly",
+ },
+ Params: map[string]string{
+ "token_uri": "{{.JsonData.tokenUri}}",
+ "client_email": "{{.JsonData.clientEmail}}",
+ "private_key": "{{.SecureJsonData.privateKey}}",
+ },
+ },
+ }
+
+ templateData := templateData{
+ JsonData: map[string]interface{}{
+ "clientEmail": "test@test.com",
+ "tokenUri": "login.url.com/token",
+ },
+ SecureJsonData: map[string]string{
+ "privateKey": "testkey",
+ },
+ }
+
+ ds := &models.DataSource{Id: 1, Version: 2}
+
+ Convey("should fetch token using jwt private key", func() {
+ getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+ return &oauth2.Token{AccessToken: "abc"}, nil
+ }
+ provider := newAccessTokenProvider(ds, pluginRoute)
+ token, err := provider.getJwtAccessToken(context.Background(), templateData)
+ So(err, ShouldBeNil)
+
+ So(token, ShouldEqual, "abc")
+ })
+
+ Convey("should set jwt config values", func() {
+ getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+ So(conf.Email, ShouldEqual, "test@test.com")
+ So(conf.PrivateKey, ShouldResemble, []byte("testkey"))
+ So(len(conf.Scopes), ShouldEqual, 2)
+ So(conf.Scopes[0], ShouldEqual, "https://www.testapi.com/auth/monitoring.read")
+ So(conf.Scopes[1], ShouldEqual, "https://www.testapi.com/auth/cloudplatformprojects.readonly")
+ So(conf.TokenURL, ShouldEqual, "login.url.com/token")
+
+ return &oauth2.Token{AccessToken: "abc"}, nil
+ }
+
+ provider := newAccessTokenProvider(ds, pluginRoute)
+ _, err := provider.getJwtAccessToken(context.Background(), templateData)
+ So(err, ShouldBeNil)
+ })
+
+ Convey("should use cached token on second call", func() {
+ getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+ return &oauth2.Token{
+ AccessToken: "abc",
+ Expiry: time.Now().Add(1 * time.Minute)}, nil
+ }
+ provider := newAccessTokenProvider(ds, pluginRoute)
+ token1, err := provider.getJwtAccessToken(context.Background(), templateData)
+ So(err, ShouldBeNil)
+ So(token1, ShouldEqual, "abc")
+
+ getTokenSource = func(conf *jwt.Config, ctx context.Context) (*oauth2.Token, error) {
+ return &oauth2.Token{AccessToken: "error: cache not used"}, nil
+ }
+ token2, err := provider.getJwtAccessToken(context.Background(), templateData)
+ So(err, ShouldBeNil)
+ So(token2, ShouldEqual, "abc")
+ })
+ })
+}
diff --git a/pkg/api/pluginproxy/ds_auth_provider.go b/pkg/api/pluginproxy/ds_auth_provider.go
new file mode 100644
index 00000000000..edf43085c7c
--- /dev/null
+++ b/pkg/api/pluginproxy/ds_auth_provider.go
@@ -0,0 +1,109 @@
+package pluginproxy
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "text/template"
+
+ m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/util"
+ "golang.org/x/oauth2/google"
+)
+
+//ApplyRoute should use the plugin route data to set auth headers and custom headers
+func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route *plugins.AppPluginRoute, ds *m.DataSource) {
+ proxyPath = strings.TrimPrefix(proxyPath, route.Path)
+
+ data := templateData{
+ JsonData: ds.JsonData.Interface().(map[string]interface{}),
+ SecureJsonData: ds.SecureJsonData.Decrypt(),
+ }
+
+ interpolatedURL, err := interpolateString(route.Url, data)
+ if err != nil {
+ logger.Error("Error interpolating proxy url", "error", err)
+ return
+ }
+
+ routeURL, err := url.Parse(interpolatedURL)
+ if err != nil {
+ logger.Error("Error parsing plugin route url", "error", err)
+ return
+ }
+
+ req.URL.Scheme = routeURL.Scheme
+ req.URL.Host = routeURL.Host
+ req.Host = routeURL.Host
+ req.URL.Path = util.JoinUrlFragments(routeURL.Path, proxyPath)
+
+ if err := addHeaders(&req.Header, route, data); err != nil {
+ logger.Error("Failed to render plugin headers", "error", err)
+ }
+
+ tokenProvider := newAccessTokenProvider(ds, route)
+
+ if route.TokenAuth != nil {
+ if token, err := tokenProvider.getAccessToken(data); err != nil {
+ logger.Error("Failed to get access token", "error", err)
+ } else {
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+ }
+ }
+
+ authenticationType := ds.JsonData.Get("authenticationType").MustString("jwt")
+ if route.JwtTokenAuth != nil && authenticationType == "jwt" {
+ if token, err := tokenProvider.getJwtAccessToken(ctx, data); err != nil {
+ logger.Error("Failed to get access token", "error", err)
+ } else {
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+ }
+ }
+
+ if authenticationType == "gce" {
+ tokenSrc, err := google.DefaultTokenSource(ctx, route.JwtTokenAuth.Scopes...)
+ if err != nil {
+ logger.Error("Failed to get default token from meta data server", "error", err)
+ } else {
+ token, err := tokenSrc.Token()
+ if err != nil {
+ logger.Error("Failed to get default access token from meta data server", "error", err)
+ } else {
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
+ }
+ }
+ }
+
+ logger.Info("Requesting", "url", req.URL.String())
+}
+
+func interpolateString(text string, data templateData) (string, error) {
+ t, err := template.New("content").Parse(text)
+ if err != nil {
+ return "", fmt.Errorf("could not parse template %s", text)
+ }
+
+ var contentBuf bytes.Buffer
+ err = t.Execute(&contentBuf, data)
+ if err != nil {
+ return "", fmt.Errorf("failed to execute template %s", text)
+ }
+
+ return contentBuf.String(), nil
+}
+
+func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error {
+ for _, header := range route.Headers {
+ interpolated, err := interpolateString(header.Content, data)
+ if err != nil {
+ return err
+ }
+ reqHeaders.Add(header.Name, interpolated)
+ }
+
+ return nil
+}
diff --git a/pkg/api/pluginproxy/ds_auth_provider_test.go b/pkg/api/pluginproxy/ds_auth_provider_test.go
new file mode 100644
index 00000000000..9bd98a339e5
--- /dev/null
+++ b/pkg/api/pluginproxy/ds_auth_provider_test.go
@@ -0,0 +1,21 @@
+package pluginproxy
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDsAuthProvider(t *testing.T) {
+ Convey("When interpolating string", t, func() {
+ data := templateData{
+ SecureJsonData: map[string]string{
+ "Test": "0asd+asd",
+ },
+ }
+
+ interpolated, err := interpolateString("{{.SecureJsonData.Test}}", data)
+ So(err, ShouldBeNil)
+ So(interpolated, ShouldEqual, "0asd+asd")
+ })
+}
diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go
index fb2cab9b9b1..0c000058e4b 100644
--- a/pkg/api/pluginproxy/ds_proxy.go
+++ b/pkg/api/pluginproxy/ds_proxy.go
@@ -2,7 +2,6 @@ package pluginproxy
import (
"bytes"
- "encoding/json"
"errors"
"fmt"
"io/ioutil"
@@ -12,7 +11,6 @@ import (
"net/url"
"strconv"
"strings"
- "text/template"
"time"
"github.com/opentracing/opentracing-go"
@@ -25,17 +23,10 @@ import (
)
var (
- logger = log.New("data-proxy-log")
- tokenCache = map[string]*jwtToken{}
- client = newHTTPClient()
+ logger = log.New("data-proxy-log")
+ client = newHTTPClient()
)
-type jwtToken struct {
- ExpiresOn time.Time `json:"-"`
- ExpiresOnString string `json:"expires_on"`
- AccessToken string `json:"access_token"`
-}
-
type DataSourceProxy struct {
ds *m.DataSource
ctx *m.ReqContext
@@ -162,7 +153,6 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
} else {
req.URL.Path = util.JoinUrlFragments(proxy.targetUrl.Path, proxy.proxyPath)
}
-
if proxy.ds.BasicAuth {
req.Header.Del("Authorization")
req.Header.Add("Authorization", util.GetBasicAuthHeader(proxy.ds.BasicAuthUser, proxy.ds.BasicAuthPassword))
@@ -219,7 +209,7 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
}
if proxy.route != nil {
- proxy.applyRoute(req)
+ ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
}
}
}
@@ -311,120 +301,3 @@ func checkWhiteList(c *m.ReqContext, host string) bool {
return true
}
-
-func (proxy *DataSourceProxy) applyRoute(req *http.Request) {
- proxy.proxyPath = strings.TrimPrefix(proxy.proxyPath, proxy.route.Path)
-
- data := templateData{
- JsonData: proxy.ds.JsonData.Interface().(map[string]interface{}),
- SecureJsonData: proxy.ds.SecureJsonData.Decrypt(),
- }
-
- interpolatedURL, err := interpolateString(proxy.route.Url, data)
- if err != nil {
- logger.Error("Error interpolating proxy url", "error", err)
- return
- }
-
- routeURL, err := url.Parse(interpolatedURL)
- if err != nil {
- logger.Error("Error parsing plugin route url", "error", err)
- return
- }
-
- req.URL.Scheme = routeURL.Scheme
- req.URL.Host = routeURL.Host
- req.Host = routeURL.Host
- req.URL.Path = util.JoinUrlFragments(routeURL.Path, proxy.proxyPath)
-
- if err := addHeaders(&req.Header, proxy.route, data); err != nil {
- logger.Error("Failed to render plugin headers", "error", err)
- }
-
- if proxy.route.TokenAuth != nil {
- if token, err := proxy.getAccessToken(data); err != nil {
- logger.Error("Failed to get access token", "error", err)
- } else {
- req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
- }
- }
-
- logger.Info("Requesting", "url", req.URL.String())
-}
-
-func (proxy *DataSourceProxy) getAccessToken(data templateData) (string, error) {
- if cachedToken, found := tokenCache[proxy.getAccessTokenCacheKey()]; found {
- if cachedToken.ExpiresOn.After(time.Now().Add(time.Second * 10)) {
- logger.Info("Using token from cache")
- return cachedToken.AccessToken, nil
- }
- }
-
- urlInterpolated, err := interpolateString(proxy.route.TokenAuth.Url, data)
- if err != nil {
- return "", err
- }
-
- params := make(url.Values)
- for key, value := range proxy.route.TokenAuth.Params {
- interpolatedParam, err := interpolateString(value, data)
- if err != nil {
- return "", err
- }
- params.Add(key, interpolatedParam)
- }
-
- getTokenReq, _ := http.NewRequest("POST", urlInterpolated, bytes.NewBufferString(params.Encode()))
- getTokenReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
- getTokenReq.Header.Add("Content-Length", strconv.Itoa(len(params.Encode())))
-
- resp, err := client.Do(getTokenReq)
- if err != nil {
- return "", err
- }
-
- defer resp.Body.Close()
-
- var token jwtToken
- if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
- return "", err
- }
-
- expiresOnEpoch, _ := strconv.ParseInt(token.ExpiresOnString, 10, 64)
- token.ExpiresOn = time.Unix(expiresOnEpoch, 0)
- tokenCache[proxy.getAccessTokenCacheKey()] = &token
-
- logger.Info("Got new access token", "ExpiresOn", token.ExpiresOn)
- return token.AccessToken, nil
-}
-
-func (proxy *DataSourceProxy) getAccessTokenCacheKey() string {
- return fmt.Sprintf("%v_%v_%v", proxy.ds.Id, proxy.route.Path, proxy.route.Method)
-}
-
-func interpolateString(text string, data templateData) (string, error) {
- t, err := template.New("content").Parse(text)
- if err != nil {
- return "", fmt.Errorf("could not parse template %s", text)
- }
-
- var contentBuf bytes.Buffer
- err = t.Execute(&contentBuf, data)
- if err != nil {
- return "", fmt.Errorf("failed to execute template %s", text)
- }
-
- return contentBuf.String(), nil
-}
-
-func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error {
- for _, header := range route.Headers {
- interpolated, err := interpolateString(header.Content, data)
- if err != nil {
- return err
- }
- reqHeaders.Add(header.Name, interpolated)
- }
-
- return nil
-}
diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go
index e6d05872787..ab0effb298f 100644
--- a/pkg/api/pluginproxy/ds_proxy_test.go
+++ b/pkg/api/pluginproxy/ds_proxy_test.go
@@ -83,7 +83,7 @@ func TestDSRouteRule(t *testing.T) {
Convey("When matching route path", func() {
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
proxy.route = plugin.Routes[0]
- proxy.applyRoute(req)
+ ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
Convey("should add headers and update url", func() {
So(req.URL.String(), ShouldEqual, "https://www.google.com/some/method")
@@ -94,7 +94,7 @@ func TestDSRouteRule(t *testing.T) {
Convey("When matching route path and has dynamic url", func() {
proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method")
proxy.route = plugin.Routes[3]
- proxy.applyRoute(req)
+ ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
Convey("should add headers and interpolate the url", func() {
So(req.URL.String(), ShouldEqual, "https://dynamic.grafana.com/some/method")
@@ -188,7 +188,7 @@ func TestDSRouteRule(t *testing.T) {
client = newFakeHTTPClient(json)
proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
proxy1.route = plugin.Routes[0]
- proxy1.applyRoute(req)
+ ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds)
authorizationHeaderCall1 = req.Header.Get("Authorization")
So(req.URL.String(), ShouldEqual, "https://api.nr1.io/some/path")
@@ -202,7 +202,7 @@ func TestDSRouteRule(t *testing.T) {
client = newFakeHTTPClient(json2)
proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2")
proxy2.route = plugin.Routes[1]
- proxy2.applyRoute(req)
+ ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds)
authorizationHeaderCall2 = req.Header.Get("Authorization")
@@ -217,7 +217,7 @@ func TestDSRouteRule(t *testing.T) {
client = newFakeHTTPClient([]byte{})
proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
proxy3.route = plugin.Routes[0]
- proxy3.applyRoute(req)
+ ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds)
authorizationHeaderCall3 := req.Header.Get("Authorization")
So(req.URL.String(), ShouldEqual, "https://api.nr1.io/some/path")
@@ -331,18 +331,6 @@ func TestDSRouteRule(t *testing.T) {
})
})
- Convey("When interpolating string", func() {
- data := templateData{
- SecureJsonData: map[string]string{
- "Test": "0asd+asd",
- },
- }
-
- interpolated, err := interpolateString("{{.SecureJsonData.Test}}", data)
- So(err, ShouldBeNil)
- So(interpolated, ShouldEqual, "0asd+asd")
- })
-
Convey("When proxying a data source with custom headers specified", func() {
plugin := &plugins.DataSourcePlugin{}
@@ -374,6 +362,23 @@ func TestDSRouteRule(t *testing.T) {
})
})
+ Convey("When proxying a custom datasource", func() {
+ plugin := &plugins.DataSourcePlugin{}
+ ds := &m.DataSource{
+ Type: "custom-datasource",
+ Url: "http://host/root/",
+ }
+ ctx := &m.ReqContext{}
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/")
+ req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
+ So(err, ShouldBeNil)
+
+ proxy.getDirector()(req)
+
+ Convey("Shoudl keep user request (including trailing slash)", func() {
+ So(req.URL.String(), ShouldEqual, "http://host/root/path/to/folder/")
+ })
+ })
})
}
diff --git a/pkg/api/user.go b/pkg/api/user.go
index 4b916202e65..7116ad83f3f 100644
--- a/pkg/api/user.go
+++ b/pkg/api/user.go
@@ -177,17 +177,17 @@ func UserSetUsingOrg(c *m.ReqContext) Response {
}
// GET /profile/switch-org/:id
-func ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) {
+func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) {
orgID := c.ParamsInt64(":id")
if !validateUsingOrg(c.UserId, orgID) {
- NotFoundHandler(c)
+ hs.NotFoundHandler(c)
}
cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgID}
if err := bus.Dispatch(&cmd); err != nil {
- NotFoundHandler(c)
+ hs.NotFoundHandler(c)
}
c.Redirect(setting.AppSubUrl + "/")
diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go
index 5e69559b9fa..902fd415977 100644
--- a/pkg/cmd/grafana-cli/commands/commands.go
+++ b/pkg/cmd/grafana-cli/commands/commands.go
@@ -6,6 +6,7 @@ import (
"github.com/codegangsta/cli"
"github.com/fatih/color"
+ "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
@@ -24,6 +25,7 @@ func runDbCommand(command func(commandLine CommandLine) error) func(context *cli
engine := &sqlstore.SqlStore{}
engine.Cfg = cfg
+ engine.Bus = bus.GetBus()
engine.Init()
if err := command(cmd); err != nil {
diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go
index f1e298671d7..c7c1ff3aff7 100644
--- a/pkg/cmd/grafana-server/main.go
+++ b/pkg/cmd/grafana-server/main.go
@@ -3,6 +3,8 @@ package main
import (
"flag"
"fmt"
+ "net/http"
+ _ "net/http/pprof"
"os"
"os/signal"
"runtime"
@@ -11,16 +13,12 @@ import (
"syscall"
"time"
- "net/http"
- _ "net/http/pprof"
-
+ extensions "github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/metrics"
- "github.com/grafana/grafana/pkg/setting"
-
- extensions "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/services/alerting/conditions"
_ "github.com/grafana/grafana/pkg/services/alerting/notifiers"
+ "github.com/grafana/grafana/pkg/setting"
_ "github.com/grafana/grafana/pkg/tsdb/cloudwatch"
_ "github.com/grafana/grafana/pkg/tsdb/elasticsearch"
_ "github.com/grafana/grafana/pkg/tsdb/graphite"
@@ -29,11 +27,13 @@ import (
_ "github.com/grafana/grafana/pkg/tsdb/opentsdb"
_ "github.com/grafana/grafana/pkg/tsdb/postgres"
_ "github.com/grafana/grafana/pkg/tsdb/prometheus"
+ _ "github.com/grafana/grafana/pkg/tsdb/stackdriver"
_ "github.com/grafana/grafana/pkg/tsdb/testdata"
)
var version = "5.0.0"
var commit = "NA"
+var buildBranch = "master"
var buildstamp string
var configFile = flag.String("config", "", "path to config file")
@@ -46,7 +46,7 @@ func main() {
profilePort := flag.Int("profile-port", 6060, "Define custom port for profiling")
flag.Parse()
if *v {
- fmt.Printf("Version %s (commit: %s)\n", version, commit)
+ fmt.Printf("Version %s (commit: %s, branch: %s)\n", version, commit, buildBranch)
os.Exit(0)
}
@@ -77,9 +77,10 @@ func main() {
setting.BuildVersion = version
setting.BuildCommit = commit
setting.BuildStamp = buildstampInt64
+ setting.BuildBranch = buildBranch
setting.IsEnterprise = extensions.IsEnterprise
- metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
+ metrics.SetBuildInformation(version, commit, buildBranch)
server := NewGrafanaServer()
@@ -99,11 +100,11 @@ func listenToSystemSignals(server *GrafanaServerImpl) {
sighupChan := make(chan os.Signal, 1)
signal.Notify(sighupChan, syscall.SIGHUP)
- signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM)
+ signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
for {
select {
- case _ = <-sighupChan:
+ case <-sighupChan:
log.Reload()
case sig := <-signalChan:
server.Shutdown(fmt.Sprintf("System signal: %s", sig))
diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go
index 8794d7d8338..765b8ddf993 100644
--- a/pkg/cmd/grafana-server/server.go
+++ b/pkg/cmd/grafana-server/server.go
@@ -12,24 +12,16 @@ import (
"time"
"github.com/facebookgo/inject"
+ "github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/middleware"
- "github.com/grafana/grafana/pkg/registry"
-
- "golang.org/x/sync/errgroup"
-
- "github.com/grafana/grafana/pkg/api"
+ _ "github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/login"
- "github.com/grafana/grafana/pkg/setting"
-
- "github.com/grafana/grafana/pkg/social"
-
- // self registering services
- _ "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/metrics"
+ "github.com/grafana/grafana/pkg/middleware"
_ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/registry"
_ "github.com/grafana/grafana/pkg/services/alerting"
_ "github.com/grafana/grafana/pkg/services/cleanup"
_ "github.com/grafana/grafana/pkg/services/notifications"
@@ -37,7 +29,10 @@ import (
_ "github.com/grafana/grafana/pkg/services/rendering"
_ "github.com/grafana/grafana/pkg/services/search"
_ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/social" // self registering services
_ "github.com/grafana/grafana/pkg/tracing"
+ "golang.org/x/sync/errgroup"
)
func NewGrafanaServer() *GrafanaServerImpl {
@@ -159,7 +154,7 @@ func (g *GrafanaServerImpl) loadConfiguration() {
os.Exit(1)
}
- g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0))
+ g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "branch", buildBranch, "compiled", time.Unix(setting.BuildStamp, 0))
g.cfg.LogConfigSources()
}
diff --git a/pkg/components/imguploader/azureblobuploader.go b/pkg/components/imguploader/azureblobuploader.go
index a902807925b..b37763931c8 100644
--- a/pkg/components/imguploader/azureblobuploader.go
+++ b/pkg/components/imguploader/azureblobuploader.go
@@ -127,8 +127,6 @@ type xmlError struct {
const ms_date_layout = "Mon, 02 Jan 2006 15:04:05 GMT"
const version = "2017-04-17"
-var client = &http.Client{}
-
type StorageClient struct {
Auth *Auth
Transport http.RoundTripper
diff --git a/pkg/extensions/main.go b/pkg/extensions/main.go
index 6e3461da8a8..1d8bbce03f3 100644
--- a/pkg/extensions/main.go
+++ b/pkg/extensions/main.go
@@ -1,3 +1,7 @@
package extensions
+import (
+ _ "gopkg.in/square/go-jose.v2"
+)
+
var IsEnterprise bool = false
diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go
index 43f45f900d9..d4e81d2bd46 100644
--- a/pkg/login/ldap.go
+++ b/pkg/login/ldap.go
@@ -185,7 +185,9 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
if ldapUser.isMemberOf(group.GroupDN) {
extUser.OrgRoles[group.OrgId] = group.OrgRole
- extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
+ if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin {
+ extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
+ }
}
}
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index 9a514fdb6f3..5709e3e3213 100644
--- a/pkg/metrics/metrics.go
+++ b/pkg/metrics/metrics.go
@@ -58,7 +58,14 @@ var (
M_StatActive_Users prometheus.Gauge
M_StatTotal_Orgs prometheus.Gauge
M_StatTotal_Playlists prometheus.Gauge
- M_Grafana_Version *prometheus.GaugeVec
+
+ // M_Grafana_Version is a gauge that contains build info about this binary
+ //
+ // Deprecated: use M_Grafana_Build_Version instead.
+ M_Grafana_Version *prometheus.GaugeVec
+
+ // grafanaBuildVersion is a gauge that contains build info about this binary
+ grafanaBuildVersion *prometheus.GaugeVec
)
func newCounterVecStartingAtZero(opts prometheus.CounterOpts, labels []string, labelValues ...string) *prometheus.CounterVec {
@@ -293,9 +300,25 @@ func init() {
M_Grafana_Version = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "info",
- Help: "Information about the Grafana",
+ Help: "Information about the Grafana. This metric is deprecated. please use `grafana_build_info`",
Namespace: exporterName,
}, []string{"version"})
+
+ grafanaBuildVersion = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "build_info",
+ Help: "A metric with a constant '1' value labeled by version, revision, branch, and goversion from which Grafana was built.",
+ Namespace: exporterName,
+ }, []string{"version", "revision", "branch", "goversion"})
+}
+
+// SetBuildInformation sets the build information for this binary
+func SetBuildInformation(version, revision, branch string) {
+ // We export this info twice for backwards compability.
+ // Once this have been released for some time we should be able to remote `M_Grafana_Version`
+ // The reason we added a new one is that its common practice in the prometheus community
+ // to name this metric `*_build_info` so its easy to do aggregation on all programs.
+ M_Grafana_Version.WithLabelValues(version).Set(1)
+ grafanaBuildVersion.WithLabelValues(version, revision, branch, runtime.Version()).Set(1)
}
func initMetricVars() {
@@ -334,7 +357,8 @@ func initMetricVars() {
M_StatActive_Users,
M_StatTotal_Orgs,
M_StatTotal_Playlists,
- M_Grafana_Version)
+ M_Grafana_Version,
+ grafanaBuildVersion)
}
diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go
index 475dce089b1..7b29901c1a3 100644
--- a/pkg/middleware/middleware.go
+++ b/pkg/middleware/middleware.go
@@ -14,6 +14,13 @@ import (
"github.com/grafana/grafana/pkg/util"
)
+var (
+ ReqGrafanaAdmin = Auth(&AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
+ ReqSignedIn = Auth(&AuthOptions{ReqSignedIn: true})
+ ReqEditorRole = RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
+ ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
+)
+
func GetContextHandler() macaron.Handler {
return func(c *macaron.Context) {
ctx := &m.ReqContext{
@@ -36,12 +43,13 @@ func GetContextHandler() macaron.Handler {
// then init session and look for userId in session
// then look for api key in session (special case for render calls via api)
// then test if anonymous access is enabled
- if initContextWithRenderAuth(ctx) ||
- initContextWithApiKey(ctx) ||
- initContextWithBasicAuth(ctx, orgId) ||
- initContextWithAuthProxy(ctx, orgId) ||
- initContextWithUserSessionCookie(ctx, orgId) ||
- initContextWithAnonymousUser(ctx) {
+ switch {
+ case initContextWithRenderAuth(ctx):
+ case initContextWithApiKey(ctx):
+ case initContextWithBasicAuth(ctx, orgId):
+ case initContextWithAuthProxy(ctx, orgId):
+ case initContextWithUserSessionCookie(ctx, orgId):
+ case initContextWithAnonymousUser(ctx):
}
ctx.Logger = log.New("context", "userId", ctx.UserId, "orgId", ctx.OrgId, "uname", ctx.Login)
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index 87c23a7b49a..e9a3c8059f8 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -18,6 +18,7 @@ import (
)
func TestMiddlewareContext(t *testing.T) {
+ setting.ERR_TEMPLATE_NAME = "error-template"
Convey("Given the grafana middleware", t, func() {
middlewareScenario("middleware should add context to injector", func(sc *scenarioContext) {
@@ -435,11 +436,6 @@ func (sc *scenarioContext) withValidApiKey() *scenarioContext {
return sc
}
-func (sc *scenarioContext) withInvalidApiKey() *scenarioContext {
- sc.apiKey = "nvalidhhhhds"
- return sc
-}
-
func (sc *scenarioContext) withAuthorizationHeader(authHeader string) *scenarioContext {
sc.authHeader = authHeader
return sc
diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go
index 456bc91354e..eef07c8c24a 100644
--- a/pkg/middleware/recovery.go
+++ b/pkg/middleware/recovery.go
@@ -138,7 +138,7 @@ func Recovery() macaron.Handler {
c.JSON(500, resp)
} else {
- c.HTML(500, "error")
+ c.HTML(500, setting.ERR_TEMPLATE_NAME)
}
}
}()
diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go
index 4bbedbc3b21..c92150f3b7d 100644
--- a/pkg/middleware/recovery_test.go
+++ b/pkg/middleware/recovery_test.go
@@ -8,11 +8,14 @@ import (
"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"
)
func TestRecoveryMiddleware(t *testing.T) {
+ setting.ERR_TEMPLATE_NAME = "error-template"
+
Convey("Given an api route that panics", t, func() {
apiURL := "/api/whatever"
recoveryScenario("recovery middleware should return json", apiURL, func(sc *scenarioContext) {
@@ -50,6 +53,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
sc := &scenarioContext{
url: url,
}
+
viewsPath, _ := filepath.Abs("../../public/views")
sc.m = macaron.New()
diff --git a/pkg/models/alert.go b/pkg/models/alert.go
index fba2aa63df9..ba1fc0779ba 100644
--- a/pkg/models/alert.go
+++ b/pkg/models/alert.go
@@ -75,7 +75,7 @@ type Alert struct {
EvalData *simplejson.Json
NewStateDate time.Time
- StateChanges int
+ StateChanges int64
Created time.Time
Updated time.Time
@@ -156,7 +156,7 @@ type SetAlertStateCommand struct {
Error string
EvalData *simplejson.Json
- Timestamp time.Time
+ Result Alert
}
//Queries
diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go
index b90b3d36ced..e0fd12937ed 100644
--- a/pkg/models/alert_notifications.go
+++ b/pkg/models/alert_notifications.go
@@ -8,43 +8,56 @@ import (
)
var (
- ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
- ErrJournalingNotFound = errors.New("alert notification journaling not found")
+ ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified")
+ ErrAlertNotificationStateNotFound = errors.New("alert notification state not found")
+ ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
+ ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.")
+)
+
+type AlertNotificationStateType string
+
+var (
+ AlertNotificationStatePending = AlertNotificationStateType("pending")
+ AlertNotificationStateCompleted = AlertNotificationStateType("completed")
+ AlertNotificationStateUnknown = AlertNotificationStateType("unknown")
)
type AlertNotification struct {
- Id int64 `json:"id"`
- OrgId int64 `json:"-"`
- Name string `json:"name"`
- Type string `json:"type"`
- SendReminder bool `json:"sendReminder"`
- Frequency time.Duration `json:"frequency"`
- IsDefault bool `json:"isDefault"`
- Settings *simplejson.Json `json:"settings"`
- Created time.Time `json:"created"`
- Updated time.Time `json:"updated"`
+ Id int64 `json:"id"`
+ OrgId int64 `json:"-"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ SendReminder bool `json:"sendReminder"`
+ DisableResolveMessage bool `json:"disableResolveMessage"`
+ Frequency time.Duration `json:"frequency"`
+ IsDefault bool `json:"isDefault"`
+ Settings *simplejson.Json `json:"settings"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
}
type CreateAlertNotificationCommand struct {
- Name string `json:"name" binding:"Required"`
- Type string `json:"type" binding:"Required"`
- SendReminder bool `json:"sendReminder"`
- Frequency string `json:"frequency"`
- IsDefault bool `json:"isDefault"`
- Settings *simplejson.Json `json:"settings"`
+ Name string `json:"name" binding:"Required"`
+ Type string `json:"type" binding:"Required"`
+ SendReminder bool `json:"sendReminder"`
+ DisableResolveMessage bool `json:"disableResolveMessage"`
+ Frequency string `json:"frequency"`
+ IsDefault bool `json:"isDefault"`
+ Settings *simplejson.Json `json:"settings"`
OrgId int64 `json:"-"`
Result *AlertNotification
}
type UpdateAlertNotificationCommand struct {
- Id int64 `json:"id" binding:"Required"`
- Name string `json:"name" binding:"Required"`
- Type string `json:"type" binding:"Required"`
- SendReminder bool `json:"sendReminder"`
- Frequency string `json:"frequency"`
- IsDefault bool `json:"isDefault"`
- Settings *simplejson.Json `json:"settings" binding:"Required"`
+ Id int64 `json:"id" binding:"Required"`
+ Name string `json:"name" binding:"Required"`
+ Type string `json:"type" binding:"Required"`
+ SendReminder bool `json:"sendReminder"`
+ DisableResolveMessage bool `json:"disableResolveMessage"`
+ Frequency string `json:"frequency"`
+ IsDefault bool `json:"isDefault"`
+ Settings *simplejson.Json `json:"settings" binding:"Required"`
OrgId int64 `json:"-"`
Result *AlertNotification
@@ -76,33 +89,34 @@ type GetAllAlertNotificationsQuery struct {
Result []*AlertNotification
}
-type AlertNotificationJournal struct {
- Id int64
- OrgId int64
- AlertId int64
- NotifierId int64
- SentAt int64
- Success bool
+type AlertNotificationState struct {
+ Id int64
+ OrgId int64
+ AlertId int64
+ NotifierId int64
+ State AlertNotificationStateType
+ Version int64
+ UpdatedAt int64
+ AlertRuleStateUpdatedVersion int64
}
-type RecordNotificationJournalCommand struct {
- OrgId int64
- AlertId int64
- NotifierId int64
- SentAt int64
- Success bool
+type SetAlertNotificationStateToPendingCommand struct {
+ Id int64
+ AlertRuleStateUpdatedVersion int64
+ Version int64
+
+ ResultVersion int64
}
-type GetLatestNotificationQuery struct {
+type SetAlertNotificationStateToCompleteCommand struct {
+ Id int64
+ Version int64
+}
+
+type GetOrCreateNotificationStateQuery struct {
OrgId int64
AlertId int64
NotifierId int64
- Result []AlertNotificationJournal
-}
-
-type CleanNotificationJournalCommand struct {
- OrgId int64
- AlertId int64
- NotifierId int64
+ Result *AlertNotificationState
}
diff --git a/pkg/models/context.go b/pkg/models/context.go
index 262f6550954..c78028665a6 100644
--- a/pkg/models/context.go
+++ b/pkg/models/context.go
@@ -36,7 +36,7 @@ func (ctx *ReqContext) Handle(status int, title string, err error) {
ctx.Data["AppSubUrl"] = setting.AppSubUrl
ctx.Data["Theme"] = "dark"
- ctx.HTML(status, "error")
+ ctx.HTML(status, setting.ERR_TEMPLATE_NAME)
}
func (ctx *ReqContext) JsonOK(message string) {
diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go
index 4b84d840113..e8aebb1d1f4 100644
--- a/pkg/models/dashboards.go
+++ b/pkg/models/dashboards.go
@@ -21,7 +21,6 @@ var (
ErrDashboardVersionMismatch = errors.New("The dashboard has been changed by someone else")
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
ErrDashboardFolderCannotHaveParent = errors.New("A Dashboard Folder cannot be added to another folder")
- ErrDashboardContainsInvalidAlertData = errors.New("Invalid alert data. Cannot save dashboard")
ErrDashboardFailedToUpdateAlertData = errors.New("Failed to save alert data")
ErrDashboardsWithSameSlugExists = errors.New("Multiple dashboards with the same slug exists")
ErrDashboardFailedGenerateUniqueUid = errors.New("Failed to generate unique dashboard id")
diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go
index cbdd0136f4d..b71d17ec0d1 100644
--- a/pkg/models/datasource.go
+++ b/pkg/models/datasource.go
@@ -22,6 +22,7 @@ const (
DS_MSSQL = "mssql"
DS_ACCESS_DIRECT = "direct"
DS_ACCESS_PROXY = "proxy"
+ DS_STACKDRIVER = "stackdriver"
)
var (
@@ -29,6 +30,7 @@ var (
ErrDataSourceNameExists = errors.New("Data source with same name already exists")
ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource")
ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration.")
+ ErrDataSourceAccessDenied = errors.New("Data source access denied")
)
type DsAccess string
@@ -70,12 +72,12 @@ var knownDatasourcePlugins = map[string]bool{
DS_POSTGRES: true,
DS_MYSQL: true,
DS_MSSQL: true,
+ DS_STACKDRIVER: true,
"opennms": true,
"abhisant-druid-datasource": true,
"dalmatinerdb-datasource": true,
"gnocci": true,
"zabbix": true,
- "alexanderzobnin-zabbix-datasource": true,
"newrelic-app": true,
"grafana-datadog-datasource": true,
"grafana-simple-json": true,
@@ -88,6 +90,7 @@ var knownDatasourcePlugins = map[string]bool{
"ayoungprogrammer-finance-datasource": true,
"monasca-datasource": true,
"vertamedia-clickhouse-datasource": true,
+ "alexanderzobnin-zabbix-datasource": true,
}
func IsKnownDataSourcePlugin(dsType string) bool {
@@ -165,6 +168,7 @@ type DeleteDataSourceByNameCommand struct {
type GetDataSourcesQuery struct {
OrgId int64
+ User *SignedInUser
Result []*DataSource
}
@@ -185,6 +189,31 @@ type GetDataSourceByNameQuery struct {
}
// ---------------------
-// EVENTS
-type DataSourceCreatedEvent struct {
+// Permissions
+// ---------------------
+
+type DsPermissionType int
+
+const (
+ DsPermissionNoAccess DsPermissionType = iota
+ DsPermissionQuery
+)
+
+func (p DsPermissionType) String() string {
+ names := map[int]string{
+ int(DsPermissionQuery): "Query",
+ int(DsPermissionNoAccess): "No Access",
+ }
+ return names[int(p)]
+}
+
+type GetDataSourcePermissionsForUserQuery struct {
+ User *SignedInUser
+ Result map[int64]DsPermissionType
+}
+
+type DatasourcesPermissionFilterQuery struct {
+ User *SignedInUser
+ Datasources []*DataSource
+ Result []*DataSource
}
diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go
index 9231d18cfd6..b6ecd924e9a 100644
--- a/pkg/models/org_user.go
+++ b/pkg/models/org_user.go
@@ -72,8 +72,10 @@ type OrgUser struct {
// COMMANDS
type RemoveOrgUserCommand struct {
- UserId int64
- OrgId int64
+ UserId int64
+ OrgId int64
+ ShouldDeleteOrphanedUser bool
+ UserWasDeleted bool
}
type AddOrgUserCommand struct {
diff --git a/pkg/plugins/app_plugin.go b/pkg/plugins/app_plugin.go
index b070ba592f0..922b2444b7b 100644
--- a/pkg/plugins/app_plugin.go
+++ b/pkg/plugins/app_plugin.go
@@ -23,12 +23,13 @@ type AppPlugin struct {
}
type AppPluginRoute struct {
- Path string `json:"path"`
- Method string `json:"method"`
- ReqRole models.RoleType `json:"reqRole"`
- Url string `json:"url"`
- Headers []AppPluginRouteHeader `json:"headers"`
- TokenAuth *JwtTokenAuth `json:"tokenAuth"`
+ Path string `json:"path"`
+ Method string `json:"method"`
+ ReqRole models.RoleType `json:"reqRole"`
+ Url string `json:"url"`
+ Headers []AppPluginRouteHeader `json:"headers"`
+ TokenAuth *JwtTokenAuth `json:"tokenAuth"`
+ JwtTokenAuth *JwtTokenAuth `json:"jwtTokenAuth"`
}
type AppPluginRouteHeader struct {
@@ -36,8 +37,11 @@ type AppPluginRouteHeader struct {
Content string `json:"content"`
}
+// JwtTokenAuth struct is both for normal Token Auth and JWT Token Auth with
+// an uploaded JWT file.
type JwtTokenAuth struct {
Url string `json:"url"`
+ Scopes []string `json:"scopes"`
Params map[string]string `json:"params"`
}
diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go
index 67eb0f51d70..4f15441bb2f 100644
--- a/pkg/plugins/plugins.go
+++ b/pkg/plugins/plugins.go
@@ -121,7 +121,6 @@ func (pm *PluginManager) Run(ctx context.Context) error {
pm.checkForUpdates()
case <-ctx.Done():
run = false
- break
}
}
diff --git a/pkg/services/alerting/conditions/evaluator.go b/pkg/services/alerting/conditions/evaluator.go
index 8d7ca57f010..eef593d39e2 100644
--- a/pkg/services/alerting/conditions/evaluator.go
+++ b/pkg/services/alerting/conditions/evaluator.go
@@ -2,6 +2,7 @@ package conditions
import (
"encoding/json"
+ "fmt"
"github.com/grafana/grafana/pkg/components/null"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -31,12 +32,12 @@ type ThresholdEvaluator struct {
func newThresholdEvaluator(typ string, model *simplejson.Json) (*ThresholdEvaluator, error) {
params := model.Get("params").MustArray()
if len(params) == 0 {
- return nil, alerting.ValidationError{Reason: "Evaluator missing threshold parameter"}
+ return nil, fmt.Errorf("Evaluator missing threshold parameter")
}
firstParam, ok := params[0].(json.Number)
if !ok {
- return nil, alerting.ValidationError{Reason: "Evaluator has invalid parameter"}
+ return nil, fmt.Errorf("Evaluator has invalid parameter")
}
defaultEval := &ThresholdEvaluator{Type: typ}
@@ -107,7 +108,7 @@ func (e *RangedEvaluator) Eval(reducedValue null.Float) bool {
func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
typ := model.Get("type").MustString()
if typ == "" {
- return nil, alerting.ValidationError{Reason: "Evaluator missing type property"}
+ return nil, fmt.Errorf("Evaluator missing type property")
}
if inSlice(typ, defaultTypes) {
@@ -122,7 +123,7 @@ func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
return &NoValueEvaluator{}, nil
}
- return nil, alerting.ValidationError{Reason: "Evaluator invalid evaluator type: " + typ}
+ return nil, fmt.Errorf("Evaluator invalid evaluator type: %s", typ)
}
func inSlice(a string, list []string) bool {
diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go
index 229092e217b..edfab2dedee 100644
--- a/pkg/services/alerting/extractor.go
+++ b/pkg/services/alerting/extractor.go
@@ -82,8 +82,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
if collapsed && collapsedJSON.MustBool() {
// extract alerts from sub panels for collapsed panels
- alertSlice, err := e.getAlertFromPanels(panel,
- validateAlertFunc)
+ alertSlice, err := e.getAlertFromPanels(panel, validateAlertFunc)
if err != nil {
return nil, err
}
@@ -100,7 +99,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
panelID, err := panel.Get("id").Int64()
if err != nil {
- return nil, fmt.Errorf("panel id is required. err %v", err)
+ return nil, ValidationError{Reason: "A numeric panel id property is missing"}
}
// backward compatibility check, can be removed later
@@ -146,7 +145,8 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
datasource, err := e.lookupDatasourceID(dsName)
if err != nil {
- return nil, err
+ e.log.Debug("Error looking up datasource", "error", err)
+ return nil, ValidationError{Reason: fmt.Sprintf("Data source used by alert rule not found, alertName=%v, datasource=%s", alert.Name, dsName)}
}
jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
@@ -167,8 +167,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
}
if !validateAlertFunc(alert) {
- e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId)
- return nil, m.ErrDashboardContainsInvalidAlertData
+ return nil, ValidationError{Reason: fmt.Sprintf("Panel id is not correct, alertName=%v, panelId=%v", alert.Name, alert.PanelId)}
}
alerts = append(alerts, alert)
diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go
index c7212e48174..e2dc01a1181 100644
--- a/pkg/services/alerting/extractor_test.go
+++ b/pkg/services/alerting/extractor_test.go
@@ -258,7 +258,7 @@ func TestAlertRuleExtraction(t *testing.T) {
Convey("Should fail on save", func() {
_, err := extractor.GetAlerts()
- So(err, ShouldEqual, m.ErrDashboardContainsInvalidAlertData)
+ So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
})
})
})
diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go
index 46f8b3c769c..040d0991861 100644
--- a/pkg/services/alerting/interfaces.go
+++ b/pkg/services/alerting/interfaces.go
@@ -3,6 +3,8 @@ package alerting
import (
"context"
"time"
+
+ "github.com/grafana/grafana/pkg/models"
)
type EvalHandler interface {
@@ -20,19 +22,25 @@ type Notifier interface {
NeedsImage() bool
// ShouldNotify checks this evaluation should send an alert notification
- ShouldNotify(ctx context.Context, evalContext *EvalContext) bool
+ ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool
GetNotifierId() int64
GetIsDefault() bool
GetSendReminder() bool
+ GetDisableResolveMessage() bool
GetFrequency() time.Duration
}
-type NotifierSlice []Notifier
+type notifierState struct {
+ notifier Notifier
+ state *models.AlertNotificationState
+}
-func (notifiers NotifierSlice) ShouldUploadImage() bool {
- for _, notifier := range notifiers {
- if notifier.NeedsImage() {
+type notifierStateSlice []*notifierState
+
+func (notifiers notifierStateSlice) ShouldUploadImage() bool {
+ for _, ns := range notifiers {
+ if ns.notifier.NeedsImage() {
return true
}
}
diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go
index cbad5cbfdcf..9ce50eadd6b 100644
--- a/pkg/services/alerting/notifier.go
+++ b/pkg/services/alerting/notifier.go
@@ -1,10 +1,8 @@
package alerting
import (
- "context"
"errors"
"fmt"
- "time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/imguploader"
@@ -41,61 +39,78 @@ type notificationService struct {
}
func (n *notificationService) SendIfNeeded(context *EvalContext) error {
- notifiers, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context)
+ notifierStates, err := n.getNeededNotifiers(context.Rule.OrgId, context.Rule.Notifications, context)
if err != nil {
return err
}
- if len(notifiers) == 0 {
+ if len(notifierStates) == 0 {
return nil
}
- if notifiers.ShouldUploadImage() {
+ if notifierStates.ShouldUploadImage() {
if err = n.uploadImage(context); err != nil {
n.log.Error("Failed to upload alert panel image.", "error", err)
}
}
- return n.sendNotifications(context, notifiers)
+ return n.sendNotifications(context, notifierStates)
}
-func (n *notificationService) sendNotifications(evalContext *EvalContext, notifiers []Notifier) error {
- for _, notifier := range notifiers {
- not := notifier
+func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error {
+ notifier := notifierState.notifier
- err := bus.InTransaction(evalContext.Ctx, func(ctx context.Context) error {
- n.log.Debug("trying to send notification", "id", not.GetNotifierId())
+ n.log.Debug("Sending notification", "type", notifier.GetType(), "id", notifier.GetNotifierId(), "isDefault", notifier.GetIsDefault())
+ metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc()
- // Verify that we can send the notification again
- // but this time within the same transaction.
- if !evalContext.IsTestRun && !not.ShouldNotify(ctx, evalContext) {
- return nil
- }
+ err := notifier.Notify(evalContext)
- n.log.Debug("Sending notification", "type", not.GetType(), "id", not.GetNotifierId(), "isDefault", not.GetIsDefault())
- metrics.M_Alerting_Notification_Sent.WithLabelValues(not.GetType()).Inc()
+ if err != nil {
+ n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err)
+ }
- //send notification
- success := not.Notify(evalContext) == nil
+ if evalContext.IsTestRun {
+ return nil
+ }
- if evalContext.IsTestRun {
- return nil
- }
+ cmd := &m.SetAlertNotificationStateToCompleteCommand{
+ Id: notifierState.state.Id,
+ Version: notifierState.state.Version,
+ }
- //write result to db.
- cmd := &m.RecordNotificationJournalCommand{
- OrgId: evalContext.Rule.OrgId,
- AlertId: evalContext.Rule.Id,
- NotifierId: not.GetNotifierId(),
- SentAt: time.Now().Unix(),
- Success: success,
- }
+ return bus.DispatchCtx(evalContext.Ctx, cmd)
+}
- return bus.DispatchCtx(ctx, cmd)
- })
+func (n *notificationService) sendNotification(evalContext *EvalContext, notifierState *notifierState) error {
+ if !evalContext.IsTestRun {
+ setPendingCmd := &m.SetAlertNotificationStateToPendingCommand{
+ Id: notifierState.state.Id,
+ Version: notifierState.state.Version,
+ AlertRuleStateUpdatedVersion: evalContext.Rule.StateChanges,
+ }
+
+ err := bus.DispatchCtx(evalContext.Ctx, setPendingCmd)
+ if err == m.ErrAlertNotificationStateVersionConflict {
+ return nil
+ }
if err != nil {
- n.log.Error("failed to send notification", "id", not.GetNotifierId())
+ return err
+ }
+
+ // We need to update state version to be able to log
+ // unexpected version conflicts when marking notifications as ok
+ notifierState.state.Version = setPendingCmd.ResultVersion
+ }
+
+ return n.sendAndMarkAsComplete(evalContext, notifierState)
+}
+
+func (n *notificationService) sendNotifications(evalContext *EvalContext, notifierStates notifierStateSlice) error {
+ for _, notifierState := range notifierStates {
+ err := n.sendNotification(evalContext, notifierState)
+ if err != nil {
+ n.log.Error("failed to send notification", "id", notifierState.notifier.GetNotifierId(), "error", err)
}
}
@@ -142,22 +157,38 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
return nil
}
-func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (NotifierSlice, error) {
+func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (notifierStateSlice, error) {
query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds}
if err := bus.Dispatch(query); err != nil {
return nil, err
}
- var result []Notifier
+ var result notifierStateSlice
for _, notification := range query.Result {
not, err := n.createNotifierFor(notification)
if err != nil {
- return nil, err
+ n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
+ continue
}
- if not.ShouldNotify(evalContext.Ctx, evalContext) {
- result = append(result, not)
+ query := &m.GetOrCreateNotificationStateQuery{
+ NotifierId: notification.Id,
+ AlertId: evalContext.Rule.Id,
+ OrgId: evalContext.Rule.OrgId,
+ }
+
+ err = bus.DispatchCtx(evalContext.Ctx, query)
+ if err != nil {
+ n.log.Error("Could not get notification state.", "notifier", notification.Id, "error", err)
+ continue
+ }
+
+ if not.ShouldNotify(evalContext.Ctx, evalContext, query.Result) {
+ result = append(result, ¬ifierState{
+ notifier: not,
+ state: query.Result,
+ })
}
}
diff --git a/pkg/services/alerting/notifiers/alertmanager.go b/pkg/services/alerting/notifiers/alertmanager.go
index 9826dd1dffb..2caa4d5ab58 100644
--- a/pkg/services/alerting/notifiers/alertmanager.go
+++ b/pkg/services/alerting/notifiers/alertmanager.go
@@ -46,7 +46,7 @@ type AlertmanagerNotifier struct {
log log.Logger
}
-func (this *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext) bool {
+func (this *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext, notificationState *m.AlertNotificationState) bool {
this.log.Debug("Should notify", "ruleId", evalContext.Rule.Id, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState)
// Do not notify when we become OK for the first time.
diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go
index d8f9aadffc5..d141d6cd257 100644
--- a/pkg/services/alerting/notifiers/base.go
+++ b/pkg/services/alerting/notifiers/base.go
@@ -4,10 +4,8 @@ import (
"context"
"time"
- "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
-
"github.com/grafana/grafana/pkg/services/alerting"
)
@@ -16,13 +14,14 @@ const (
)
type NotifierBase struct {
- Name string
- Type string
- Id int64
- IsDeault bool
- UploadImage bool
- SendReminder bool
- Frequency time.Duration
+ Name string
+ Type string
+ Id int64
+ IsDeault bool
+ UploadImage bool
+ SendReminder bool
+ DisableResolveMessage bool
+ Frequency time.Duration
log log.Logger
}
@@ -35,67 +34,64 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
}
return NotifierBase{
- Id: model.Id,
- Name: model.Name,
- IsDeault: model.IsDefault,
- Type: model.Type,
- UploadImage: uploadImage,
- SendReminder: model.SendReminder,
- Frequency: model.Frequency,
- log: log.New("alerting.notifier." + model.Name),
+ Id: model.Id,
+ Name: model.Name,
+ IsDeault: model.IsDefault,
+ Type: model.Type,
+ UploadImage: uploadImage,
+ SendReminder: model.SendReminder,
+ DisableResolveMessage: model.DisableResolveMessage,
+ Frequency: model.Frequency,
+ log: log.New("alerting.notifier." + model.Name),
}
}
-func defaultShouldNotify(context *alerting.EvalContext, sendReminder bool, frequency time.Duration, journals []models.AlertNotificationJournal) bool {
+// ShouldNotify checks this evaluation should send an alert notification
+func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalContext, notiferState *models.AlertNotificationState) bool {
// Only notify on state change.
- if context.PrevAlertState == context.Rule.State && !sendReminder {
+ if context.PrevAlertState == context.Rule.State && !n.SendReminder {
return false
}
- // get last successfully sent notification
- lastNotify := time.Time{}
- for _, j := range journals {
- if j.Success {
- lastNotify = time.Unix(j.SentAt, 0)
- break
+ if context.PrevAlertState == context.Rule.State && n.SendReminder {
+ // Do not notify if interval has not elapsed
+ lastNotify := time.Unix(notiferState.UpdatedAt, 0)
+ if notiferState.UpdatedAt != 0 && lastNotify.Add(n.Frequency).After(time.Now()) {
+ return false
+ }
+
+ // Do not notify if alert state is OK or pending even on repeated notify
+ if context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending {
+ return false
}
}
- // Do not notify if interval has not elapsed
- if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) {
- return false
- }
-
- // Do not notify if alert state if OK or pending even on repeated notify
- if sendReminder && (context.Rule.State == models.AlertStateOK || context.Rule.State == models.AlertStatePending) {
- return false
- }
-
// Do not notify when we become OK for the first time.
- if (context.PrevAlertState == models.AlertStatePending) && (context.Rule.State == models.AlertStateOK) {
+ if context.PrevAlertState == models.AlertStatePending && context.Rule.State == models.AlertStateOK {
+ return false
+ }
+
+ // Do not notify when we OK -> Pending
+ if context.PrevAlertState == models.AlertStateOK && context.Rule.State == models.AlertStatePending {
+ return false
+ }
+
+ // Do not notifu if state pending and it have been updated last minute
+ if notiferState.State == models.AlertNotificationStatePending {
+ lastUpdated := time.Unix(notiferState.UpdatedAt, 0)
+ if lastUpdated.Add(1 * time.Minute).After(time.Now()) {
+ return false
+ }
+ }
+
+ // Do not notify when state is OK if DisableResolveMessage is set to true
+ if context.Rule.State == models.AlertStateOK && n.DisableResolveMessage {
return false
}
return true
}
-// ShouldNotify checks this evaluation should send an alert notification
-func (n *NotifierBase) ShouldNotify(ctx context.Context, c *alerting.EvalContext) bool {
- cmd := &models.GetLatestNotificationQuery{
- OrgId: c.Rule.OrgId,
- AlertId: c.Rule.Id,
- NotifierId: n.Id,
- }
-
- err := bus.DispatchCtx(ctx, cmd)
- if err != nil {
- n.log.Error("Could not determine last time alert notifier fired", "Alert name", c.Rule.Name, "Error", err)
- return false
- }
-
- return defaultShouldNotify(c, n.SendReminder, n.Frequency, cmd.Result)
-}
-
func (n *NotifierBase) GetType() string {
return n.Type
}
@@ -116,6 +112,10 @@ func (n *NotifierBase) GetSendReminder() bool {
return n.SendReminder
}
+func (n *NotifierBase) GetDisableResolveMessage() bool {
+ return n.DisableResolveMessage
+}
+
func (n *NotifierBase) GetFrequency() time.Duration {
return n.Frequency
}
diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go
index 9ea4b82fd54..5062828cb4f 100644
--- a/pkg/services/alerting/notifiers/base_test.go
+++ b/pkg/services/alerting/notifiers/base_test.go
@@ -2,12 +2,9 @@ package notifiers
import (
"context"
- "errors"
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
-
"github.com/grafana/grafana/pkg/components/simplejson"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
@@ -23,34 +20,34 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState m.AlertStateType
sendReminder bool
frequency time.Duration
- journals []m.AlertNotificationJournal
+ state *m.AlertNotificationState
expect bool
}{
{
name: "pending -> ok should not trigger an notification",
- newState: m.AlertStatePending,
- prevState: m.AlertStateOK,
+ newState: m.AlertStateOK,
+ prevState: m.AlertStatePending,
sendReminder: false,
- journals: []m.AlertNotificationJournal{},
+ state: &m.AlertNotificationState{},
expect: false,
},
{
name: "ok -> alerting should trigger an notification",
- newState: m.AlertStateOK,
- prevState: m.AlertStateAlerting,
+ newState: m.AlertStateAlerting,
+ prevState: m.AlertStateOK,
sendReminder: false,
- journals: []m.AlertNotificationJournal{},
+ state: &m.AlertNotificationState{},
expect: true,
},
{
name: "ok -> pending should not trigger an notification",
- newState: m.AlertStateOK,
- prevState: m.AlertStatePending,
+ newState: m.AlertStatePending,
+ prevState: m.AlertStateOK,
sendReminder: false,
- journals: []m.AlertNotificationJournal{},
+ state: &m.AlertNotificationState{},
expect: false,
},
@@ -59,100 +56,100 @@ func TestShouldSendAlertNotification(t *testing.T) {
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
sendReminder: false,
- journals: []m.AlertNotificationJournal{},
+ state: &m.AlertNotificationState{},
expect: false,
},
- {
- name: "ok -> alerting should trigger an notification",
- newState: m.AlertStateOK,
- prevState: m.AlertStateAlerting,
- sendReminder: true,
- journals: []m.AlertNotificationJournal{},
-
- expect: true,
- },
{
name: "ok -> ok with reminder should not trigger an notification",
newState: m.AlertStateOK,
prevState: m.AlertStateOK,
sendReminder: true,
- journals: []m.AlertNotificationJournal{},
+ state: &m.AlertNotificationState{},
expect: false,
},
{
- name: "alerting -> alerting with reminder and no journaling should trigger",
- newState: m.AlertStateAlerting,
+ name: "alerting -> ok should trigger an notification",
+ newState: m.AlertStateOK,
prevState: m.AlertStateAlerting,
- frequency: time.Minute * 10,
- sendReminder: true,
- journals: []m.AlertNotificationJournal{},
+ sendReminder: false,
+ state: &m.AlertNotificationState{},
expect: true,
},
{
- name: "alerting -> alerting with reminder and successful recent journal event should not trigger",
+ name: "alerting -> ok should trigger an notification when reminders enabled",
+ newState: m.AlertStateOK,
+ prevState: m.AlertStateAlerting,
+ frequency: time.Minute * 10,
+ sendReminder: true,
+ state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
+
+ expect: true,
+ },
+ {
+ name: "alerting -> alerting with reminder and no state should trigger",
newState: m.AlertStateAlerting,
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
- journals: []m.AlertNotificationJournal{
- {SentAt: tnow.Add(-time.Minute).Unix(), Success: true},
- },
+ state: &m.AlertNotificationState{},
+
+ expect: true,
+ },
+ {
+ name: "alerting -> alerting with reminder and last notification sent 1 minute ago should not trigger",
+ newState: m.AlertStateAlerting,
+ prevState: m.AlertStateAlerting,
+ frequency: time.Minute * 10,
+ sendReminder: true,
+ state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
expect: false,
},
{
- name: "alerting -> alerting with reminder and failed recent journal event should trigger",
+ name: "alerting -> alerting with reminder and last notifciation sent 11 minutes ago should trigger",
newState: m.AlertStateAlerting,
prevState: m.AlertStateAlerting,
frequency: time.Minute * 10,
sendReminder: true,
- expect: true,
- journals: []m.AlertNotificationJournal{
- {SentAt: tnow.Add(-time.Minute).Unix(), Success: false}, // recent failed notification
- {SentAt: tnow.Add(-time.Hour).Unix(), Success: true}, // old successful notification
- },
+ state: &m.AlertNotificationState{UpdatedAt: tnow.Add(-11 * time.Minute).Unix()},
+
+ expect: true,
+ },
+ {
+ name: "OK -> alerting with notifciation state pending and updated 30 seconds ago should not trigger",
+ newState: m.AlertStateAlerting,
+ prevState: m.AlertStateOK,
+ state: &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-30 * time.Second).Unix()},
+
+ expect: false,
+ },
+ {
+ name: "OK -> alerting with notifciation state pending and updated 2 minutes ago should trigger",
+ newState: m.AlertStateAlerting,
+ prevState: m.AlertStateOK,
+ state: &m.AlertNotificationState{State: m.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()},
+
+ expect: true,
},
}
for _, tc := range tcs {
evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{
- State: tc.newState,
+ State: tc.prevState,
})
- evalContext.Rule.State = tc.prevState
- if defaultShouldNotify(evalContext, true, tc.frequency, tc.journals) != tc.expect {
+ evalContext.Rule.State = tc.newState
+ nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency}
+
+ if nb.ShouldNotify(evalContext.Ctx, evalContext, tc.state) != tc.expect {
t.Errorf("failed test %s.\n expected \n%+v \nto return: %v", tc.name, tc, tc.expect)
}
}
}
-func TestShouldNotifyWhenNoJournalingIsFound(t *testing.T) {
- Convey("base notifier", t, func() {
- bus.ClearBusHandlers()
-
- notifier := NewNotifierBase(&m.AlertNotification{
- Id: 1,
- Name: "name",
- Type: "email",
- Settings: simplejson.New(),
- })
- evalContext := alerting.NewEvalContext(context.TODO(), &alerting.Rule{})
-
- Convey("should not notify query returns error", func() {
- bus.AddHandlerCtx("", func(ctx context.Context, q *m.GetLatestNotificationQuery) error {
- return errors.New("some kind of error unknown error")
- })
-
- if notifier.ShouldNotify(context.Background(), evalContext) {
- t.Errorf("should not send notifications when query returns error")
- }
- })
- })
-}
-
func TestBaseNotifier(t *testing.T) {
Convey("default constructor for notifiers", t, func() {
bJson := simplejson.New()
@@ -182,5 +179,10 @@ func TestBaseNotifier(t *testing.T) {
base := NewNotifierBase(model)
So(base.UploadImage, ShouldBeTrue)
})
+
+ Convey("default value should be false for backwards compatibility", func() {
+ base := NewNotifierBase(model)
+ So(base.DisableResolveMessage, ShouldBeFalse)
+ })
})
}
diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go
index 738e43af2d2..1ef085c82f1 100644
--- a/pkg/services/alerting/notifiers/dingding.go
+++ b/pkg/services/alerting/notifiers/dingding.go
@@ -57,6 +57,9 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
message := evalContext.Rule.Message
picUrl := evalContext.ImagePublicUrl
title := evalContext.GetNotificationTitle()
+ if message == "" {
+ message = title
+ }
bodyJSON, err := simplejson.NewJson([]byte(`{
"msgtype": "link",
diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go
index 5492de45d39..4a4a989d873 100644
--- a/pkg/services/alerting/notifiers/telegram.go
+++ b/pkg/services/alerting/notifiers/telegram.go
@@ -14,7 +14,7 @@ import (
)
const (
- captionLengthLimit = 200
+ captionLengthLimit = 1024
)
var (
@@ -127,7 +127,13 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
var err error
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
- defer imageFile.Close()
+ defer func() {
+ err := imageFile.Close()
+ if err != nil {
+ log.Error2("Could not close Telegram inline image.", "err", err)
+ }
+ }()
+
if err != nil {
return nil, err
}
diff --git a/pkg/services/alerting/notifiers/telegram_test.go b/pkg/services/alerting/notifiers/telegram_test.go
index 98c8d884ad0..9906a2ffd95 100644
--- a/pkg/services/alerting/notifiers/telegram_test.go
+++ b/pkg/services/alerting/notifiers/telegram_test.go
@@ -1,6 +1,7 @@
package notifiers
import (
+ "context"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -52,14 +53,15 @@ func TestTelegramNotifier(t *testing.T) {
})
Convey("generateCaption should generate a message with all pertinent details", func() {
- evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
- Name: "This is an alarm",
- Message: "Some kind of message.",
- State: m.AlertStateOK,
- })
+ evalContext := alerting.NewEvalContext(context.Background(),
+ &alerting.Rule{
+ Name: "This is an alarm",
+ Message: "Some kind of message.",
+ State: m.AlertStateOK,
+ })
caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "")
- So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+ So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "Some kind of message.")
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldContainSubstring, "http://grafa.url/abcdef")
@@ -68,16 +70,17 @@ func TestTelegramNotifier(t *testing.T) {
Convey("When generating a message", func() {
Convey("URL should be skipped if it's too long", func() {
- evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
- Name: "This is an alarm",
- Message: "Some kind of message.",
- State: m.AlertStateOK,
- })
+ evalContext := alerting.NewEvalContext(context.Background(),
+ &alerting.Rule{
+ Name: "This is an alarm",
+ Message: "Some kind of message.",
+ State: m.AlertStateOK,
+ })
caption := generateImageCaption(evalContext,
- "http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"foo bar")
- So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+ So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "Some kind of message.")
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldContainSubstring, "foo bar")
@@ -85,32 +88,34 @@ func TestTelegramNotifier(t *testing.T) {
})
Convey("Message should be trimmed if it's too long", func() {
- evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
- Name: "This is an alarm",
- Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.",
- State: m.AlertStateOK,
- })
+ evalContext := alerting.NewEvalContext(context.Background(),
+ &alerting.Rule{
+ Name: "This is an alarm",
+ Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis scelerisque. Nulla ipsum ex, iaculis vitae vehicula sit amet, fermentum eu eros.",
+ State: m.AlertStateOK,
+ })
caption := generateImageCaption(evalContext,
"http://grafa.url/foo",
"")
- So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+ So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldNotContainSubstring, "http")
- So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise ")
+ So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri")
})
Convey("Metrics should be skipped if they don't fit", func() {
- evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
- Name: "This is an alarm",
- Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ",
- State: m.AlertStateOK,
- })
+ evalContext := alerting.NewEvalContext(context.Background(),
+ &alerting.Rule{
+ Name: "This is an alarm",
+ Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it. But suddenly Telegram increased the length so now we need some lorem ipsum to fix this test. Here we go: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus consectetur molestie cursus. Donec suscipit egestas nisi. Proin ut efficitur ex. Mauris mi augue, volutpat a nisi vel, euismod dictum arcu. Sed quis tempor eros, sed malesuada dolor. Ut orci augue, viverra sit amet blandit quis, faucibus sit amet ex. Duis condimentum efficitur lectus, id dignissim quam tempor id. Morbi sollicitudin rhoncus diam, id tincidunt lectus scelerisque vitae. Etiam imperdiet semper sem, vel eleifend ligula mollis eget. Etiam ultrices fringilla lacus, sit amet pharetra ex blandit quis. Suspendisse in egestas neque, et posuere lectus. Vestibulum eu ex dui. Sed molestie nulla a lobortis sceleri",
+ State: m.AlertStateOK,
+ })
caption := generateImageCaption(evalContext,
"http://grafa.url/foo",
"foo bar long song")
- So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+ So(len(caption), ShouldBeLessThanOrEqualTo, 1024)
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
So(caption, ShouldNotContainSubstring, "http")
So(caption, ShouldNotContainSubstring, "foo bar")
diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go
index 627159c286b..2cdbc57b41d 100644
--- a/pkg/services/alerting/reader.go
+++ b/pkg/services/alerting/reader.go
@@ -34,11 +34,8 @@ func NewRuleReader() *DefaultRuleReader {
func (arr *DefaultRuleReader) initReader() {
heartbeat := time.NewTicker(time.Second * 10)
- for {
- select {
- case <-heartbeat.C:
- arr.heartbeat()
- }
+ for range heartbeat.C {
+ arr.heartbeat()
}
}
diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go
index 893cca948f9..420ffeb9a55 100644
--- a/pkg/services/alerting/result_handler.go
+++ b/pkg/services/alerting/result_handler.go
@@ -67,6 +67,12 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
}
handler.log.Error("Failed to save state", "error", err)
+ } else {
+
+ // StateChanges is used for de duping alert notifications
+ // when two servers are raising. This makes sure that the server
+ // with the last state change always sends a notification.
+ evalContext.Rule.StateChanges = cmd.Result.StateChanges
}
// save annotation
@@ -88,19 +94,6 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
}
}
- if evalContext.Rule.State == m.AlertStateOK && evalContext.PrevAlertState != m.AlertStateOK {
- for _, notifierId := range evalContext.Rule.Notifications {
- cmd := &m.CleanNotificationJournalCommand{
- AlertId: evalContext.Rule.Id,
- NotifierId: notifierId,
- OrgId: evalContext.Rule.OrgId,
- }
- if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
- handler.log.Error("Failed to clean up old notification records", "notifier", notifierId, "alert", evalContext.Rule.Id, "Error", err)
- }
- }
- }
-
handler.notifier.SendIfNeeded(evalContext)
return nil
}
diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go
index 018d138dbe4..999611f15c4 100644
--- a/pkg/services/alerting/rule.go
+++ b/pkg/services/alerting/rule.go
@@ -23,6 +23,8 @@ type Rule struct {
State m.AlertStateType
Conditions []Condition
Notifications []int64
+
+ StateChanges int64
}
type ValidationError struct {
@@ -34,13 +36,13 @@ type ValidationError struct {
}
func (e ValidationError) Error() string {
- extraInfo := ""
+ extraInfo := e.Reason
if e.Alertid != 0 {
extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.Alertid)
}
if e.PanelId != 0 {
- extraInfo = fmt.Sprintf("%s PanelId: %v ", extraInfo, e.PanelId)
+ extraInfo = fmt.Sprintf("%s PanelId: %v", extraInfo, e.PanelId)
}
if e.DashboardId != 0 {
@@ -48,10 +50,10 @@ func (e ValidationError) Error() string {
}
if e.Err != nil {
- return fmt.Sprintf("%s %s%s", e.Err.Error(), e.Reason, extraInfo)
+ return fmt.Sprintf("Alert validation error: %s%s", e.Err.Error(), extraInfo)
}
- return fmt.Sprintf("Failed to extract alert.Reason: %s %s", e.Reason, extraInfo)
+ return fmt.Sprintf("Alert validation error: %s", extraInfo)
}
var (
@@ -100,6 +102,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
model.State = ruleDef.State
model.NoDataState = m.NoDataOption(ruleDef.Settings.Get("noDataState").MustString("no_data"))
model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting"))
+ model.StateChanges = ruleDef.StateChanges
for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
jsonModel := simplejson.NewFromAny(v)
@@ -125,7 +128,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
}
if len(model.Conditions) == 0 {
- return nil, fmt.Errorf("Alert is missing conditions")
+ return nil, ValidationError{Reason: "Alert is missing conditions"}
}
return model, nil
diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go
index 8421360b5ed..8aa1b80aa22 100644
--- a/pkg/services/alerting/test_notification.go
+++ b/pkg/services/alerting/test_notification.go
@@ -39,7 +39,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
return err
}
- return notifier.sendNotifications(createTestEvalContext(cmd), []Notifier{notifiers})
+ return notifier.sendNotifications(createTestEvalContext(cmd), notifierStateSlice{{notifier: notifiers}})
}
func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext {
diff --git a/pkg/services/alerting/ticker.go b/pkg/services/alerting/ticker.go
index 5ce19b1b232..8cee2653ee9 100644
--- a/pkg/services/alerting/ticker.go
+++ b/pkg/services/alerting/ticker.go
@@ -37,10 +37,6 @@ func NewTicker(last time.Time, initialOffset time.Duration, c clock.Clock) *Tick
return t
}
-func (t *Ticker) updateOffset(offset time.Duration) {
- t.newOffset <- offset
-}
-
func (t *Ticker) run() {
for {
next := t.last.Add(time.Duration(1) * time.Second)
diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go
index 521601a358b..c15ae8ef36c 100644
--- a/pkg/services/cleanup/cleanup.go
+++ b/pkg/services/cleanup/cleanup.go
@@ -73,7 +73,7 @@ func (srv *CleanUpService) cleanUpTmpFiles() {
}
}
- srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "keept", len(files))
+ srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "kept", len(files))
}
func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.Time) bool {
diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go
index 278421e6be7..8eb7f4a6e72 100644
--- a/pkg/services/dashboards/dashboard_service.go
+++ b/pkg/services/dashboards/dashboard_service.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/util"
@@ -25,7 +26,9 @@ type DashboardProvisioningService interface {
// NewService factory for creating a new dashboard service
var NewService = func() DashboardService {
- return &dashboardServiceImpl{}
+ return &dashboardServiceImpl{
+ log: log.New("dashboard-service"),
+ }
}
// NewProvisioningService factory for creating a new dashboard provisioning service
@@ -45,6 +48,7 @@ type SaveDashboardDTO struct {
type dashboardServiceImpl struct {
orgId int64
user *models.SignedInUser
+ log log.Logger
}
func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
@@ -89,7 +93,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
}
if err := bus.Dispatch(&validateAlertsCmd); err != nil {
- return nil, models.ErrDashboardContainsInvalidAlertData
+ return nil, err
}
}
diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go
index f9d487f625c..b8300a5af8d 100644
--- a/pkg/services/dashboards/dashboard_service_test.go
+++ b/pkg/services/dashboards/dashboard_service_test.go
@@ -117,12 +117,12 @@ func TestDashboardService(t *testing.T) {
})
bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
- return errors.New("error")
+ return errors.New("Alert validation error")
})
dto.Dashboard = models.NewDashboard("Dash")
_, err := service.SaveDashboard(dto)
- So(err, ShouldEqual, models.ErrDashboardContainsInvalidAlertData)
+ So(err.Error(), ShouldEqual, "Alert validation error")
})
})
diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go
index 7506338c5f0..366bc90fc37 100644
--- a/pkg/services/guardian/guardian.go
+++ b/pkg/services/guardian/guardian.go
@@ -40,7 +40,7 @@ var New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardia
user: user,
dashId: dashId,
orgId: orgId,
- log: log.New("guardians.dashboard"),
+ log: log.New("dashboard.permissions"),
}
}
@@ -66,15 +66,30 @@ func (g *dashboardGuardianImpl) CanAdmin() (bool, error) {
func (g *dashboardGuardianImpl) HasPermission(permission m.PermissionType) (bool, error) {
if g.user.OrgRole == m.ROLE_ADMIN {
- return true, nil
+ return g.logHasPermissionResult(permission, true, nil)
}
acl, err := g.GetAcl()
if err != nil {
- return false, err
+ return g.logHasPermissionResult(permission, false, err)
}
- return g.checkAcl(permission, acl)
+ result, err := g.checkAcl(permission, acl)
+ return g.logHasPermissionResult(permission, result, err)
+}
+
+func (g *dashboardGuardianImpl) logHasPermissionResult(permission m.PermissionType, hasPermission bool, err error) (bool, error) {
+ if err != nil {
+ return hasPermission, err
+ }
+
+ if hasPermission {
+ g.log.Debug("User granted access to execute action", "userId", g.user.UserId, "orgId", g.orgId, "uname", g.user.Login, "dashId", g.dashId, "action", permission)
+ } else {
+ g.log.Debug("User denied access to execute action", "userId", g.user.UserId, "orgId", g.orgId, "uname", g.user.Login, "dashId", g.dashId, "action", permission)
+ }
+
+ return hasPermission, err
}
func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) {
diff --git a/pkg/services/hooks/hooks.go b/pkg/services/hooks/hooks.go
new file mode 100644
index 00000000000..c51650cf6c9
--- /dev/null
+++ b/pkg/services/hooks/hooks.go
@@ -0,0 +1,30 @@
+package hooks
+
+import (
+ "github.com/grafana/grafana/pkg/api/dtos"
+ "github.com/grafana/grafana/pkg/registry"
+)
+
+type IndexDataHook func(indexData *dtos.IndexViewData)
+
+type HooksService struct {
+ indexDataHooks []IndexDataHook
+}
+
+func init() {
+ registry.RegisterService(&HooksService{})
+}
+
+func (srv *HooksService) Init() error {
+ return nil
+}
+
+func (srv *HooksService) AddIndexDataHook(hook IndexDataHook) {
+ srv.indexDataHooks = append(srv.indexDataHooks, hook)
+}
+
+func (srv *HooksService) RunIndexDataHooks(indexData *dtos.IndexViewData) {
+ for _, hook := range srv.indexDataHooks {
+ hook(indexData)
+ }
+}
diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go
index 504c10c22ec..d54b70e704f 100644
--- a/pkg/services/notifications/notifications_test.go
+++ b/pkg/services/notifications/notifications_test.go
@@ -9,12 +9,6 @@ import (
. "github.com/smartystreets/goconvey/convey"
)
-type testTriggeredAlert struct {
- ActualValue float64
- Name string
- State string
-}
-
func TestNotifications(t *testing.T) {
Convey("Given the notifications service", t, func() {
diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go
index ef27ba97235..ea093860f3e 100644
--- a/pkg/services/provisioning/dashboards/file_reader.go
+++ b/pkg/services/provisioning/dashboards/file_reader.go
@@ -43,26 +43,6 @@ func NewDashboardFileReader(cfg *DashboardsAsConfig, log log.Logger) (*fileReade
log.Warn("[Deprecated] The folder property is deprecated. Please use path instead.")
}
- if _, err := os.Stat(path); os.IsNotExist(err) {
- log.Error("Cannot read directory", "error", err)
- }
-
- copy := path
- path, err := filepath.Abs(path)
- if err != nil {
- log.Error("Could not create absolute path ", "path", path)
- }
-
- path, err = filepath.EvalSymlinks(path)
- if err != nil {
- log.Error("Failed to read content of symlinked path: %s", path)
- }
-
- if path == "" {
- path = copy
- log.Info("falling back to original path due to EvalSymlink/Abs failure")
- }
-
return &fileReader{
Cfg: cfg,
Path: path,
@@ -99,7 +79,8 @@ func (fr *fileReader) ReadAndListen(ctx context.Context) error {
}
func (fr *fileReader) startWalkingDisk() error {
- if _, err := os.Stat(fr.Path); err != nil {
+ resolvedPath := fr.resolvePath(fr.Path)
+ if _, err := os.Stat(resolvedPath); err != nil {
if os.IsNotExist(err) {
return err
}
@@ -116,7 +97,7 @@ func (fr *fileReader) startWalkingDisk() error {
}
filesFoundOnDisk := map[string]os.FileInfo{}
- err = filepath.Walk(fr.Path, createWalkFn(filesFoundOnDisk))
+ err = filepath.Walk(resolvedPath, createWalkFn(filesFoundOnDisk))
if err != nil {
return err
}
@@ -156,7 +137,7 @@ func (fr *fileReader) deleteDashboardIfFileIsMissing(provisionedDashboardRefs ma
cmd := &models.DeleteDashboardCommand{OrgId: fr.Cfg.OrgId, Id: dashboardId}
err := bus.Dispatch(cmd)
if err != nil {
- fr.log.Error("failed to delete dashboard", "id", cmd.Id)
+ fr.log.Error("failed to delete dashboard", "id", cmd.Id, "error", err)
}
}
}
@@ -344,6 +325,29 @@ func (fr *fileReader) readDashboardFromFile(path string, lastModified time.Time,
}, nil
}
+func (fr *fileReader) resolvePath(path string) string {
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ fr.log.Error("Cannot read directory", "error", err)
+ }
+
+ copy := path
+ path, err := filepath.Abs(path)
+ if err != nil {
+ fr.log.Error("Could not create absolute path ", "path", path)
+ }
+
+ path, err = filepath.EvalSymlinks(path)
+ if err != nil {
+ fr.log.Error("Failed to read content of symlinked path: %s", path)
+ }
+
+ if path == "" {
+ path = copy
+ fr.log.Info("falling back to original path due to EvalSymlink/Abs failure")
+ }
+ return path
+}
+
type provisioningMetadata struct {
uid string
title string
diff --git a/pkg/services/provisioning/dashboards/file_reader_linux_test.go b/pkg/services/provisioning/dashboards/file_reader_linux_test.go
index 9d4cdae8609..77f488ebcfb 100644
--- a/pkg/services/provisioning/dashboards/file_reader_linux_test.go
+++ b/pkg/services/provisioning/dashboards/file_reader_linux_test.go
@@ -30,10 +30,11 @@ func TestProvsionedSymlinkedFolder(t *testing.T) {
want, err := filepath.Abs(containingId)
if err != nil {
- t.Errorf("expected err to be nill")
+ t.Errorf("expected err to be nil")
}
- if reader.Path != want {
- t.Errorf("got %s want %s", reader.Path, want)
+ resolvedPath := reader.resolvePath(reader.Path)
+ if resolvedPath != want {
+ t.Errorf("got %s want %s", resolvedPath, want)
}
}
diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go
index bdc1e95aafe..fe849816553 100644
--- a/pkg/services/provisioning/dashboards/file_reader_test.go
+++ b/pkg/services/provisioning/dashboards/file_reader_test.go
@@ -67,7 +67,8 @@ func TestCreatingNewDashboardFileReader(t *testing.T) {
reader, err := NewDashboardFileReader(cfg, log.New("test-logger"))
So(err, ShouldBeNil)
- So(filepath.IsAbs(reader.Path), ShouldBeTrue)
+ resolvedPath := reader.resolvePath(reader.Path)
+ So(filepath.IsAbs(resolvedPath), ShouldBeTrue)
})
})
}
diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go
index ba898769578..2f17402b80c 100644
--- a/pkg/services/sqlstore/alert.go
+++ b/pkg/services/sqlstore/alert.go
@@ -60,6 +60,10 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro
return err
}
+ if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil {
+ return err
+ }
+
return nil
}
@@ -275,6 +279,8 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error {
}
sess.ID(alert.Id).Update(&alert)
+
+ cmd.Result = alert
return nil
})
}
diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go
index df247e6891d..afe6269510f 100644
--- a/pkg/services/sqlstore/alert_notification.go
+++ b/pkg/services/sqlstore/alert_notification.go
@@ -3,6 +3,7 @@ package sqlstore
import (
"bytes"
"context"
+ "errors"
"fmt"
"strings"
"time"
@@ -18,16 +19,23 @@ func init() {
bus.AddHandler("sql", DeleteAlertNotification)
bus.AddHandler("sql", GetAlertNotificationsToSend)
bus.AddHandler("sql", GetAllAlertNotifications)
- bus.AddHandlerCtx("sql", RecordNotificationJournal)
- bus.AddHandlerCtx("sql", GetLatestNotification)
- bus.AddHandlerCtx("sql", CleanNotificationJournal)
+ bus.AddHandlerCtx("sql", GetOrCreateAlertNotificationState)
+ bus.AddHandlerCtx("sql", SetAlertNotificationStateToCompleteCommand)
+ bus.AddHandlerCtx("sql", SetAlertNotificationStateToPendingCommand)
}
func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
return inTransaction(func(sess *DBSession) error {
sql := "DELETE FROM alert_notification WHERE alert_notification.org_id = ? AND alert_notification.id = ?"
- _, err := sess.Exec(sql, cmd.OrgId, cmd.Id)
- return err
+ if _, err := sess.Exec(sql, cmd.OrgId, cmd.Id); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_notification_state.org_id = ? AND alert_notification_state.notifier_id = ?", cmd.OrgId, cmd.Id); err != nil {
+ return err
+ }
+
+ return nil
})
}
@@ -58,6 +66,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
alert_notification.updated,
alert_notification.settings,
alert_notification.is_default,
+ alert_notification.disable_resolve_message,
alert_notification.send_reminder,
alert_notification.frequency
FROM alert_notification
@@ -98,6 +107,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS
alert_notification.updated,
alert_notification.settings,
alert_notification.is_default,
+ alert_notification.disable_resolve_message,
alert_notification.send_reminder,
alert_notification.frequency
FROM alert_notification
@@ -158,15 +168,16 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
}
alertNotification := &m.AlertNotification{
- OrgId: cmd.OrgId,
- Name: cmd.Name,
- Type: cmd.Type,
- Settings: cmd.Settings,
- SendReminder: cmd.SendReminder,
- Frequency: frequency,
- Created: time.Now(),
- Updated: time.Now(),
- IsDefault: cmd.IsDefault,
+ OrgId: cmd.OrgId,
+ Name: cmd.Name,
+ Type: cmd.Type,
+ Settings: cmd.Settings,
+ SendReminder: cmd.SendReminder,
+ DisableResolveMessage: cmd.DisableResolveMessage,
+ Frequency: frequency,
+ Created: time.Now(),
+ Updated: time.Now(),
+ IsDefault: cmd.IsDefault,
}
if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil {
@@ -202,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
current.Type = cmd.Type
current.IsDefault = cmd.IsDefault
current.SendReminder = cmd.SendReminder
+ current.DisableResolveMessage = cmd.DisableResolveMessage
if current.SendReminder {
if cmd.Frequency == "" {
@@ -216,7 +228,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
current.Frequency = frequency
}
- sess.UseBool("is_default", "send_reminder")
+ sess.UseBool("is_default", "send_reminder", "disable_resolve_message")
if affected, err := sess.ID(cmd.Id).Update(current); err != nil {
return err
@@ -229,44 +241,123 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
})
}
-func RecordNotificationJournal(ctx context.Context, cmd *m.RecordNotificationJournalCommand) error {
- return withDbSession(ctx, func(sess *DBSession) error {
- journalEntry := &m.AlertNotificationJournal{
- OrgId: cmd.OrgId,
- AlertId: cmd.AlertId,
- NotifierId: cmd.NotifierId,
- SentAt: cmd.SentAt,
- Success: cmd.Success,
- }
+func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error {
+ return inTransactionCtx(ctx, func(sess *DBSession) error {
+ version := cmd.Version
+ var current m.AlertNotificationState
+ sess.ID(cmd.Id).Get(¤t)
- _, err := sess.Insert(journalEntry)
- return err
- })
-}
+ newVersion := cmd.Version + 1
-func GetLatestNotification(ctx context.Context, cmd *m.GetLatestNotificationQuery) error {
- return withDbSession(ctx, func(sess *DBSession) error {
- nj := []m.AlertNotificationJournal{}
+ sql := `UPDATE alert_notification_state SET
+ state = ?,
+ version = ?,
+ updated_at = ?
+ WHERE
+ id = ?`
- err := sess.Desc("alert_notification_journal.sent_at").
- Where("alert_notification_journal.org_id = ?", cmd.OrgId).
- Where("alert_notification_journal.alert_id = ?", cmd.AlertId).
- Where("alert_notification_journal.notifier_id = ?", cmd.NotifierId).
- Find(&nj)
+ _, err := sess.Exec(sql, m.AlertNotificationStateCompleted, newVersion, timeNow().Unix(), cmd.Id)
if err != nil {
return err
}
- cmd.Result = nj
+ if current.Version != version {
+ sqlog.Error("notification state out of sync. the notification is marked as complete but has been modified between set as pending and completion.", "notifierId", current.NotifierId)
+ }
+
return nil
})
}
-func CleanNotificationJournal(ctx context.Context, cmd *m.CleanNotificationJournalCommand) error {
- return inTransactionCtx(ctx, func(sess *DBSession) error {
- sql := "DELETE FROM alert_notification_journal WHERE alert_notification_journal.org_id = ? AND alert_notification_journal.alert_id = ? AND alert_notification_journal.notifier_id = ?"
- _, err := sess.Exec(sql, cmd.OrgId, cmd.AlertId, cmd.NotifierId)
- return err
+func SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToPendingCommand) error {
+ return withDbSession(ctx, func(sess *DBSession) error {
+ newVersion := cmd.Version + 1
+ sql := `UPDATE alert_notification_state SET
+ state = ?,
+ version = ?,
+ updated_at = ?,
+ alert_rule_state_updated_version = ?
+ WHERE
+ id = ? AND
+ (version = ? OR alert_rule_state_updated_version < ?)`
+
+ res, err := sess.Exec(sql,
+ m.AlertNotificationStatePending,
+ newVersion,
+ timeNow().Unix(),
+ cmd.AlertRuleStateUpdatedVersion,
+ cmd.Id,
+ cmd.Version,
+ cmd.AlertRuleStateUpdatedVersion)
+
+ if err != nil {
+ return err
+ }
+
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ return m.ErrAlertNotificationStateVersionConflict
+ }
+
+ cmd.ResultVersion = newVersion
+
+ return nil
})
}
+
+func GetOrCreateAlertNotificationState(ctx context.Context, cmd *m.GetOrCreateNotificationStateQuery) error {
+ return inTransactionCtx(ctx, func(sess *DBSession) error {
+ nj := &m.AlertNotificationState{}
+
+ exist, err := getAlertNotificationState(sess, cmd, nj)
+
+ // if exists, return it, otherwise create it with default values
+ if err != nil {
+ return err
+ }
+
+ if exist {
+ cmd.Result = nj
+ return nil
+ }
+
+ notificationState := &m.AlertNotificationState{
+ OrgId: cmd.OrgId,
+ AlertId: cmd.AlertId,
+ NotifierId: cmd.NotifierId,
+ State: m.AlertNotificationStateUnknown,
+ UpdatedAt: timeNow().Unix(),
+ }
+
+ if _, err := sess.Insert(notificationState); err != nil {
+ if dialect.IsUniqueConstraintViolation(err) {
+ exist, err = getAlertNotificationState(sess, cmd, nj)
+
+ if err != nil {
+ return err
+ }
+
+ if !exist {
+ return errors.New("Should not happen")
+ }
+
+ cmd.Result = nj
+ return nil
+ }
+
+ return err
+ }
+
+ cmd.Result = notificationState
+ return nil
+ })
+}
+
+func getAlertNotificationState(sess *DBSession, cmd *m.GetOrCreateNotificationStateQuery, nj *m.AlertNotificationState) (bool, error) {
+ return sess.
+ Where("alert_notification_state.org_id = ?", cmd.OrgId).
+ Where("alert_notification_state.alert_id = ?", cmd.AlertId).
+ Where("alert_notification_state.notifier_id = ?", cmd.NotifierId).
+ Get(nj)
+}
diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go
index 1e3df45b5cf..629a6292eb5 100644
--- a/pkg/services/sqlstore/alert_notification_test.go
+++ b/pkg/services/sqlstore/alert_notification_test.go
@@ -6,7 +6,7 @@ import (
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
- m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/models"
. "github.com/smartystreets/goconvey/convey"
)
@@ -14,58 +14,133 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("Testing Alert notification sql access", t, func() {
InitTestDB(t)
- Convey("Alert notification journal", func() {
- var alertId int64 = 7
- var orgId int64 = 5
- var notifierId int64 = 10
+ Convey("Alert notification state", func() {
+ var alertID int64 = 7
+ var orgID int64 = 5
+ var notifierID int64 = 10
+ oldTimeNow := timeNow
+ now := time.Date(2018, 9, 30, 0, 0, 0, 0, time.UTC)
+ timeNow = func() time.Time { return now }
- Convey("Getting last journal should raise error if no one exists", func() {
- query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
- GetLatestNotification(context.Background(), query)
- So(len(query.Result), ShouldEqual, 0)
-
- // recording an journal entry in another org to make sure org filter works as expected.
- journalInOtherOrg := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: 10, Success: true, SentAt: 1}
- err := RecordNotificationJournal(context.Background(), journalInOtherOrg)
+ Convey("Get no existing state should create a new state", func() {
+ query := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+ err := GetOrCreateAlertNotificationState(context.Background(), query)
So(err, ShouldBeNil)
+ So(query.Result, ShouldNotBeNil)
+ So(query.Result.State, ShouldEqual, "unknown")
+ So(query.Result.Version, ShouldEqual, 0)
+ So(query.Result.UpdatedAt, ShouldEqual, now.Unix())
- Convey("should be able to record two journaling events", func() {
- createCmd := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId, Success: true, SentAt: 1}
-
- err := RecordNotificationJournal(context.Background(), createCmd)
+ Convey("Get existing state should not create a new state", func() {
+ query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+ err := GetOrCreateAlertNotificationState(context.Background(), query2)
So(err, ShouldBeNil)
+ So(query2.Result, ShouldNotBeNil)
+ So(query2.Result.Id, ShouldEqual, query.Result.Id)
+ So(query2.Result.UpdatedAt, ShouldEqual, now.Unix())
+ })
- createCmd.SentAt += 1000 //increase epoch
+ Convey("Update existing state to pending with correct version should update database", func() {
+ s := *query.Result
- err = RecordNotificationJournal(context.Background(), createCmd)
+ cmd := models.SetAlertNotificationStateToPendingCommand{
+ Id: s.Id,
+ Version: s.Version,
+ AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
+ }
+
+ err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
So(err, ShouldBeNil)
+ So(cmd.ResultVersion, ShouldEqual, 1)
- Convey("get last journaling event", func() {
- err := GetLatestNotification(context.Background(), query)
+ query2 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+ err = GetOrCreateAlertNotificationState(context.Background(), query2)
+ So(err, ShouldBeNil)
+ So(query2.Result.Version, ShouldEqual, 1)
+ So(query2.Result.State, ShouldEqual, models.AlertNotificationStatePending)
+ So(query2.Result.UpdatedAt, ShouldEqual, now.Unix())
+
+ Convey("Update existing state to completed should update database", func() {
+ s := *query.Result
+ setStateCmd := models.SetAlertNotificationStateToCompleteCommand{
+ Id: s.Id,
+ Version: cmd.ResultVersion,
+ }
+ err := SetAlertNotificationStateToCompleteCommand(context.Background(), &setStateCmd)
So(err, ShouldBeNil)
- So(len(query.Result), ShouldEqual, 2)
- last := query.Result[0]
- So(last.SentAt, ShouldEqual, 1001)
- Convey("be able to clear all journaling for an notifier", func() {
- cmd := &m.CleanNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId}
- err := CleanNotificationJournal(context.Background(), cmd)
- So(err, ShouldBeNil)
+ query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+ err = GetOrCreateAlertNotificationState(context.Background(), query3)
+ So(err, ShouldBeNil)
+ So(query3.Result.Version, ShouldEqual, 2)
+ So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
+ So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
+ })
- Convey("querying for last journaling should return no journal entries", func() {
- query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId}
- err := GetLatestNotification(context.Background(), query)
- So(err, ShouldBeNil)
- So(len(query.Result), ShouldEqual, 0)
- })
- })
+ Convey("Update existing state to completed should update database. regardless of version", func() {
+ s := *query.Result
+ unknownVersion := int64(1000)
+ cmd := models.SetAlertNotificationStateToCompleteCommand{
+ Id: s.Id,
+ Version: unknownVersion,
+ }
+ err := SetAlertNotificationStateToCompleteCommand(context.Background(), &cmd)
+ So(err, ShouldBeNil)
+
+ query3 := &models.GetOrCreateNotificationStateQuery{AlertId: alertID, OrgId: orgID, NotifierId: notifierID}
+ err = GetOrCreateAlertNotificationState(context.Background(), query3)
+ So(err, ShouldBeNil)
+ So(query3.Result.Version, ShouldEqual, unknownVersion+1)
+ So(query3.Result.State, ShouldEqual, models.AlertNotificationStateCompleted)
+ So(query3.Result.UpdatedAt, ShouldEqual, now.Unix())
})
})
+
+ Convey("Update existing state to pending with incorrect version should return version mismatch error", func() {
+ s := *query.Result
+ s.Version = 1000
+ cmd := models.SetAlertNotificationStateToPendingCommand{
+ Id: s.NotifierId,
+ Version: s.Version,
+ AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
+ }
+ err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
+ So(err, ShouldEqual, models.ErrAlertNotificationStateVersionConflict)
+ })
+
+ Convey("Updating existing state to pending with incorrect version since alert rule state update version is higher", func() {
+ s := *query.Result
+ cmd := models.SetAlertNotificationStateToPendingCommand{
+ Id: s.Id,
+ Version: s.Version,
+ AlertRuleStateUpdatedVersion: 1000,
+ }
+ err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
+ So(err, ShouldBeNil)
+
+ So(cmd.ResultVersion, ShouldEqual, 1)
+ })
+
+ Convey("different version and same alert state change version should return error", func() {
+ s := *query.Result
+ s.Version = 1000
+ cmd := models.SetAlertNotificationStateToPendingCommand{
+ Id: s.Id,
+ Version: s.Version,
+ AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
+ }
+ err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
+ So(err, ShouldNotBeNil)
+ })
+ })
+
+ Reset(func() {
+ timeNow = oldTimeNow
})
})
Convey("Alert notifications should be empty", func() {
- cmd := &m.GetAlertNotificationsQuery{
+ cmd := &models.GetAlertNotificationsQuery{
OrgId: 2,
Name: "email",
}
@@ -76,7 +151,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Cannot save alert notifier with send reminder = true", func() {
- cmd := &m.CreateAlertNotificationCommand{
+ cmd := &models.CreateAlertNotificationCommand{
Name: "ops",
Type: "email",
OrgId: 1,
@@ -86,7 +161,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
Convey("and missing frequency", func() {
err := CreateAlertNotificationCommand(cmd)
- So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
+ So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
})
Convey("invalid frequency", func() {
@@ -98,7 +173,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Cannot update alert notifier with send reminder = false", func() {
- cmd := &m.CreateAlertNotificationCommand{
+ cmd := &models.CreateAlertNotificationCommand{
Name: "ops update",
Type: "email",
OrgId: 1,
@@ -109,14 +184,14 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
err := CreateAlertNotificationCommand(cmd)
So(err, ShouldBeNil)
- updateCmd := &m.UpdateAlertNotificationCommand{
+ updateCmd := &models.UpdateAlertNotificationCommand{
Id: cmd.Result.Id,
SendReminder: true,
}
Convey("and missing frequency", func() {
err := UpdateAlertNotification(updateCmd)
- So(err, ShouldEqual, m.ErrNotificationFrequencyNotFound)
+ So(err, ShouldEqual, models.ErrNotificationFrequencyNotFound)
})
Convey("invalid frequency", func() {
@@ -129,7 +204,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Can save Alert Notification", func() {
- cmd := &m.CreateAlertNotificationCommand{
+ cmd := &models.CreateAlertNotificationCommand{
Name: "ops",
Type: "email",
OrgId: 1,
@@ -144,6 +219,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(cmd.Result.OrgId, ShouldNotEqual, 0)
So(cmd.Result.Type, ShouldEqual, "email")
So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
+ So(cmd.Result.DisableResolveMessage, ShouldBeFalse)
Convey("Cannot save Alert Notification with the same name", func() {
err = CreateAlertNotificationCommand(cmd)
@@ -151,23 +227,25 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Can update alert notification", func() {
- newCmd := &m.UpdateAlertNotificationCommand{
- Name: "NewName",
- Type: "webhook",
- OrgId: cmd.Result.OrgId,
- SendReminder: true,
- Frequency: "60s",
- Settings: simplejson.New(),
- Id: cmd.Result.Id,
+ newCmd := &models.UpdateAlertNotificationCommand{
+ Name: "NewName",
+ Type: "webhook",
+ OrgId: cmd.Result.OrgId,
+ SendReminder: true,
+ DisableResolveMessage: true,
+ Frequency: "60s",
+ Settings: simplejson.New(),
+ Id: cmd.Result.Id,
}
err := UpdateAlertNotification(newCmd)
So(err, ShouldBeNil)
So(newCmd.Result.Name, ShouldEqual, "NewName")
So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second)
+ So(newCmd.Result.DisableResolveMessage, ShouldBeTrue)
})
Convey("Can update alert notification to disable sending of reminders", func() {
- newCmd := &m.UpdateAlertNotificationCommand{
+ newCmd := &models.UpdateAlertNotificationCommand{
Name: "NewName",
Type: "webhook",
OrgId: cmd.Result.OrgId,
@@ -182,12 +260,12 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("Can search using an array of ids", func() {
- cmd1 := m.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
- cmd2 := m.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
- cmd3 := m.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
- cmd4 := m.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+ cmd1 := models.CreateAlertNotificationCommand{Name: "nagios", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+ cmd2 := models.CreateAlertNotificationCommand{Name: "slack", Type: "webhook", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+ cmd3 := models.CreateAlertNotificationCommand{Name: "ops2", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+ cmd4 := models.CreateAlertNotificationCommand{IsDefault: true, Name: "default", Type: "email", OrgId: 1, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
- otherOrg := m.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
+ otherOrg := models.CreateAlertNotificationCommand{Name: "default", Type: "email", OrgId: 2, SendReminder: true, Frequency: "10s", Settings: simplejson.New()}
So(CreateAlertNotificationCommand(&cmd1), ShouldBeNil)
So(CreateAlertNotificationCommand(&cmd2), ShouldBeNil)
@@ -196,7 +274,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil)
Convey("search", func() {
- query := &m.GetAlertNotificationsToSendQuery{
+ query := &models.GetAlertNotificationsToSendQuery{
Ids: []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231},
OrgId: 1,
}
@@ -207,7 +285,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
})
Convey("all", func() {
- query := &m.GetAllAlertNotificationsQuery{
+ query := &models.GetAllAlertNotificationsQuery{
OrgId: 1,
}
diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go
index e43279208e7..1b853d17b5f 100644
--- a/pkg/services/sqlstore/dashboard.go
+++ b/pkg/services/sqlstore/dashboard.go
@@ -320,13 +320,18 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
"DELETE FROM dashboard WHERE id = ?",
"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
"DELETE FROM dashboard_version WHERE dashboard_id = ?",
- "DELETE FROM dashboard WHERE folder_id = ?",
"DELETE FROM annotation WHERE dashboard_id = ?",
"DELETE FROM dashboard_provisioning WHERE dashboard_id = ?",
}
+ if dashboard.IsFolder {
+ deletes = append(deletes, "DELETE FROM dashboard_provisioning WHERE dashboard_id in (select id from dashboard where folder_id = ?)")
+ deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ?")
+ }
+
for _, sql := range deletes {
_, err := sess.Exec(sql, dashboard.Id)
+
if err != nil {
return err
}
diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/sqlstore/dashboard_provisioning_test.go
index 7ef45df3152..1b7a3976727 100644
--- a/pkg/services/sqlstore/dashboard_provisioning_test.go
+++ b/pkg/services/sqlstore/dashboard_provisioning_test.go
@@ -13,17 +13,30 @@ func TestDashboardProvisioningTest(t *testing.T) {
Convey("Testing Dashboard provisioning", t, func() {
InitTestDB(t)
- saveDashboardCmd := &models.SaveDashboardCommand{
+ folderCmd := &models.SaveDashboardCommand{
OrgId: 1,
FolderId: 0,
- IsFolder: false,
+ IsFolder: true,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": "test dashboard",
}),
}
- Convey("Saving dashboards with extras", func() {
+ err := SaveDashboard(folderCmd)
+ So(err, ShouldBeNil)
+
+ saveDashboardCmd := &models.SaveDashboardCommand{
+ OrgId: 1,
+ IsFolder: false,
+ FolderId: folderCmd.Result.Id,
+ Dashboard: simplejson.NewFromAny(map[string]interface{}{
+ "id": nil,
+ "title": "test dashboard",
+ }),
+ }
+
+ Convey("Saving dashboards with provisioning meta data", func() {
now := time.Now()
cmd := &models.SaveProvisionedDashboardCommand{
@@ -67,6 +80,21 @@ func TestDashboardProvisioningTest(t *testing.T) {
So(err, ShouldBeNil)
So(query.Result, ShouldBeFalse)
})
+
+ Convey("Deleteing folder should delete provision meta data", func() {
+ deleteCmd := &models.DeleteDashboardCommand{
+ Id: folderCmd.Result.Id,
+ OrgId: 1,
+ }
+
+ So(DeleteDashboard(deleteCmd), ShouldBeNil)
+
+ query := &models.IsDashboardProvisionedQuery{DashboardId: cmd.Result.Id}
+
+ err = GetProvisionedDataByDashboardId(query)
+ So(err, ShouldBeNil)
+ So(query.Result, ShouldBeFalse)
+ })
})
})
}
diff --git a/pkg/services/sqlstore/dashboard_service_integration_test.go b/pkg/services/sqlstore/dashboard_service_integration_test.go
index a9658f7ab76..a4e76aca340 100644
--- a/pkg/services/sqlstore/dashboard_service_integration_test.go
+++ b/pkg/services/sqlstore/dashboard_service_integration_test.go
@@ -932,29 +932,6 @@ func TestIntegratedDashboardService(t *testing.T) {
})
}
-type scenarioContext struct {
- dashboardGuardianMock *guardian.FakeDashboardGuardian
-}
-
-type scenarioFunc func(c *scenarioContext)
-
-func dashboardGuardianScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
- Convey(desc, func() {
- origNewDashboardGuardian := guardian.New
- guardian.MockDashboardGuardian(mock)
-
- sc := &scenarioContext{
- dashboardGuardianMock: mock,
- }
-
- defer func() {
- guardian.New = origNewDashboardGuardian
- }()
-
- fn(sc)
- })
-}
-
type dashboardPermissionScenarioContext struct {
dashboardGuardianMock *guardian.FakeDashboardGuardian
}
diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go
index 00d520bcfc6..7f70e5c25fc 100644
--- a/pkg/services/sqlstore/datasource.go
+++ b/pkg/services/sqlstore/datasource.go
@@ -27,6 +27,7 @@ func GetDataSourceById(query *m.GetDataSourceByIdQuery) error {
datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id}
has, err := x.Get(&datasource)
+
if err != nil {
return err
}
diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go
index e27e64c6124..198a47b50ff 100644
--- a/pkg/services/sqlstore/migrations/alert_mig.go
+++ b/pkg/services/sqlstore/migrations/alert_mig.go
@@ -71,6 +71,9 @@ func addAlertMigrations(mg *Migrator) {
mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{
Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0",
}))
+ mg.AddMigration("Add column disable_resolve_message", NewAddColumnMigration(alert_notification, &Column{
+ Name: "disable_resolve_message", Type: DB_Bool, Nullable: false, Default: "0",
+ }))
mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0]))
@@ -107,4 +110,27 @@ func addAlertMigrations(mg *Migrator) {
mg.AddMigration("create notification_journal table v1", NewAddTableMigration(notification_journal))
mg.AddMigration("add index notification_journal org_id & alert_id & notifier_id", NewAddIndexMigration(notification_journal, notification_journal.Indices[0]))
+
+ mg.AddMigration("drop alert_notification_journal", NewDropTableMigration("alert_notification_journal"))
+
+ alert_notification_state := Table{
+ Name: "alert_notification_state",
+ Columns: []*Column{
+ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+ {Name: "org_id", Type: DB_BigInt, Nullable: false},
+ {Name: "alert_id", Type: DB_BigInt, Nullable: false},
+ {Name: "notifier_id", Type: DB_BigInt, Nullable: false},
+ {Name: "state", Type: DB_NVarchar, Length: 50, Nullable: false},
+ {Name: "version", Type: DB_BigInt, Nullable: false},
+ {Name: "updated_at", Type: DB_BigInt, Nullable: false},
+ {Name: "alert_rule_state_updated_version", Type: DB_BigInt, Nullable: false},
+ },
+ Indices: []*Index{
+ {Cols: []string{"org_id", "alert_id", "notifier_id"}, Type: UniqueIndex},
+ },
+ }
+
+ mg.AddMigration("create alert_notification_state table v1", NewAddTableMigration(alert_notification_state))
+ mg.AddMigration("add index alert_notification_state org_id & alert_id & notifier_id",
+ NewAddIndexMigration(alert_notification_state, alert_notification_state.Indices[0]))
}
diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go
index 427d102b280..506a01c3ed8 100644
--- a/pkg/services/sqlstore/migrator/dialect.go
+++ b/pkg/services/sqlstore/migrator/dialect.go
@@ -44,6 +44,8 @@ type Dialect interface {
CleanDB() error
NoOpSql() string
+
+ IsUniqueConstraintViolation(err error) bool
}
func NewDialect(engine *xorm.Engine) Dialect {
diff --git a/pkg/services/sqlstore/migrator/mysql_dialect.go b/pkg/services/sqlstore/migrator/mysql_dialect.go
index 1ed16871c15..7daa4597430 100644
--- a/pkg/services/sqlstore/migrator/mysql_dialect.go
+++ b/pkg/services/sqlstore/migrator/mysql_dialect.go
@@ -5,6 +5,8 @@ import (
"strconv"
"strings"
+ "github.com/VividCortex/mysqlerr"
+ "github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
)
@@ -125,3 +127,13 @@ func (db *Mysql) CleanDB() error {
return nil
}
+
+func (db *Mysql) IsUniqueConstraintViolation(err error) bool {
+ if driverErr, ok := err.(*mysql.MySQLError); ok {
+ if driverErr.Number == mysqlerr.ER_DUP_ENTRY {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go
index eae9ad3ca3f..ab8812a1e26 100644
--- a/pkg/services/sqlstore/migrator/postgres_dialect.go
+++ b/pkg/services/sqlstore/migrator/postgres_dialect.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/go-xorm/xorm"
+ "github.com/lib/pq"
)
type Postgres struct {
@@ -136,3 +137,13 @@ func (db *Postgres) CleanDB() error {
return nil
}
+
+func (db *Postgres) IsUniqueConstraintViolation(err error) bool {
+ if driverErr, ok := err.(*pq.Error); ok {
+ if driverErr.Code == "23505" {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go
index 01082b95c88..446e3fcef12 100644
--- a/pkg/services/sqlstore/migrator/sqlite_dialect.go
+++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go
@@ -4,6 +4,7 @@ import (
"fmt"
"github.com/go-xorm/xorm"
+ sqlite3 "github.com/mattn/go-sqlite3"
)
type Sqlite3 struct {
@@ -82,3 +83,13 @@ func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {
func (db *Sqlite3) CleanDB() error {
return nil
}
+
+func (db *Sqlite3) IsUniqueConstraintViolation(err error) bool {
+ if driverErr, ok := err.(sqlite3.Error); ok {
+ if driverErr.ExtendedCode == sqlite3.ErrConstraintUnique {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go
index af8500707d5..c02686c24ba 100644
--- a/pkg/services/sqlstore/org_test.go
+++ b/pkg/services/sqlstore/org_test.go
@@ -182,6 +182,21 @@ func TestAccountDataAccess(t *testing.T) {
})
})
+ Convey("Removing user from org should delete user completely if in no other org", func() {
+ // make sure ac2 has no org
+ err := DeleteOrg(&m.DeleteOrgCommand{Id: ac2.OrgId})
+ So(err, ShouldBeNil)
+
+ // remove frome ac2 from ac1 org
+ remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, ShouldDeleteOrphanedUser: true}
+ err = RemoveOrgUser(&remCmd)
+ So(err, ShouldBeNil)
+ So(remCmd.UserWasDeleted, ShouldBeTrue)
+
+ err = GetSignedInUser(&m.GetSignedInUserQuery{UserId: ac2.Id})
+ So(err, ShouldEqual, m.ErrUserNotFound)
+ })
+
Convey("Cannot delete last admin org user", func() {
cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id}
err := RemoveOrgUser(&cmd)
diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go
index 14981cfde64..abbc320020e 100644
--- a/pkg/services/sqlstore/org_users.go
+++ b/pkg/services/sqlstore/org_users.go
@@ -157,6 +157,12 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
}
}
+ // validate that after delete there is at least one user with admin role in org
+ if err := validateOneAdminLeftInOrg(cmd.OrgId, sess); err != nil {
+ return err
+ }
+
+ // check user other orgs and update user current org
var userOrgs []*m.UserOrgDTO
sess.Table("org_user")
sess.Join("INNER", "org", "org_user.org_id=org.id")
@@ -168,22 +174,31 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
return err
}
- hasCurrentOrgSet := false
- for _, userOrg := range userOrgs {
- if user.OrgId == userOrg.OrgId {
- hasCurrentOrgSet = true
- break
+ if len(userOrgs) > 0 {
+ hasCurrentOrgSet := false
+ for _, userOrg := range userOrgs {
+ if user.OrgId == userOrg.OrgId {
+ hasCurrentOrgSet = true
+ break
+ }
}
- }
- if !hasCurrentOrgSet && len(userOrgs) > 0 {
- err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId)
- if err != nil {
+ if !hasCurrentOrgSet {
+ err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId)
+ if err != nil {
+ return err
+ }
+ }
+ } else if cmd.ShouldDeleteOrphanedUser {
+ // no other orgs, delete the full user
+ if err := deleteUserInTransaction(sess, &m.DeleteUserCommand{UserId: user.Id}); err != nil {
return err
}
+
+ cmd.UserWasDeleted = true
}
- return validateOneAdminLeftInOrg(cmd.OrgId, sess)
+ return nil
})
}
diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go
index 5477bc7b2d1..f904b44c3c8 100644
--- a/pkg/services/sqlstore/sqlstore.go
+++ b/pkg/services/sqlstore/sqlstore.go
@@ -53,6 +53,7 @@ type SqlStore struct {
dbCfg DatabaseConfig
engine *xorm.Engine
log log.Logger
+ Dialect migrator.Dialect
skipEnsureAdmin bool
}
@@ -125,10 +126,12 @@ func (ss *SqlStore) Init() error {
}
ss.engine = engine
+ ss.Dialect = migrator.NewDialect(ss.engine)
// temporarily still set global var
x = engine
- dialect = migrator.NewDialect(x)
+ dialect = ss.Dialect
+
migrator := migrator.NewMigrator(x)
migrations.AddMigrations(migrator)
@@ -233,7 +236,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
case migrator.SQLITE:
// special case for tests
if !filepath.IsAbs(ss.dbCfg.Path) {
- ss.dbCfg.Path = filepath.Join(setting.DataPath, ss.dbCfg.Path)
+ ss.dbCfg.Path = filepath.Join(ss.Cfg.DataPath, ss.dbCfg.Path)
}
os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
cnnstr = "file:" + ss.dbCfg.Path + "?cache=shared&mode=rwc"
@@ -347,7 +350,11 @@ func InitTestDB(t *testing.T) *SqlStore {
t.Fatalf("Failed to init test database: %v", err)
}
- dialect = migrator.NewDialect(engine)
+ sqlstore.Dialect = migrator.NewDialect(engine)
+
+ // temp global var until we get rid of global vars
+ dialect = sqlstore.Dialect
+
if err := dialect.CleanDB(); err != nil {
t.Fatalf("Failed to clean test db %v", err)
}
diff --git a/pkg/services/sqlstore/transactions_test.go b/pkg/services/sqlstore/transactions_test.go
index 41dedde5db4..041359cf1d3 100644
--- a/pkg/services/sqlstore/transactions_test.go
+++ b/pkg/services/sqlstore/transactions_test.go
@@ -10,10 +10,6 @@ import (
. "github.com/smartystreets/goconvey/convey"
)
-type testQuery struct {
- result bool
-}
-
var ProvokedError = errors.New("testing error.")
func TestTransaction(t *testing.T) {
diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go
index 848a11d81ab..72d5654a777 100644
--- a/pkg/services/sqlstore/user.go
+++ b/pkg/services/sqlstore/user.go
@@ -445,27 +445,31 @@ func SearchUsers(query *m.SearchUsersQuery) error {
func DeleteUser(cmd *m.DeleteUserCommand) error {
return inTransaction(func(sess *DBSession) error {
- deletes := []string{
- "DELETE FROM star WHERE user_id = ?",
- "DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
- "DELETE FROM org_user WHERE user_id = ?",
- "DELETE FROM dashboard_acl WHERE user_id = ?",
- "DELETE FROM preferences WHERE user_id = ?",
- "DELETE FROM team_member WHERE user_id = ?",
- "DELETE FROM user_auth WHERE user_id = ?",
- }
-
- for _, sql := range deletes {
- _, err := sess.Exec(sql, cmd.UserId)
- if err != nil {
- return err
- }
- }
-
- return nil
+ return deleteUserInTransaction(sess, cmd)
})
}
+func deleteUserInTransaction(sess *DBSession, cmd *m.DeleteUserCommand) error {
+ deletes := []string{
+ "DELETE FROM star WHERE user_id = ?",
+ "DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
+ "DELETE FROM org_user WHERE user_id = ?",
+ "DELETE FROM dashboard_acl WHERE user_id = ?",
+ "DELETE FROM preferences WHERE user_id = ?",
+ "DELETE FROM team_member WHERE user_id = ?",
+ "DELETE FROM user_auth WHERE user_id = ?",
+ }
+
+ for _, sql := range deletes {
+ _, err := sess.Exec(sql, cmd.UserId)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error {
return inTransaction(func(sess *DBSession) error {
user := m.User{}
diff --git a/pkg/services/sqlstore/user_auth_test.go b/pkg/services/sqlstore/user_auth_test.go
index 5ad93dc7a3b..a0dd714fe6f 100644
--- a/pkg/services/sqlstore/user_auth_test.go
+++ b/pkg/services/sqlstore/user_auth_test.go
@@ -16,7 +16,6 @@ func TestUserAuth(t *testing.T) {
Convey("Given 5 users", t, func() {
var err error
var cmd *m.CreateUserCommand
- users := []m.User{}
for i := 0; i < 5; i++ {
cmd = &m.CreateUserCommand{
Email: fmt.Sprint("user", i, "@test.com"),
@@ -25,7 +24,6 @@ func TestUserAuth(t *testing.T) {
}
err = CreateUser(context.Background(), cmd)
So(err, ShouldBeNil)
- users = append(users, cmd.Result)
}
Reset(func() {
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 27df73a9eed..afae642f5b3 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -13,15 +13,12 @@ import (
"regexp"
"runtime"
"strings"
-
- "gopkg.in/ini.v1"
-
- "github.com/go-macaron/session"
-
"time"
+ "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/util"
+ "gopkg.in/ini.v1"
)
type Scheme string
@@ -34,9 +31,15 @@ const (
)
const (
- DEV string = "development"
- PROD string = "production"
- TEST string = "test"
+ DEV = "development"
+ PROD = "production"
+ TEST = "test"
+ APP_NAME = "Grafana"
+ APP_NAME_ENTERPRISE = "Grafana Enterprise"
+)
+
+var (
+ ERR_TEMPLATE_NAME = "error"
)
var (
@@ -49,19 +52,17 @@ var (
// build
BuildVersion string
BuildCommit string
+ BuildBranch string
BuildStamp int64
IsEnterprise bool
ApplicationName string
// Paths
- LogsPath string
HomePath string
- DataPath string
PluginsPath string
CustomInitPath = "conf/custom.ini"
// Log settings.
- LogModes []string
LogConfigs []util.DynMap
// Http server options
@@ -187,11 +188,18 @@ var (
ImageUploadProvider string
)
+// TODO move all global vars to this struct
type Cfg struct {
Raw *ini.File
+ // HTTP Server Settings
+ AppUrl string
+ AppSubUrl string
+
// Paths
ProvisioningPath string
+ DataPath string
+ LogsPath string
// SMTP email settings
Smtp SmtpSettings
@@ -205,10 +213,10 @@ type Cfg struct {
RendererLimitAlerting int
DisableBruteForceLoginProtection bool
-
- TempDataLifetime time.Duration
-
- MetricsEndpointEnabled bool
+ TempDataLifetime time.Duration
+ MetricsEndpointEnabled bool
+ EnableAlphaPanels bool
+ EnterpriseLicensePath string
}
type CommandLineArgs struct {
@@ -411,7 +419,7 @@ func loadSpecifedConfigFile(configFile string, masterFile *ini.File) error {
return nil
}
-func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
+func (cfg *Cfg) loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
var err error
// load config defaults
@@ -442,7 +450,7 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
// load specified config file
err = loadSpecifedConfigFile(args.Config, parsedFile)
if err != nil {
- initLogging(parsedFile)
+ cfg.initLogging(parsedFile)
log.Fatal(3, err.Error())
}
@@ -459,8 +467,8 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
evalConfigValues(parsedFile)
// update data path and logging config
- DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath)
- initLogging(parsedFile)
+ cfg.DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath)
+ cfg.initLogging(parsedFile)
return parsedFile, err
}
@@ -517,7 +525,7 @@ func NewCfg() *Cfg {
func (cfg *Cfg) Load(args *CommandLineArgs) error {
setHomePath(args)
- iniFile, err := loadConfiguration(args)
+ iniFile, err := cfg.loadConfiguration(args)
if err != nil {
return err
}
@@ -527,9 +535,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
// Temporary keep global, to make refactor in steps
Raw = cfg.Raw
- ApplicationName = "Grafana"
+ ApplicationName = APP_NAME
if IsEnterprise {
- ApplicationName += " Enterprise"
+ ApplicationName = APP_NAME_ENTERPRISE
}
Env = iniFile.Section("").Key("app_mode").MustString("development")
@@ -538,6 +546,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
server := iniFile.Section("server")
AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
+ cfg.AppUrl = AppUrl
+ cfg.AppSubUrl = AppSubUrl
Protocol = HTTP
if server.Key("protocol").MustString("http") == "https" {
@@ -662,7 +672,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
log.Fatal(4, "Invalid callback_url(%s): %s", cfg.RendererCallbackUrl, err)
}
}
- cfg.ImagesDir = filepath.Join(DataPath, "png")
+ cfg.ImagesDir = filepath.Join(cfg.DataPath, "png")
cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)
cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true)
@@ -688,6 +698,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
explore := iniFile.Section("explore")
ExploreEnabled = explore.Key("enabled").MustBool(false)
+ panels := iniFile.Section("panels")
+ cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
+
cfg.readSessionConfig()
cfg.readSmtpSettings()
cfg.readQuotaSettings()
@@ -704,6 +717,10 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
imageUploadingSection := iniFile.Section("external_image_storage")
ImageUploadProvider = imageUploadingSection.Key("provider").MustString("")
+
+ enterprise := iniFile.Section("enterprise")
+ cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt"))
+
return nil
}
@@ -720,7 +737,7 @@ func (cfg *Cfg) readSessionConfig() {
SessionOptions.IDLength = 16
if SessionOptions.Provider == "file" {
- SessionOptions.ProviderConfig = makeAbsolute(SessionOptions.ProviderConfig, DataPath)
+ SessionOptions.ProviderConfig = makeAbsolute(SessionOptions.ProviderConfig, cfg.DataPath)
os.MkdirAll(path.Dir(SessionOptions.ProviderConfig), os.ModePerm)
}
@@ -731,15 +748,15 @@ func (cfg *Cfg) readSessionConfig() {
SessionConnMaxLifetime = cfg.Raw.Section("session").Key("conn_max_lifetime").MustInt64(14400)
}
-func initLogging(file *ini.File) {
+func (cfg *Cfg) initLogging(file *ini.File) {
// split on comma
- LogModes = strings.Split(file.Section("log").Key("mode").MustString("console"), ",")
+ logModes := strings.Split(file.Section("log").Key("mode").MustString("console"), ",")
// also try space
- if len(LogModes) == 1 {
- LogModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ")
+ if len(logModes) == 1 {
+ logModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ")
}
- LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath)
- log.ReadLoggingConfig(LogModes, LogsPath, file)
+ cfg.LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath)
+ log.ReadLoggingConfig(logModes, cfg.LogsPath, file)
}
func (cfg *Cfg) LogConfigSources() {
@@ -763,8 +780,8 @@ func (cfg *Cfg) LogConfigSources() {
}
logger.Info("Path Home", "path", HomePath)
- logger.Info("Path Data", "path", DataPath)
- logger.Info("Path Logs", "path", LogsPath)
+ logger.Info("Path Data", "path", cfg.DataPath)
+ logger.Info("Path Logs", "path", cfg.LogsPath)
logger.Info("Path Plugins", "path", PluginsPath)
logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
logger.Info("App mode " + Env)
diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go
index 6524073e4da..72dbe2378c7 100644
--- a/pkg/setting/setting_test.go
+++ b/pkg/setting/setting_test.go
@@ -30,8 +30,8 @@ func TestLoadingSettings(t *testing.T) {
cfg.Load(&CommandLineArgs{HomePath: "../../"})
So(AdminUser, ShouldEqual, "superduper")
- So(DataPath, ShouldEqual, filepath.Join(HomePath, "data"))
- So(LogsPath, ShouldEqual, filepath.Join(DataPath, "log"))
+ So(cfg.DataPath, ShouldEqual, filepath.Join(HomePath, "data"))
+ So(cfg.LogsPath, ShouldEqual, filepath.Join(cfg.DataPath, "log"))
})
Convey("Should replace password when defined in environment", func() {
@@ -76,8 +76,8 @@ func TestLoadingSettings(t *testing.T) {
HomePath: "../../",
Args: []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`},
})
- So(DataPath, ShouldEqual, `c:\tmp\data`)
- So(LogsPath, ShouldEqual, `c:\tmp\logs`)
+ So(cfg.DataPath, ShouldEqual, `c:\tmp\data`)
+ So(cfg.LogsPath, ShouldEqual, `c:\tmp\logs`)
} else {
cfg := NewCfg()
cfg.Load(&CommandLineArgs{
@@ -85,8 +85,8 @@ func TestLoadingSettings(t *testing.T) {
Args: []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
})
- So(DataPath, ShouldEqual, "/tmp/data")
- So(LogsPath, ShouldEqual, "/tmp/logs")
+ So(cfg.DataPath, ShouldEqual, "/tmp/data")
+ So(cfg.LogsPath, ShouldEqual, "/tmp/logs")
}
})
@@ -112,7 +112,7 @@ func TestLoadingSettings(t *testing.T) {
Args: []string{`cfg:default.paths.data=c:\tmp\data`},
})
- So(DataPath, ShouldEqual, `c:\tmp\override`)
+ So(cfg.DataPath, ShouldEqual, `c:\tmp\override`)
} else {
cfg := NewCfg()
cfg.Load(&CommandLineArgs{
@@ -121,7 +121,7 @@ func TestLoadingSettings(t *testing.T) {
Args: []string{"cfg:default.paths.data=/tmp/data"},
})
- So(DataPath, ShouldEqual, "/tmp/override")
+ So(cfg.DataPath, ShouldEqual, "/tmp/override")
}
})
@@ -134,7 +134,7 @@ func TestLoadingSettings(t *testing.T) {
Args: []string{`cfg:paths.data=c:\tmp\data`},
})
- So(DataPath, ShouldEqual, `c:\tmp\data`)
+ So(cfg.DataPath, ShouldEqual, `c:\tmp\data`)
} else {
cfg := NewCfg()
cfg.Load(&CommandLineArgs{
@@ -143,7 +143,7 @@ func TestLoadingSettings(t *testing.T) {
Args: []string{"cfg:paths.data=/tmp/data"},
})
- So(DataPath, ShouldEqual, "/tmp/data")
+ So(cfg.DataPath, ShouldEqual, "/tmp/data")
}
})
@@ -156,7 +156,7 @@ func TestLoadingSettings(t *testing.T) {
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
})
- So(DataPath, ShouldEqual, `c:\tmp\env_override`)
+ So(cfg.DataPath, ShouldEqual, `c:\tmp\env_override`)
} else {
os.Setenv("GF_DATA_PATH", "/tmp/env_override")
cfg := NewCfg()
@@ -165,7 +165,7 @@ func TestLoadingSettings(t *testing.T) {
Args: []string{"cfg:paths.data=${GF_DATA_PATH}"},
})
- So(DataPath, ShouldEqual, "/tmp/env_override")
+ So(cfg.DataPath, ShouldEqual, "/tmp/env_override")
}
})
diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go
index be14c6f96ec..437457df52a 100644
--- a/pkg/tsdb/cloudwatch/cloudwatch.go
+++ b/pkg/tsdb/cloudwatch/cloudwatch.go
@@ -86,9 +86,10 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
}
func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) {
- result := &tsdb.Response{
+ results := &tsdb.Response{
Results: make(map[string]*tsdb.QueryResult),
}
+ resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries))
eg, ectx := errgroup.WithContext(ctx)
@@ -102,10 +103,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
RefId := queryContext.Queries[i].RefId
query, err := parseQuery(queryContext.Queries[i].Model)
if err != nil {
- result.Results[RefId] = &tsdb.QueryResult{
+ results.Results[RefId] = &tsdb.QueryResult{
Error: err,
}
- return result, nil
+ return results, nil
}
query.RefId = RefId
@@ -118,10 +119,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
}
if query.Id == "" && query.Expression != "" {
- result.Results[query.RefId] = &tsdb.QueryResult{
+ results.Results[query.RefId] = &tsdb.QueryResult{
Error: fmt.Errorf("Invalid query: id should be set if using expression"),
}
- return result, nil
+ return results, nil
}
eg.Go(func() error {
@@ -129,10 +130,14 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
return err
}
- result.Results[queryRes.RefId] = queryRes
if err != nil {
- result.Results[queryRes.RefId].Error = err
+ resultChan <- &tsdb.QueryResult{
+ RefId: query.RefId,
+ Error: err,
+ }
+ return nil
}
+ resultChan <- queryRes
return nil
})
}
@@ -146,10 +151,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
return err
}
for _, queryRes := range queryResponses {
- result.Results[queryRes.RefId] = queryRes
if err != nil {
- result.Results[queryRes.RefId].Error = err
+ queryRes.Error = err
}
+ resultChan <- queryRes
}
return nil
})
@@ -159,8 +164,12 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
if err := eg.Wait(); err != nil {
return nil, err
}
+ close(resultChan)
+ for result := range resultChan {
+ results.Results[result.RefId] = result
+ }
- return result, nil
+ return results, nil
}
func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {
@@ -269,7 +278,7 @@ func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, regi
for _, query := range queries {
// 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) {
- return nil, errors.New("too long query period")
+ return queryResponses, errors.New("too long query period")
}
mdq := &cloudwatch.MetricDataQuery{
@@ -362,6 +371,7 @@ func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, regi
}
queryRes.Series = append(queryRes.Series, &series)
+ queryRes.Meta = simplejson.New()
queryResponses = append(queryResponses, queryRes)
}
@@ -565,6 +575,12 @@ func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatch
}
queryRes.Series = append(queryRes.Series, &series)
+ queryRes.Meta = simplejson.New()
+ if len(resp.Datapoints) > 0 && resp.Datapoints[0].Unit != nil {
+ if unit, ok := cloudwatchUnitMappings[*resp.Datapoints[0].Unit]; ok {
+ queryRes.Meta.Set("unit", unit)
+ }
+ }
}
return queryRes, nil
diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go
index 719edba08ba..32b8c910f2b 100644
--- a/pkg/tsdb/cloudwatch/cloudwatch_test.go
+++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go
@@ -71,6 +71,7 @@ func TestCloudWatch(t *testing.T) {
"p50.00": aws.Float64(30.0),
"p90.00": aws.Float64(40.0),
},
+ Unit: aws.String("Seconds"),
},
},
}
@@ -103,6 +104,7 @@ func TestCloudWatch(t *testing.T) {
So(queryRes.Series[1].Points[0][0].String(), ShouldEqual, null.FloatFrom(20.0).String())
So(queryRes.Series[2].Points[0][0].String(), ShouldEqual, null.FloatFrom(30.0).String())
So(queryRes.Series[3].Points[0][0].String(), ShouldEqual, null.FloatFrom(40.0).String())
+ So(queryRes.Meta.Get("unit").MustString(), ShouldEqual, "s")
})
Convey("terminate gap of data points", func() {
@@ -118,6 +120,7 @@ func TestCloudWatch(t *testing.T) {
"p50.00": aws.Float64(30.0),
"p90.00": aws.Float64(40.0),
},
+ Unit: aws.String("Seconds"),
},
{
Timestamp: aws.Time(timestamp.Add(60 * time.Second)),
@@ -127,6 +130,7 @@ func TestCloudWatch(t *testing.T) {
"p50.00": aws.Float64(40.0),
"p90.00": aws.Float64(50.0),
},
+ Unit: aws.String("Seconds"),
},
{
Timestamp: aws.Time(timestamp.Add(180 * time.Second)),
@@ -136,6 +140,7 @@ func TestCloudWatch(t *testing.T) {
"p50.00": aws.Float64(50.0),
"p90.00": aws.Float64(60.0),
},
+ Unit: aws.String("Seconds"),
},
},
}
diff --git a/pkg/tsdb/cloudwatch/constants.go b/pkg/tsdb/cloudwatch/constants.go
new file mode 100644
index 00000000000..23817b1d133
--- /dev/null
+++ b/pkg/tsdb/cloudwatch/constants.go
@@ -0,0 +1,30 @@
+package cloudwatch
+
+var cloudwatchUnitMappings = map[string]string{
+ "Seconds": "s",
+ "Microseconds": "µs",
+ "Milliseconds": "ms",
+ "Bytes": "bytes",
+ "Kilobytes": "kbytes",
+ "Megabytes": "mbytes",
+ "Gigabytes": "gbytes",
+ //"Terabytes": "",
+ "Bits": "bits",
+ //"Kilobits": "",
+ //"Megabits": "",
+ //"Gigabits": "",
+ //"Terabits": "",
+ "Percent": "percent",
+ //"Count": "",
+ "Bytes/Second": "Bps",
+ "Kilobytes/Second": "KBs",
+ "Megabytes/Second": "MBs",
+ "Gigabytes/Second": "GBs",
+ //"Terabytes/Second": "",
+ "Bits/Second": "bps",
+ "Kilobits/Second": "Kbits",
+ "Megabits/Second": "Mbits",
+ "Gigabits/Second": "Gbits",
+ //"Terabits/Second": "",
+ //"Count/Second": "",
+}
diff --git a/pkg/tsdb/cloudwatch/credentials.go b/pkg/tsdb/cloudwatch/credentials.go
index 8b32c76daa3..165f8fdbe97 100644
--- a/pkg/tsdb/cloudwatch/credentials.go
+++ b/pkg/tsdb/cloudwatch/credentials.go
@@ -42,8 +42,7 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
accessKeyId := ""
secretAccessKey := ""
sessionToken := ""
- var expiration *time.Time
- expiration = nil
+ var expiration *time.Time = nil
if dsInfo.AuthType == "arn" && strings.Index(dsInfo.AssumeRoleArn, "arn:aws:iam:") == 0 {
params := &sts.AssumeRoleInput{
RoleArn: aws.String(dsInfo.AssumeRoleArn),
diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go
index e1e131d9f3a..718f9e0d253 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query.go
@@ -35,6 +35,7 @@ type CustomMetricsCache struct {
var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache
var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache
+var regionCache sync.Map
func init() {
metricsMap = map[string][]string{
@@ -233,15 +234,50 @@ func parseMultiSelectValue(input string) []string {
// Whenever this list is updated, frontend list should also be updated.
// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
- regions := []string{
- "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "ca-central-1", "cn-north-1", "cn-northwest-1",
- "eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2",
+ dsInfo := e.getDsInfo("default")
+ profile := dsInfo.Profile
+ if cache, ok := regionCache.Load(profile); ok {
+ if cache2, ok2 := cache.([]suggestData); ok2 {
+ return cache2, nil
+ }
}
+ regions := []string{
+ "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1",
+ "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2",
+ "cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1",
+ }
+ err := e.ensureClientSession("default")
+ if err != nil {
+ return nil, err
+ }
+ r, err := e.ec2Svc.DescribeRegions(&ec2.DescribeRegionsInput{})
+ if err != nil {
+ // ignore error for backward compatibility
+ plog.Error("Failed to get regions", "error", err)
+ } else {
+ for _, region := range r.Regions {
+ exists := false
+
+ for _, existingRegion := range regions {
+ if existingRegion == *region.RegionName {
+ exists = true
+ break
+ }
+ }
+
+ if !exists {
+ regions = append(regions, *region.RegionName)
+ }
+ }
+ }
+ sort.Strings(regions)
+
result := make([]suggestData, 0)
for _, region := range regions {
result = append(result, suggestData{Text: region, Value: region})
}
+ regionCache.Store(profile, result)
return result, nil
}
diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go
index e3903e8027e..34c3379b4df 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query_test.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go
@@ -9,20 +9,26 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/bmizerany/assert"
+ "github.com/grafana/grafana/pkg/components/securejsondata"
"github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb"
. "github.com/smartystreets/goconvey/convey"
)
type mockedEc2 struct {
ec2iface.EC2API
- Resp ec2.DescribeInstancesOutput
+ Resp ec2.DescribeInstancesOutput
+ RespRegions ec2.DescribeRegionsOutput
}
func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error {
fn(&m.Resp, true)
return nil
}
+func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeRegionsOutput, error) {
+ return &m.RespRegions, nil
+}
func TestCloudWatchMetrics(t *testing.T) {
@@ -82,6 +88,31 @@ func TestCloudWatchMetrics(t *testing.T) {
})
})
+ Convey("When calling handleGetRegions", t, func() {
+ executor := &CloudWatchExecutor{
+ ec2Svc: mockedEc2{RespRegions: ec2.DescribeRegionsOutput{
+ Regions: []*ec2.Region{
+ {
+ RegionName: aws.String("ap-northeast-2"),
+ },
+ },
+ }},
+ }
+ jsonData := simplejson.New()
+ jsonData.Set("defaultRegion", "default")
+ executor.DataSource = &models.DataSource{
+ JsonData: jsonData,
+ SecureJsonData: securejsondata.SecureJsonData{},
+ }
+
+ result, _ := executor.handleGetRegions(context.Background(), simplejson.New(), &tsdb.TsdbQuery{})
+
+ Convey("Should return regions", func() {
+ So(result[0].Text, ShouldEqual, "ap-northeast-1")
+ So(result[1].Text, ShouldEqual, "ap-northeast-2")
+ })
+ })
+
Convey("When calling handleGetEc2InstanceAttribute", t, func() {
executor := &CloudWatchExecutor{
ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{
diff --git a/pkg/tsdb/elasticsearch/client/client_test.go b/pkg/tsdb/elasticsearch/client/client_test.go
index af9ac0d8fce..540a999688a 100644
--- a/pkg/tsdb/elasticsearch/client/client_test.go
+++ b/pkg/tsdb/elasticsearch/client/client_test.go
@@ -25,7 +25,7 @@ func TestClient(t *testing.T) {
JsonData: simplejson.NewFromAny(make(map[string]interface{})),
}
- _, err := NewClient(nil, ds, nil)
+ _, err := NewClient(context.Background(), ds, nil)
So(err, ShouldNotBeNil)
})
@@ -36,7 +36,7 @@ func TestClient(t *testing.T) {
}),
}
- _, err := NewClient(nil, ds, nil)
+ _, err := NewClient(context.Background(), ds, nil)
So(err, ShouldNotBeNil)
})
@@ -48,7 +48,7 @@ func TestClient(t *testing.T) {
}),
}
- _, err := NewClient(nil, ds, nil)
+ _, err := NewClient(context.Background(), ds, nil)
So(err, ShouldNotBeNil)
})
@@ -60,7 +60,7 @@ func TestClient(t *testing.T) {
}),
}
- c, err := NewClient(nil, ds, nil)
+ c, err := NewClient(context.Background(), ds, nil)
So(err, ShouldBeNil)
So(c.GetVersion(), ShouldEqual, 2)
})
@@ -73,7 +73,7 @@ func TestClient(t *testing.T) {
}),
}
- c, err := NewClient(nil, ds, nil)
+ c, err := NewClient(context.Background(), ds, nil)
So(err, ShouldBeNil)
So(c.GetVersion(), ShouldEqual, 5)
})
@@ -86,7 +86,7 @@ func TestClient(t *testing.T) {
}),
}
- c, err := NewClient(nil, ds, nil)
+ c, err := NewClient(context.Background(), ds, nil)
So(err, ShouldBeNil)
So(c.GetVersion(), ShouldEqual, 56)
})
diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go
index fddcf3cb8b3..869e23e21ce 100644
--- a/pkg/tsdb/elasticsearch/time_series_query.go
+++ b/pkg/tsdb/elasticsearch/time_series_query.go
@@ -171,6 +171,10 @@ func addTermsAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg, metrics []*Metr
} else {
a.Size = 500
}
+ if a.Size == 0 {
+ a.Size = 500
+ }
+
if minDocCount, err := bucketAgg.Settings.Get("min_doc_count").Int(); err == nil {
a.MinDocCount = &minDocCount
}
diff --git a/pkg/tsdb/elasticsearch/time_series_query_test.go b/pkg/tsdb/elasticsearch/time_series_query_test.go
index 49bf5f5bc75..fe8ae0fa8f2 100644
--- a/pkg/tsdb/elasticsearch/time_series_query_test.go
+++ b/pkg/tsdb/elasticsearch/time_series_query_test.go
@@ -60,7 +60,7 @@ func TestExecuteTimeSeriesQuery(t *testing.T) {
_, err := executeTsdbQuery(c, `{
"timeField": "@timestamp",
"bucketAggs": [
- { "type": "terms", "field": "@host", "id": "2" },
+ { "type": "terms", "field": "@host", "id": "2", "settings": { "size": "0", "order": "asc" } },
{ "type": "date_histogram", "field": "@timestamp", "id": "3" }
],
"metrics": [{"type": "count", "id": "1" }]
@@ -69,7 +69,9 @@ func TestExecuteTimeSeriesQuery(t *testing.T) {
sr := c.multisearchRequests[0].Requests[0]
firstLevel := sr.Aggs[0]
So(firstLevel.Key, ShouldEqual, "2")
- So(firstLevel.Aggregation.Aggregation.(*es.TermsAggregation).Field, ShouldEqual, "@host")
+ termsAgg := firstLevel.Aggregation.Aggregation.(*es.TermsAggregation)
+ So(termsAgg.Field, ShouldEqual, "@host")
+ So(termsAgg.Size, ShouldEqual, 500)
secondLevel := firstLevel.Aggregation.Aggs[0]
So(secondLevel.Key, ShouldEqual, "3")
So(secondLevel.Aggregation.Aggregation.(*es.DateHistogramAgg).Field, ShouldEqual, "@timestamp")
diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go
index 2960ba0edc4..ff0ed8d0620 100644
--- a/pkg/tsdb/graphite/graphite.go
+++ b/pkg/tsdb/graphite/graphite.go
@@ -164,14 +164,12 @@ func formatTimeRange(input string) string {
func fixIntervalFormat(target string) string {
rMinute := regexp.MustCompile(`'(\d+)m'`)
- rMin := regexp.MustCompile("m")
target = rMinute.ReplaceAllStringFunc(target, func(m string) string {
- return rMin.ReplaceAllString(m, "min")
+ return strings.Replace(m, "m", "min", -1)
})
rMonth := regexp.MustCompile(`'(\d+)M'`)
- rMon := regexp.MustCompile("M")
target = rMonth.ReplaceAllStringFunc(target, func(M string) string {
- return rMon.ReplaceAllString(M, "mon")
+ return strings.Replace(M, "M", "mon", -1)
})
return target
}
diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go
index 9303712a480..0a260f7ad70 100644
--- a/pkg/tsdb/mssql/macros.go
+++ b/pkg/tsdb/mssql/macros.go
@@ -66,10 +66,6 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
}
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
- case "__timeFrom":
- return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
- case "__timeTo":
- return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval", name)
@@ -96,10 +92,6 @@ func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
- case "__unixEpochFrom":
- return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
- case "__unixEpochTo":
- return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
diff --git a/pkg/tsdb/mssql/macros_test.go b/pkg/tsdb/mssql/macros_test.go
index 8e0973b750c..7456238efa4 100644
--- a/pkg/tsdb/mssql/macros_test.go
+++ b/pkg/tsdb/mssql/macros_test.go
@@ -111,20 +111,6 @@ func TestMacroEngine(t *testing.T) {
So(fillInterval, ShouldEqual, 5*time.Minute.Seconds())
})
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
- })
-
Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil)
@@ -132,20 +118,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
})
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
-
Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@@ -171,40 +143,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
})
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
- })
-
Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
})
-
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
})
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -219,40 +163,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
})
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
- })
-
Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time_column)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time_column >= %d AND time_column <= %d", from.Unix(), to.Unix()))
})
-
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
})
})
}
diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go
index 72e57d03fa0..469d6baa5de 100644
--- a/pkg/tsdb/mssql/mssql.go
+++ b/pkg/tsdb/mssql/mssql.go
@@ -52,13 +52,18 @@ func generateConnectionString(datasource *models.DataSource) string {
}
server, port := hostParts[0], hostParts[1]
- return fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
+ encrypt := datasource.JsonData.Get("encrypt").MustString("false")
+ connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
server,
port,
datasource.Database,
datasource.User,
password,
)
+ if encrypt != "false" {
+ connStr += fmt.Sprintf("encrypt=%s;", encrypt)
+ }
+ return connStr
}
type mssqlRowTransformer struct {
diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go
index f9525fc37ac..c3d4470603d 100644
--- a/pkg/tsdb/mssql/mssql_test.go
+++ b/pkg/tsdb/mssql/mssql_test.go
@@ -1,6 +1,7 @@
package mssql
import (
+ "context"
"fmt"
"math/rand"
"strings"
@@ -128,7 +129,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["A"]
So(err, ShouldBeNil)
@@ -218,7 +219,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -265,7 +266,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -327,7 +328,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -352,7 +353,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -441,7 +442,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -463,7 +464,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -485,7 +486,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -507,7 +508,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -529,7 +530,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -551,7 +552,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -573,7 +574,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -595,7 +596,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -617,7 +618,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -640,7 +641,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -663,7 +664,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -675,6 +676,30 @@ func TestMSSQL(t *testing.T) {
So(queryResult.Series[3].Name, ShouldEqual, "Metric B valueTwo")
})
+ Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
+ tsdb.Interpolate = origInterpolate
+ query := &tsdb.TsdbQuery{
+ TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
+ Queries: []*tsdb.Query{
+ {
+ DataSource: &models.DataSource{JsonData: simplejson.New()},
+ Model: simplejson.NewFromAny(map[string]interface{}{
+ "rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
+ "format": "time_series",
+ }),
+ RefId: "A",
+ },
+ },
+ }
+
+ resp, err := endpoint.Query(context.Background(), nil, query)
+ So(err, ShouldBeNil)
+ queryResult := resp.Results["A"]
+ So(queryResult.Error, ShouldBeNil)
+ So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
+
+ })
+
Convey("Given a stored procedure that takes @from and @to in epoch time", func() {
sql := `
IF object_id('sp_test_epoch') IS NOT NULL
@@ -719,9 +744,11 @@ func TestMSSQL(t *testing.T) {
So(err, ShouldBeNil)
Convey("When doing a metric query using stored procedure should return correct result", func() {
+ tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{
{
+ DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `DECLARE
@from int = $__unixEpochFrom(),
@@ -739,7 +766,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["A"]
So(err, ShouldBeNil)
So(queryResult.Error, ShouldBeNil)
@@ -796,9 +823,11 @@ func TestMSSQL(t *testing.T) {
So(err, ShouldBeNil)
Convey("When doing a metric query using stored procedure should return correct result", func() {
+ tsdb.Interpolate = origInterpolate
query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{
{
+ DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": `DECLARE
@from int = $__unixEpochFrom(),
@@ -816,7 +845,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["A"]
So(err, ShouldBeNil)
So(queryResult.Error, ShouldBeNil)
@@ -892,7 +921,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Deploys"]
So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -915,7 +944,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Tickets"]
So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -941,7 +970,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -971,7 +1000,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -1001,7 +1030,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -1031,7 +1060,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -1059,7 +1088,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -1087,7 +1116,7 @@ func TestMSSQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go
index 0f1c4fcaf2c..839f805568e 100644
--- a/pkg/tsdb/mysql/macros.go
+++ b/pkg/tsdb/mysql/macros.go
@@ -60,11 +60,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
- return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
- case "__timeFrom":
- return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
- case "__timeTo":
- return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
+ return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], m.timeRange.GetFromAsSecondsEpoch(), m.timeRange.GetToAsSecondsEpoch()), nil
case "__timeGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval", name)
@@ -91,10 +87,6 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
- case "__unixEpochFrom":
- return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
- case "__unixEpochTo":
- return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go
index fe153ca3e2d..24bf18873d5 100644
--- a/pkg/tsdb/mysql/macros_test.go
+++ b/pkg/tsdb/mysql/macros_test.go
@@ -60,21 +60,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
- So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
+ So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
})
Convey("interpolate __unixEpochFilter function", func() {
@@ -84,20 +70,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
-
Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@@ -120,21 +92,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
- So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
+ So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
})
Convey("interpolate __unixEpochFilter function", func() {
@@ -143,20 +101,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
-
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
})
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -168,21 +112,7 @@ func TestMacroEngine(t *testing.T) {
sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
So(err, ShouldBeNil)
- So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
+ So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix()))
})
Convey("interpolate __unixEpochFilter function", func() {
@@ -191,20 +121,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
-
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
})
})
}
diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go
index 645f6b49bbb..35b03e489a0 100644
--- a/pkg/tsdb/mysql/mysql.go
+++ b/pkg/tsdb/mysql/mysql.go
@@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"strconv"
+ "strings"
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/core"
@@ -20,10 +21,14 @@ func init() {
func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
logger := log.New("tsdb.mysql")
+ protocol := "tcp"
+ if strings.HasPrefix(datasource.Url, "/") {
+ protocol = "unix"
+ }
cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true",
datasource.User,
datasource.Password,
- "tcp",
+ protocol,
datasource.Url,
datasource.Database,
)
diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go
index 13d9040a738..476e3ba6586 100644
--- a/pkg/tsdb/mysql/mysql_test.go
+++ b/pkg/tsdb/mysql/mysql_test.go
@@ -1,6 +1,7 @@
package mysql
import (
+ "context"
"fmt"
"math/rand"
"strings"
@@ -129,7 +130,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -217,7 +218,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -264,7 +265,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -313,7 +314,7 @@ func TestMySQL(t *testing.T) {
query := &tsdb.TsdbQuery{
Queries: []*tsdb.Query{
{
- DataSource: &models.DataSource{},
+ DataSource: &models.DataSource{JsonData: simplejson.New()},
Model: simplejson.NewFromAny(map[string]interface{}{
"rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1",
"format": "time_series",
@@ -327,7 +328,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -352,7 +353,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -378,7 +379,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -473,7 +474,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -495,7 +496,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -517,7 +518,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -539,7 +540,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -561,7 +562,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -583,7 +584,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -605,7 +606,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -627,7 +628,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -649,7 +650,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -671,7 +672,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -693,7 +694,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -716,7 +717,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -741,7 +742,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -752,6 +753,30 @@ func TestMySQL(t *testing.T) {
})
})
+ Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
+ tsdb.Interpolate = origInterpolate
+ query := &tsdb.TsdbQuery{
+ TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
+ Queries: []*tsdb.Query{
+ {
+ DataSource: &models.DataSource{JsonData: simplejson.New()},
+ Model: simplejson.NewFromAny(map[string]interface{}{
+ "rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
+ "format": "time_series",
+ }),
+ RefId: "A",
+ },
+ },
+ }
+
+ resp, err := endpoint.Query(context.Background(), nil, query)
+ So(err, ShouldBeNil)
+ queryResult := resp.Results["A"]
+ So(queryResult.Error, ShouldBeNil)
+ So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
+
+ })
+
Convey("Given a table with event data", func() {
type event struct {
TimeSec int64
@@ -802,7 +827,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Deploys"]
So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -825,7 +850,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Tickets"]
So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -851,7 +876,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -881,7 +906,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -911,7 +936,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -941,7 +966,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -969,7 +994,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -997,7 +1022,7 @@ func TestMySQL(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go
index 16f4adb68a6..0fa5d8077e1 100644
--- a/pkg/tsdb/postgres/macros.go
+++ b/pkg/tsdb/postgres/macros.go
@@ -87,10 +87,6 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
}
return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
- case "__timeFrom":
- return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
- case "__timeTo":
- return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
case "__timeGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
@@ -122,10 +118,6 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
return "", fmt.Errorf("missing time column argument for macro %v", name)
}
return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], m.timeRange.GetFromAsSecondsEpoch(), args[0], m.timeRange.GetToAsSecondsEpoch()), nil
- case "__unixEpochFrom":
- return fmt.Sprintf("%d", m.timeRange.GetFromAsSecondsEpoch()), nil
- case "__unixEpochTo":
- return fmt.Sprintf("%d", m.timeRange.GetToAsSecondsEpoch()), nil
case "__unixEpochGroup":
if len(args) < 2 {
return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go
index b0b7a28ddd4..8a3699f82b2 100644
--- a/pkg/tsdb/postgres/macros_test.go
+++ b/pkg/tsdb/postgres/macros_test.go
@@ -44,13 +44,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
})
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
Convey("interpolate __timeGroup function pre 5.3 compatibility", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__timeGroup(time_column,'5m'), value")
@@ -102,13 +95,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, "GROUP BY time_bucket('300s',time_column)")
})
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
- })
-
Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil)
@@ -116,20 +102,6 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
-
Convey("interpolate __unixEpochGroup function", func() {
sql, err := engine.Interpolate(query, timeRange, "SELECT $__unixEpochGroup(time_column,'5m')")
@@ -155,40 +127,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
})
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
- })
-
Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
-
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
})
Convey("Given a time range between 1960-02-01 07:00 and 1980-02-03 08:00", func() {
@@ -203,40 +147,12 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
})
- Convey("interpolate __timeFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeFrom(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
- })
-
- Convey("interpolate __timeTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__timeTo(time_column)")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
- })
-
Convey("interpolate __unixEpochFilter function", func() {
sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFilter(time)")
So(err, ShouldBeNil)
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
-
- Convey("interpolate __unixEpochFrom function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochFrom()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
- })
-
- Convey("interpolate __unixEpochTo function", func() {
- sql, err := engine.Interpolate(query, timeRange, "select $__unixEpochTo()")
- So(err, ShouldBeNil)
-
- So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
- })
})
})
}
diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go
index fc1a5f34253..c381938aead 100644
--- a/pkg/tsdb/postgres/postgres_test.go
+++ b/pkg/tsdb/postgres/postgres_test.go
@@ -1,6 +1,7 @@
package postgres
import (
+ "context"
"fmt"
"math/rand"
"strings"
@@ -117,7 +118,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -197,7 +198,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -254,7 +255,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -279,7 +280,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -333,7 +334,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -360,7 +361,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -450,7 +451,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -472,7 +473,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -494,7 +495,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -516,7 +517,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -538,7 +539,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -560,7 +561,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -582,7 +583,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -604,7 +605,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -626,7 +627,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -649,7 +650,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -674,7 +675,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -683,6 +684,30 @@ func TestPostgres(t *testing.T) {
So(queryResult.Series[0].Name, ShouldEqual, "valueOne")
So(queryResult.Series[1].Name, ShouldEqual, "valueTwo")
})
+
+ Convey("When doing a query with timeFrom,timeTo,unixEpochFrom,unixEpochTo macros", func() {
+ tsdb.Interpolate = origInterpolate
+ query := &tsdb.TsdbQuery{
+ TimeRange: tsdb.NewFakeTimeRange("5m", "now", fromStart),
+ Queries: []*tsdb.Query{
+ {
+ DataSource: &models.DataSource{JsonData: simplejson.New()},
+ Model: simplejson.NewFromAny(map[string]interface{}{
+ "rawSql": `SELECT time FROM metric_values WHERE time > $__timeFrom() OR time < $__timeFrom() OR 1 < $__unixEpochFrom() OR $__unixEpochTo() > 1 ORDER BY 1`,
+ "format": "time_series",
+ }),
+ RefId: "A",
+ },
+ },
+ }
+
+ resp, err := endpoint.Query(context.Background(), nil, query)
+ So(err, ShouldBeNil)
+ queryResult := resp.Results["A"]
+ So(queryResult.Error, ShouldBeNil)
+ So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT time FROM metric_values WHERE time > '2018-03-15T12:55:00Z' OR time < '2018-03-15T12:55:00Z' OR 1 < 1521118500 OR 1521118800 > 1 ORDER BY 1")
+
+ })
})
Convey("Given a table with event data", func() {
@@ -735,7 +760,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Deploys"]
So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -758,7 +783,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
queryResult := resp.Results["Tickets"]
So(err, ShouldBeNil)
So(len(queryResult.Tables[0].Rows), ShouldEqual, 3)
@@ -784,7 +809,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -814,7 +839,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -844,7 +869,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -874,7 +899,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -902,7 +927,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
@@ -930,7 +955,7 @@ func TestPostgres(t *testing.T) {
},
}
- resp, err := endpoint.Query(nil, nil, query)
+ resp, err := endpoint.Query(context.Background(), nil, query)
So(err, ShouldBeNil)
queryResult := resp.Results["A"]
So(queryResult.Error, ShouldBeNil)
diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sql_engine.go
index 18e02e328d1..1a4e2bd3943 100644
--- a/pkg/tsdb/sql_engine.go
+++ b/pkg/tsdb/sql_engine.go
@@ -98,8 +98,12 @@ var NewSqlQueryEndpoint = func(config *SqlQueryEndpointConfiguration, rowTransfo
return nil, err
}
- engine.SetMaxOpenConns(10)
- engine.SetMaxIdleConns(10)
+ maxOpenConns := config.Datasource.JsonData.Get("maxOpenConns").MustInt(0)
+ engine.SetMaxOpenConns(maxOpenConns)
+ maxIdleConns := config.Datasource.JsonData.Get("maxIdleConns").MustInt(2)
+ engine.SetMaxIdleConns(maxIdleConns)
+ connMaxLifetime := config.Datasource.JsonData.Get("connMaxLifetime").MustInt(14400)
+ engine.SetConnMaxLifetime(time.Duration(connMaxLifetime) * time.Second)
engineCache.versions[config.Datasource.Id] = config.Datasource.Version
engineCache.cache[config.Datasource.Id] = engine
@@ -116,9 +120,7 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource,
Results: make(map[string]*QueryResult),
}
- session := e.engine.NewSession()
- defer session.Close()
- db := session.DB()
+ var wg sync.WaitGroup
for _, query := range tsdbQuery.Queries {
rawSQL := query.Model.Get("rawSql").MustString()
@@ -145,31 +147,41 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource,
queryResult.Meta.Set("sql", rawSQL)
- rows, err := db.Query(rawSQL)
- if err != nil {
- queryResult.Error = err
- continue
- }
+ wg.Add(1)
- defer rows.Close()
+ go func(rawSQL string, query *Query, queryResult *QueryResult) {
+ defer wg.Done()
+ session := e.engine.NewSession()
+ defer session.Close()
+ db := session.DB()
- format := query.Model.Get("format").MustString("time_series")
-
- switch format {
- case "time_series":
- err := e.transformToTimeSeries(query, rows, queryResult, tsdbQuery)
+ rows, err := db.Query(rawSQL)
if err != nil {
queryResult.Error = err
- continue
+ return
}
- case "table":
- err := e.transformToTable(query, rows, queryResult, tsdbQuery)
- if err != nil {
- queryResult.Error = err
- continue
+
+ defer rows.Close()
+
+ format := query.Model.Get("format").MustString("time_series")
+
+ switch format {
+ case "time_series":
+ err := e.transformToTimeSeries(query, rows, queryResult, tsdbQuery)
+ if err != nil {
+ queryResult.Error = err
+ return
+ }
+ case "table":
+ err := e.transformToTable(query, rows, queryResult, tsdbQuery)
+ if err != nil {
+ queryResult.Error = err
+ return
+ }
}
- }
+ }(rawSQL, query, queryResult)
}
+ wg.Wait()
return result, nil
}
@@ -184,6 +196,10 @@ var Interpolate = func(query *Query, timeRange *TimeRange, sql string) (string,
sql = strings.Replace(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10), -1)
sql = strings.Replace(sql, "$__interval", interval.Text, -1)
+ sql = strings.Replace(sql, "$__timeFrom()", fmt.Sprintf("'%s'", timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), -1)
+ sql = strings.Replace(sql, "$__timeTo()", fmt.Sprintf("'%s'", timeRange.GetToAsTimeUTC().Format(time.RFC3339)), -1)
+ sql = strings.Replace(sql, "$__unixEpochFrom()", fmt.Sprintf("%d", timeRange.GetFromAsSecondsEpoch()), -1)
+ sql = strings.Replace(sql, "$__unixEpochTo()", fmt.Sprintf("%d", timeRange.GetToAsSecondsEpoch()), -1)
return sql, nil
}
diff --git a/pkg/tsdb/sql_engine_test.go b/pkg/tsdb/sql_engine_test.go
index 05b8a51ae6f..bfcc82aac47 100644
--- a/pkg/tsdb/sql_engine_test.go
+++ b/pkg/tsdb/sql_engine_test.go
@@ -1,6 +1,7 @@
package tsdb
import (
+ "fmt"
"testing"
"time"
@@ -43,6 +44,34 @@ func TestSqlEngine(t *testing.T) {
So(sql, ShouldEqual, "select 60000 ")
})
+ Convey("interpolate __timeFrom function", func() {
+ sql, err := Interpolate(query, timeRange, "select $__timeFrom()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select '%s'", from.Format(time.RFC3339)))
+ })
+
+ Convey("interpolate __timeTo function", func() {
+ sql, err := Interpolate(query, timeRange, "select $__timeTo()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select '%s'", to.Format(time.RFC3339)))
+ })
+
+ Convey("interpolate __unixEpochFrom function", func() {
+ sql, err := Interpolate(query, timeRange, "select $__unixEpochFrom()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", from.Unix()))
+ })
+
+ Convey("interpolate __unixEpochTo function", func() {
+ sql, err := Interpolate(query, timeRange, "select $__unixEpochTo()")
+ So(err, ShouldBeNil)
+
+ So(sql, ShouldEqual, fmt.Sprintf("select %d", to.Unix()))
+ })
+
})
Convey("Given row values with time.Time as time columns", func() {
diff --git a/pkg/tsdb/stackdriver/annotation_query.go b/pkg/tsdb/stackdriver/annotation_query.go
new file mode 100644
index 00000000000..db35171ad70
--- /dev/null
+++ b/pkg/tsdb/stackdriver/annotation_query.go
@@ -0,0 +1,120 @@
+package stackdriver
+
+import (
+ "context"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/grafana/grafana/pkg/tsdb"
+)
+
+func (e *StackdriverExecutor) executeAnnotationQuery(ctx context.Context, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+ result := &tsdb.Response{
+ Results: make(map[string]*tsdb.QueryResult),
+ }
+
+ firstQuery := tsdbQuery.Queries[0]
+
+ queries, err := e.buildQueries(tsdbQuery)
+ if err != nil {
+ return nil, err
+ }
+
+ queryRes, resp, err := e.executeQuery(ctx, queries[0], tsdbQuery)
+ if err != nil {
+ return nil, err
+ }
+ title := firstQuery.Model.Get("title").MustString()
+ text := firstQuery.Model.Get("text").MustString()
+ tags := firstQuery.Model.Get("tags").MustString()
+ err = e.parseToAnnotations(queryRes, resp, queries[0], title, text, tags)
+ result.Results[firstQuery.RefId] = queryRes
+
+ return result, err
+}
+
+func (e *StackdriverExecutor) parseToAnnotations(queryRes *tsdb.QueryResult, data StackdriverResponse, query *StackdriverQuery, title string, text string, tags string) error {
+ annotations := make([]map[string]string, 0)
+
+ for _, series := range data.TimeSeries {
+ // reverse the order to be ascending
+ for i := len(series.Points) - 1; i >= 0; i-- {
+ point := series.Points[i]
+ value := strconv.FormatFloat(point.Value.DoubleValue, 'f', 6, 64)
+ if series.ValueType == "STRING" {
+ value = point.Value.StringValue
+ }
+ annotation := make(map[string]string)
+ annotation["time"] = point.Interval.EndTime.UTC().Format(time.RFC3339)
+ annotation["title"] = formatAnnotationText(title, value, series.Metric.Type, series.Metric.Labels, series.Resource.Labels)
+ annotation["tags"] = tags
+ annotation["text"] = formatAnnotationText(text, value, series.Metric.Type, series.Metric.Labels, series.Resource.Labels)
+ annotations = append(annotations, annotation)
+ }
+ }
+
+ transformAnnotationToTable(annotations, queryRes)
+ return nil
+}
+
+func transformAnnotationToTable(data []map[string]string, result *tsdb.QueryResult) {
+ table := &tsdb.Table{
+ Columns: make([]tsdb.TableColumn, 4),
+ Rows: make([]tsdb.RowValues, 0),
+ }
+ table.Columns[0].Text = "time"
+ table.Columns[1].Text = "title"
+ table.Columns[2].Text = "tags"
+ table.Columns[3].Text = "text"
+
+ for _, r := range data {
+ values := make([]interface{}, 4)
+ values[0] = r["time"]
+ values[1] = r["title"]
+ values[2] = r["tags"]
+ values[3] = r["text"]
+ table.Rows = append(table.Rows, values)
+ }
+ result.Tables = append(result.Tables, table)
+ result.Meta.Set("rowCount", len(data))
+ slog.Info("anno", "len", len(data))
+}
+
+func formatAnnotationText(annotationText string, pointValue string, metricType string, metricLabels map[string]string, resourceLabels map[string]string) string {
+ result := legendKeyFormat.ReplaceAllFunc([]byte(annotationText), func(in []byte) []byte {
+ metaPartName := strings.Replace(string(in), "{{", "", 1)
+ metaPartName = strings.Replace(metaPartName, "}}", "", 1)
+ metaPartName = strings.TrimSpace(metaPartName)
+
+ if metaPartName == "metric.type" {
+ return []byte(metricType)
+ }
+
+ metricPart := replaceWithMetricPart(metaPartName, metricType)
+
+ if metricPart != nil {
+ return metricPart
+ }
+
+ if metaPartName == "metric.value" {
+ return []byte(pointValue)
+ }
+
+ metaPartName = strings.Replace(metaPartName, "metric.label.", "", 1)
+
+ if val, exists := metricLabels[metaPartName]; exists {
+ return []byte(val)
+ }
+
+ metaPartName = strings.Replace(metaPartName, "resource.label.", "", 1)
+
+ if val, exists := resourceLabels[metaPartName]; exists {
+ return []byte(val)
+ }
+
+ return in
+ })
+
+ return string(result)
+}
diff --git a/pkg/tsdb/stackdriver/annotation_query_test.go b/pkg/tsdb/stackdriver/annotation_query_test.go
new file mode 100644
index 00000000000..8229470d665
--- /dev/null
+++ b/pkg/tsdb/stackdriver/annotation_query_test.go
@@ -0,0 +1,33 @@
+package stackdriver
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/tsdb"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestStackdriverAnnotationQuery(t *testing.T) {
+ Convey("Stackdriver Annotation Query Executor", t, func() {
+ executor := &StackdriverExecutor{}
+ Convey("When parsing the stackdriver api response", func() {
+ data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+ So(err, ShouldBeNil)
+ So(len(data.TimeSeries), ShouldEqual, 3)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "annotationQuery"}
+ query := &StackdriverQuery{}
+ err = executor.parseToAnnotations(res, data, query, "atitle {{metric.label.instance_name}} {{metric.value}}", "atext {{resource.label.zone}}", "atag")
+ So(err, ShouldBeNil)
+
+ Convey("Should return annotations table", func() {
+ So(len(res.Tables), ShouldEqual, 1)
+ So(len(res.Tables[0].Rows), ShouldEqual, 9)
+ So(res.Tables[0].Rows[0][1], ShouldEqual, "atitle collector-asia-east-1 9.856650")
+ So(res.Tables[0].Rows[0][3], ShouldEqual, "atext asia-east1-a")
+ })
+ })
+ })
+}
diff --git a/pkg/tsdb/stackdriver/ensure_default_project.go b/pkg/tsdb/stackdriver/ensure_default_project.go
new file mode 100644
index 00000000000..265fdda5151
--- /dev/null
+++ b/pkg/tsdb/stackdriver/ensure_default_project.go
@@ -0,0 +1,24 @@
+package stackdriver
+
+import (
+ "context"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/tsdb"
+)
+
+func (e *StackdriverExecutor) ensureDefaultProject(ctx context.Context, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+ queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: tsdbQuery.Queries[0].RefId}
+ result := &tsdb.Response{
+ Results: make(map[string]*tsdb.QueryResult),
+ }
+ defaultProject, err := e.getDefaultProject(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ e.dsInfo.JsonData.Set("defaultProject", defaultProject)
+ queryResult.Meta.Set("defaultProject", defaultProject)
+ result.Results[tsdbQuery.Queries[0].RefId] = queryResult
+ return result, nil
+}
diff --git a/pkg/tsdb/stackdriver/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go
new file mode 100644
index 00000000000..2ad47cc4b83
--- /dev/null
+++ b/pkg/tsdb/stackdriver/stackdriver.go
@@ -0,0 +1,612 @@
+package stackdriver
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "math"
+ "net/http"
+ "net/url"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/net/context/ctxhttp"
+ "golang.org/x/oauth2/google"
+
+ "github.com/grafana/grafana/pkg/api/pluginproxy"
+ "github.com/grafana/grafana/pkg/components/null"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/plugins"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/tsdb"
+ "github.com/opentracing/opentracing-go"
+)
+
+var (
+ slog log.Logger
+ legendKeyFormat *regexp.Regexp
+ metricNameFormat *regexp.Regexp
+)
+
+const (
+ gceAuthentication string = "gce"
+ jwtAuthentication string = "jwt"
+)
+
+// StackdriverExecutor executes queries for the Stackdriver datasource
+type StackdriverExecutor struct {
+ httpClient *http.Client
+ dsInfo *models.DataSource
+}
+
+// NewStackdriverExecutor initializes a http client
+func NewStackdriverExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
+ httpClient, err := dsInfo.GetHttpClient()
+ if err != nil {
+ return nil, err
+ }
+
+ return &StackdriverExecutor{
+ httpClient: httpClient,
+ dsInfo: dsInfo,
+ }, nil
+}
+
+func init() {
+ slog = log.New("tsdb.stackdriver")
+ tsdb.RegisterTsdbQueryEndpoint("stackdriver", NewStackdriverExecutor)
+ legendKeyFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`)
+ metricNameFormat = regexp.MustCompile(`([\w\d_]+)\.googleapis\.com/(.+)`)
+}
+
+// Query takes in the frontend queries, parses them into the Stackdriver query format
+// executes the queries against the Stackdriver API and parses the response into
+// the time series or table format
+func (e *StackdriverExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+ var result *tsdb.Response
+ var err error
+ queryType := tsdbQuery.Queries[0].Model.Get("type").MustString("")
+
+ switch queryType {
+ case "annotationQuery":
+ result, err = e.executeAnnotationQuery(ctx, tsdbQuery)
+ case "ensureDefaultProjectQuery":
+ result, err = e.ensureDefaultProject(ctx, tsdbQuery)
+ case "timeSeriesQuery":
+ fallthrough
+ default:
+ result, err = e.executeTimeSeriesQuery(ctx, tsdbQuery)
+ }
+
+ return result, err
+}
+
+func (e *StackdriverExecutor) executeTimeSeriesQuery(ctx context.Context, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
+ result := &tsdb.Response{
+ Results: make(map[string]*tsdb.QueryResult),
+ }
+
+ authenticationType := e.dsInfo.JsonData.Get("authenticationType").MustString(jwtAuthentication)
+ if authenticationType == gceAuthentication {
+ defaultProject, err := e.getDefaultProject(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("Failed to retrieve default project from GCE metadata server. error: %v", err)
+ }
+
+ e.dsInfo.JsonData.Set("defaultProject", defaultProject)
+ }
+
+ queries, err := e.buildQueries(tsdbQuery)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, query := range queries {
+ queryRes, resp, err := e.executeQuery(ctx, query, tsdbQuery)
+ if err != nil {
+ return nil, err
+ }
+ err = e.parseResponse(queryRes, resp, query)
+ if err != nil {
+ queryRes.Error = err
+ }
+ result.Results[query.RefID] = queryRes
+ }
+
+ return result, nil
+}
+
+func (e *StackdriverExecutor) buildQueries(tsdbQuery *tsdb.TsdbQuery) ([]*StackdriverQuery, error) {
+ stackdriverQueries := []*StackdriverQuery{}
+
+ startTime, err := tsdbQuery.TimeRange.ParseFrom()
+ if err != nil {
+ return nil, err
+ }
+
+ endTime, err := tsdbQuery.TimeRange.ParseTo()
+ if err != nil {
+ return nil, err
+ }
+
+ durationSeconds := int(endTime.Sub(startTime).Seconds())
+
+ for _, query := range tsdbQuery.Queries {
+ var target string
+
+ metricType := query.Model.Get("metricType").MustString()
+ filterParts := query.Model.Get("filters").MustArray()
+
+ params := url.Values{}
+ params.Add("interval.startTime", startTime.UTC().Format(time.RFC3339))
+ params.Add("interval.endTime", endTime.UTC().Format(time.RFC3339))
+ params.Add("filter", buildFilterString(metricType, filterParts))
+ params.Add("view", query.Model.Get("view").MustString("FULL"))
+ setAggParams(¶ms, query, durationSeconds)
+
+ target = params.Encode()
+
+ if setting.Env == setting.DEV {
+ slog.Debug("Stackdriver request", "params", params)
+ }
+
+ groupBys := query.Model.Get("groupBys").MustArray()
+ groupBysAsStrings := make([]string, 0)
+ for _, groupBy := range groupBys {
+ groupBysAsStrings = append(groupBysAsStrings, groupBy.(string))
+ }
+
+ aliasBy := query.Model.Get("aliasBy").MustString()
+
+ stackdriverQueries = append(stackdriverQueries, &StackdriverQuery{
+ Target: target,
+ Params: params,
+ RefID: query.RefId,
+ GroupBys: groupBysAsStrings,
+ AliasBy: aliasBy,
+ })
+ }
+
+ return stackdriverQueries, nil
+}
+
+func reverse(s string) string {
+ chars := []rune(s)
+ for i, j := 0, len(chars)-1; i < j; i, j = i+1, j-1 {
+ chars[i], chars[j] = chars[j], chars[i]
+ }
+ return string(chars)
+}
+
+func interpolateFilterWildcards(value string) string {
+ matches := strings.Count(value, "*")
+ if matches == 2 && strings.HasSuffix(value, "*") && strings.HasPrefix(value, "*") {
+ value = strings.Replace(value, "*", "", -1)
+ value = fmt.Sprintf(`has_substring("%s")`, value)
+ } else if matches == 1 && strings.HasPrefix(value, "*") {
+ value = strings.Replace(value, "*", "", 1)
+ value = fmt.Sprintf(`ends_with("%s")`, value)
+ } else if matches == 1 && strings.HasSuffix(value, "*") {
+ value = reverse(strings.Replace(reverse(value), "*", "", 1))
+ value = fmt.Sprintf(`starts_with("%s")`, value)
+ } else if matches != 0 {
+ re := regexp.MustCompile(`[-\/^$+?.()|[\]{}]`)
+ value = string(re.ReplaceAllFunc([]byte(value), func(in []byte) []byte {
+ return []byte(strings.Replace(string(in), string(in), `\\`+string(in), 1))
+ }))
+ value = strings.Replace(value, "*", ".*", -1)
+ value = strings.Replace(value, `"`, `\\"`, -1)
+ value = fmt.Sprintf(`monitoring.regex.full_match("^%s$")`, value)
+ }
+
+ return value
+}
+
+func buildFilterString(metricType string, filterParts []interface{}) string {
+ filterString := ""
+ for i, part := range filterParts {
+ mod := i % 4
+ if part == "AND" {
+ filterString += " "
+ } else if mod == 2 {
+ operator := filterParts[i-1]
+ if operator == "=~" || operator == "!=~" {
+ filterString = reverse(strings.Replace(reverse(filterString), "~", "", 1))
+ filterString += fmt.Sprintf(`monitoring.regex.full_match("%s")`, part)
+ } else if strings.Contains(part.(string), "*") {
+ filterString += interpolateFilterWildcards(part.(string))
+ } else {
+ filterString += fmt.Sprintf(`"%s"`, part)
+ }
+ } else {
+ filterString += part.(string)
+ }
+ }
+ return strings.Trim(fmt.Sprintf(`metric.type="%s" %s`, metricType, filterString), " ")
+}
+
+func setAggParams(params *url.Values, query *tsdb.Query, durationSeconds int) {
+ primaryAggregation := query.Model.Get("primaryAggregation").MustString()
+ perSeriesAligner := query.Model.Get("perSeriesAligner").MustString()
+ alignmentPeriod := query.Model.Get("alignmentPeriod").MustString()
+
+ if primaryAggregation == "" {
+ primaryAggregation = "REDUCE_NONE"
+ }
+
+ if perSeriesAligner == "" {
+ perSeriesAligner = "ALIGN_MEAN"
+ }
+
+ if alignmentPeriod == "grafana-auto" || alignmentPeriod == "" {
+ alignmentPeriodValue := int(math.Max(float64(query.IntervalMs)/1000, 60.0))
+ alignmentPeriod = "+" + strconv.Itoa(alignmentPeriodValue) + "s"
+ }
+
+ if alignmentPeriod == "stackdriver-auto" {
+ alignmentPeriodValue := int(math.Max(float64(durationSeconds), 60.0))
+ if alignmentPeriodValue < 60*60*23 {
+ alignmentPeriod = "+60s"
+ } else if alignmentPeriodValue < 60*60*24*6 {
+ alignmentPeriod = "+300s"
+ } else {
+ alignmentPeriod = "+3600s"
+ }
+ }
+
+ re := regexp.MustCompile("[0-9]+")
+ seconds, err := strconv.ParseInt(re.FindString(alignmentPeriod), 10, 64)
+ if err != nil || seconds > 3600 {
+ alignmentPeriod = "+3600s"
+ }
+
+ params.Add("aggregation.crossSeriesReducer", primaryAggregation)
+ params.Add("aggregation.perSeriesAligner", perSeriesAligner)
+ params.Add("aggregation.alignmentPeriod", alignmentPeriod)
+
+ groupBys := query.Model.Get("groupBys").MustArray()
+ if len(groupBys) > 0 {
+ for i := 0; i < len(groupBys); i++ {
+ params.Add("aggregation.groupByFields", groupBys[i].(string))
+ }
+ }
+}
+
+func (e *StackdriverExecutor) executeQuery(ctx context.Context, query *StackdriverQuery, tsdbQuery *tsdb.TsdbQuery) (*tsdb.QueryResult, StackdriverResponse, error) {
+ queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefID}
+
+ req, err := e.createRequest(ctx, e.dsInfo)
+ if err != nil {
+ queryResult.Error = err
+ return queryResult, StackdriverResponse{}, nil
+ }
+
+ req.URL.RawQuery = query.Params.Encode()
+ queryResult.Meta.Set("rawQuery", req.URL.RawQuery)
+ alignmentPeriod, ok := req.URL.Query()["aggregation.alignmentPeriod"]
+
+ if ok {
+ re := regexp.MustCompile("[0-9]+")
+ seconds, err := strconv.ParseInt(re.FindString(alignmentPeriod[0]), 10, 64)
+ if err == nil {
+ queryResult.Meta.Set("alignmentPeriod", seconds)
+ }
+ }
+
+ span, ctx := opentracing.StartSpanFromContext(ctx, "stackdriver query")
+ span.SetTag("target", query.Target)
+ span.SetTag("from", tsdbQuery.TimeRange.From)
+ span.SetTag("until", tsdbQuery.TimeRange.To)
+ span.SetTag("datasource_id", e.dsInfo.Id)
+ span.SetTag("org_id", e.dsInfo.OrgId)
+
+ defer span.Finish()
+
+ opentracing.GlobalTracer().Inject(
+ span.Context(),
+ opentracing.HTTPHeaders,
+ opentracing.HTTPHeadersCarrier(req.Header))
+
+ res, err := ctxhttp.Do(ctx, e.httpClient, req)
+ if err != nil {
+ queryResult.Error = err
+ return queryResult, StackdriverResponse{}, nil
+ }
+
+ data, err := e.unmarshalResponse(res)
+ if err != nil {
+ queryResult.Error = err
+ return queryResult, StackdriverResponse{}, nil
+ }
+
+ return queryResult, data, nil
+}
+
+func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (StackdriverResponse, error) {
+ body, err := ioutil.ReadAll(res.Body)
+ defer res.Body.Close()
+ if err != nil {
+ return StackdriverResponse{}, err
+ }
+
+ if res.StatusCode/100 != 2 {
+ slog.Error("Request failed", "status", res.Status, "body", string(body))
+ return StackdriverResponse{}, fmt.Errorf(string(body))
+ }
+
+ var data StackdriverResponse
+ err = json.Unmarshal(body, &data)
+ if err != nil {
+ slog.Error("Failed to unmarshal Stackdriver response", "error", err, "status", res.Status, "body", string(body))
+ return StackdriverResponse{}, err
+ }
+
+ return data, nil
+}
+
+func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data StackdriverResponse, query *StackdriverQuery) error {
+ metricLabels := make(map[string][]string)
+ resourceLabels := make(map[string][]string)
+ var resourceTypes []string
+
+ for _, series := range data.TimeSeries {
+ if !containsLabel(resourceTypes, series.Resource.Type) {
+ resourceTypes = append(resourceTypes, series.Resource.Type)
+ }
+ }
+
+ for _, series := range data.TimeSeries {
+ points := make([]tsdb.TimePoint, 0)
+
+ defaultMetricName := series.Metric.Type
+ if len(resourceTypes) > 1 {
+ defaultMetricName += " " + series.Resource.Type
+ }
+
+ for key, value := range series.Metric.Labels {
+ if !containsLabel(metricLabels[key], value) {
+ metricLabels[key] = append(metricLabels[key], value)
+ }
+ if len(query.GroupBys) == 0 || containsLabel(query.GroupBys, "metric.label."+key) {
+ defaultMetricName += " " + value
+ }
+ }
+
+ for key, value := range series.Resource.Labels {
+ if !containsLabel(resourceLabels[key], value) {
+ resourceLabels[key] = append(resourceLabels[key], value)
+ }
+ if containsLabel(query.GroupBys, "resource.label."+key) {
+ defaultMetricName += " " + value
+ }
+ }
+
+ // reverse the order to be ascending
+ if series.ValueType != "DISTRIBUTION" {
+ for i := len(series.Points) - 1; i >= 0; i-- {
+ point := series.Points[i]
+ value := point.Value.DoubleValue
+
+ if series.ValueType == "INT64" {
+ parsedValue, err := strconv.ParseFloat(point.Value.IntValue, 64)
+ if err == nil {
+ value = parsedValue
+ }
+ }
+
+ if series.ValueType == "BOOL" {
+ if point.Value.BoolValue {
+ value = 1
+ } else {
+ value = 0
+ }
+ }
+
+ points = append(points, tsdb.NewTimePoint(null.FloatFrom(value), float64((point.Interval.EndTime).Unix())*1000))
+ }
+
+ metricName := formatLegendKeys(series.Metric.Type, defaultMetricName, series.Resource.Type, series.Metric.Labels, series.Resource.Labels, make(map[string]string), query)
+
+ queryRes.Series = append(queryRes.Series, &tsdb.TimeSeries{
+ Name: metricName,
+ Points: points,
+ })
+ } else {
+ buckets := make(map[int]*tsdb.TimeSeries)
+
+ for i := len(series.Points) - 1; i >= 0; i-- {
+ point := series.Points[i]
+ if len(point.Value.DistributionValue.BucketCounts) == 0 {
+ continue
+ }
+ maxKey := 0
+ for i := 0; i < len(point.Value.DistributionValue.BucketCounts); i++ {
+ value, err := strconv.ParseFloat(point.Value.DistributionValue.BucketCounts[i], 64)
+ if err != nil {
+ continue
+ }
+ if _, ok := buckets[i]; !ok {
+ // set lower bounds
+ // https://cloud.google.com/monitoring/api/ref_v3/rest/v3/TimeSeries#Distribution
+ bucketBound := calcBucketBound(point.Value.DistributionValue.BucketOptions, i)
+ additionalLabels := map[string]string{"bucket": bucketBound}
+ buckets[i] = &tsdb.TimeSeries{
+ Name: formatLegendKeys(series.Metric.Type, defaultMetricName, series.Resource.Type, series.Metric.Labels, series.Resource.Labels, additionalLabels, query),
+ Points: make([]tsdb.TimePoint, 0),
+ }
+ if maxKey < i {
+ maxKey = i
+ }
+ }
+ buckets[i].Points = append(buckets[i].Points, tsdb.NewTimePoint(null.FloatFrom(value), float64((point.Interval.EndTime).Unix())*1000))
+ }
+
+ // fill empty bucket
+ for i := 0; i < maxKey; i++ {
+ if _, ok := buckets[i]; !ok {
+ bucketBound := calcBucketBound(point.Value.DistributionValue.BucketOptions, i)
+ additionalLabels := map[string]string{"bucket": bucketBound}
+ buckets[i] = &tsdb.TimeSeries{
+ Name: formatLegendKeys(series.Metric.Type, defaultMetricName, series.Resource.Type, series.Metric.Labels, series.Resource.Labels, additionalLabels, query),
+ Points: make([]tsdb.TimePoint, 0),
+ }
+ }
+ }
+ }
+ for i := 0; i < len(buckets); i++ {
+ queryRes.Series = append(queryRes.Series, buckets[i])
+ }
+ }
+ }
+
+ queryRes.Meta.Set("resourceLabels", resourceLabels)
+ queryRes.Meta.Set("metricLabels", metricLabels)
+ queryRes.Meta.Set("groupBys", query.GroupBys)
+ queryRes.Meta.Set("resourceTypes", resourceTypes)
+
+ return nil
+}
+
+func containsLabel(labels []string, newLabel string) bool {
+ for _, val := range labels {
+ if val == newLabel {
+ return true
+ }
+ }
+ return false
+}
+
+func formatLegendKeys(metricType string, defaultMetricName string, resourceType string, metricLabels map[string]string, resourceLabels map[string]string, additionalLabels map[string]string, query *StackdriverQuery) string {
+ if query.AliasBy == "" {
+ return defaultMetricName
+ }
+
+ result := legendKeyFormat.ReplaceAllFunc([]byte(query.AliasBy), func(in []byte) []byte {
+ metaPartName := strings.Replace(string(in), "{{", "", 1)
+ metaPartName = strings.Replace(metaPartName, "}}", "", 1)
+ metaPartName = strings.TrimSpace(metaPartName)
+
+ if metaPartName == "metric.type" {
+ return []byte(metricType)
+ }
+
+ if metaPartName == "resource.type" && resourceType != "" {
+ return []byte(resourceType)
+ }
+
+ metricPart := replaceWithMetricPart(metaPartName, metricType)
+
+ if metricPart != nil {
+ return metricPart
+ }
+
+ metaPartName = strings.Replace(metaPartName, "metric.label.", "", 1)
+
+ if val, exists := metricLabels[metaPartName]; exists {
+ return []byte(val)
+ }
+
+ metaPartName = strings.Replace(metaPartName, "resource.label.", "", 1)
+
+ if val, exists := resourceLabels[metaPartName]; exists {
+ return []byte(val)
+ }
+
+ if val, exists := additionalLabels[metaPartName]; exists {
+ return []byte(val)
+ }
+
+ return in
+ })
+
+ return string(result)
+}
+
+func replaceWithMetricPart(metaPartName string, metricType string) []byte {
+ // https://cloud.google.com/monitoring/api/v3/metrics-details#label_names
+ shortMatches := metricNameFormat.FindStringSubmatch(metricType)
+
+ if metaPartName == "metric.name" {
+ if len(shortMatches) > 0 {
+ return []byte(shortMatches[2])
+ }
+ }
+
+ if metaPartName == "metric.service" {
+ if len(shortMatches) > 0 {
+ return []byte(shortMatches[1])
+ }
+ }
+
+ return nil
+}
+
+func calcBucketBound(bucketOptions StackdriverBucketOptions, n int) string {
+ bucketBound := "0"
+ if n == 0 {
+ return bucketBound
+ }
+
+ if bucketOptions.LinearBuckets != nil {
+ bucketBound = strconv.FormatInt(bucketOptions.LinearBuckets.Offset+(bucketOptions.LinearBuckets.Width*int64(n-1)), 10)
+ } else if bucketOptions.ExponentialBuckets != nil {
+ bucketBound = strconv.FormatInt(int64(bucketOptions.ExponentialBuckets.Scale*math.Pow(bucketOptions.ExponentialBuckets.GrowthFactor, float64(n-1))), 10)
+ } else if bucketOptions.ExplicitBuckets != nil {
+ bucketBound = strconv.FormatInt(bucketOptions.ExplicitBuckets.Bounds[(n-1)], 10)
+ }
+ return bucketBound
+}
+
+func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models.DataSource) (*http.Request, error) {
+ u, _ := url.Parse(dsInfo.Url)
+ u.Path = path.Join(u.Path, "render")
+
+ req, err := http.NewRequest(http.MethodGet, "https://monitoring.googleapis.com/", nil)
+ if err != nil {
+ slog.Error("Failed to create request", "error", err)
+ return nil, fmt.Errorf("Failed to create request. error: %v", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion))
+
+ // find plugin
+ plugin, ok := plugins.DataSources[dsInfo.Type]
+ if !ok {
+ return nil, errors.New("Unable to find datasource plugin Stackdriver")
+ }
+
+ var stackdriverRoute *plugins.AppPluginRoute
+ for _, route := range plugin.Routes {
+ if route.Path == "stackdriver" {
+ stackdriverRoute = route
+ break
+ }
+ }
+
+ projectName := dsInfo.JsonData.Get("defaultProject").MustString()
+ proxyPass := fmt.Sprintf("stackdriver%s", "v3/projects/"+projectName+"/timeSeries")
+
+ pluginproxy.ApplyRoute(ctx, req, proxyPass, stackdriverRoute, dsInfo)
+
+ return req, nil
+}
+
+func (e *StackdriverExecutor) getDefaultProject(ctx context.Context) (string, error) {
+ authenticationType := e.dsInfo.JsonData.Get("authenticationType").MustString(jwtAuthentication)
+ if authenticationType == gceAuthentication {
+ defaultCredentials, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/monitoring.read")
+ if err != nil {
+ return "", fmt.Errorf("Failed to retrieve default project from GCE metadata server. error: %v", err)
+ }
+ return defaultCredentials.ProjectID, nil
+ }
+ return e.dsInfo.JsonData.Get("defaultProject").MustString(), nil
+}
diff --git a/pkg/tsdb/stackdriver/stackdriver_test.go b/pkg/tsdb/stackdriver/stackdriver_test.go
new file mode 100644
index 00000000000..784bf4a7fbb
--- /dev/null
+++ b/pkg/tsdb/stackdriver/stackdriver_test.go
@@ -0,0 +1,490 @@
+package stackdriver
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "math"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/tsdb"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestStackdriver(t *testing.T) {
+ Convey("Stackdriver", t, func() {
+ executor := &StackdriverExecutor{}
+
+ Convey("Parse queries from frontend and build Stackdriver API queries", func() {
+ fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local)
+ tsdbQuery := &tsdb.TsdbQuery{
+ TimeRange: &tsdb.TimeRange{
+ From: fmt.Sprintf("%v", fromStart.Unix()*1000),
+ To: fmt.Sprintf("%v", fromStart.Add(34*time.Minute).Unix()*1000),
+ },
+ Queries: []*tsdb.Query{
+ {
+ Model: simplejson.NewFromAny(map[string]interface{}{
+ "metricType": "a/metric/type",
+ "view": "FULL",
+ "aliasBy": "testalias",
+ "type": "timeSeriesQuery",
+ }),
+ RefId: "A",
+ },
+ },
+ }
+
+ Convey("and query has no aggregation set", func() {
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+
+ So(len(queries), ShouldEqual, 1)
+ So(queries[0].RefID, ShouldEqual, "A")
+ So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
+ So(len(queries[0].Params), ShouldEqual, 7)
+ So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
+ So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
+ So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
+ So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
+ So(queries[0].Params["view"][0], ShouldEqual, "FULL")
+ So(queries[0].AliasBy, ShouldEqual, "testalias")
+ })
+
+ Convey("and query has filters", func() {
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "metricType": "a/metric/type",
+ "filters": []interface{}{"key", "=", "value", "AND", "key2", "=", "value2"},
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(len(queries), ShouldEqual, 1)
+ So(queries[0].Params["filter"][0], ShouldEqual, `metric.type="a/metric/type" key="value" key2="value2"`)
+ })
+
+ Convey("and alignmentPeriod is set to grafana-auto", func() {
+ Convey("and IntervalMs is larger than 60000", func() {
+ tsdbQuery.Queries[0].IntervalMs = 1000000
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "alignmentPeriod": "grafana-auto",
+ "filters": []interface{}{"key", "=", "value", "AND", "key2", "=", "value2"},
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+1000s`)
+ })
+ Convey("and IntervalMs is less than 60000", func() {
+ tsdbQuery.Queries[0].IntervalMs = 30000
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "alignmentPeriod": "grafana-auto",
+ "filters": []interface{}{"key", "=", "value", "AND", "key2", "=", "value2"},
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+60s`)
+ })
+ })
+
+ Convey("and alignmentPeriod is set to stackdriver-auto", func() {
+ Convey("and range is two hours", func() {
+ tsdbQuery.TimeRange.From = "1538033322461"
+ tsdbQuery.TimeRange.To = "1538040522461"
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "target": "target",
+ "alignmentPeriod": "stackdriver-auto",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+60s`)
+ })
+
+ Convey("and range is 22 hours", func() {
+ tsdbQuery.TimeRange.From = "1538034524922"
+ tsdbQuery.TimeRange.To = "1538113724922"
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "target": "target",
+ "alignmentPeriod": "stackdriver-auto",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+60s`)
+ })
+
+ Convey("and range is 23 hours", func() {
+ tsdbQuery.TimeRange.From = "1538034567985"
+ tsdbQuery.TimeRange.To = "1538117367985"
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "target": "target",
+ "alignmentPeriod": "stackdriver-auto",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+300s`)
+ })
+
+ Convey("and range is 7 days", func() {
+ tsdbQuery.TimeRange.From = "1538036324073"
+ tsdbQuery.TimeRange.To = "1538641124073"
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "target": "target",
+ "alignmentPeriod": "stackdriver-auto",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+3600s`)
+ })
+ })
+
+ Convey("and alignmentPeriod is set in frontend", func() {
+ Convey("and alignment period is too big", func() {
+ tsdbQuery.Queries[0].IntervalMs = 1000
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "alignmentPeriod": "+360000s",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+3600s`)
+ })
+
+ Convey("and alignment period is within accepted range", func() {
+ tsdbQuery.Queries[0].IntervalMs = 1000
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "alignmentPeriod": "+600s",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, `+600s`)
+ })
+ })
+
+ Convey("and query has aggregation mean set", func() {
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "metricType": "a/metric/type",
+ "primaryAggregation": "REDUCE_MEAN",
+ "view": "FULL",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+
+ So(len(queries), ShouldEqual, 1)
+ So(queries[0].RefID, ShouldEqual, "A")
+ So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_MEAN&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
+ So(len(queries[0].Params), ShouldEqual, 7)
+ So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
+ So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
+ So(queries[0].Params["aggregation.crossSeriesReducer"][0], ShouldEqual, "REDUCE_MEAN")
+ So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
+ So(queries[0].Params["aggregation.alignmentPeriod"][0], ShouldEqual, "+60s")
+ So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
+ So(queries[0].Params["view"][0], ShouldEqual, "FULL")
+ })
+
+ Convey("and query has group bys", func() {
+ tsdbQuery.Queries[0].Model = simplejson.NewFromAny(map[string]interface{}{
+ "metricType": "a/metric/type",
+ "primaryAggregation": "REDUCE_NONE",
+ "groupBys": []interface{}{"metric.label.group1", "metric.label.group2"},
+ "view": "FULL",
+ })
+
+ queries, err := executor.buildQueries(tsdbQuery)
+ So(err, ShouldBeNil)
+
+ So(len(queries), ShouldEqual, 1)
+ So(queries[0].RefID, ShouldEqual, "A")
+ So(queries[0].Target, ShouldEqual, "aggregation.alignmentPeriod=%2B60s&aggregation.crossSeriesReducer=REDUCE_NONE&aggregation.groupByFields=metric.label.group1&aggregation.groupByFields=metric.label.group2&aggregation.perSeriesAligner=ALIGN_MEAN&filter=metric.type%3D%22a%2Fmetric%2Ftype%22&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z&view=FULL")
+ So(len(queries[0].Params), ShouldEqual, 8)
+ So(queries[0].Params["interval.startTime"][0], ShouldEqual, "2018-03-15T13:00:00Z")
+ So(queries[0].Params["interval.endTime"][0], ShouldEqual, "2018-03-15T13:34:00Z")
+ So(queries[0].Params["aggregation.perSeriesAligner"][0], ShouldEqual, "ALIGN_MEAN")
+ So(queries[0].Params["aggregation.groupByFields"][0], ShouldEqual, "metric.label.group1")
+ So(queries[0].Params["aggregation.groupByFields"][1], ShouldEqual, "metric.label.group2")
+ So(queries[0].Params["filter"][0], ShouldEqual, "metric.type=\"a/metric/type\"")
+ So(queries[0].Params["view"][0], ShouldEqual, "FULL")
+ })
+
+ })
+
+ Convey("Parse stackdriver response in the time series format", func() {
+ Convey("when data from query aggregated to one time series", func() {
+ data, err := loadTestFile("./test-data/1-series-response-agg-one-metric.json")
+ So(err, ShouldBeNil)
+ So(len(data.TimeSeries), ShouldEqual, 1)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &StackdriverQuery{}
+ err = executor.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ So(len(res.Series), ShouldEqual, 1)
+ So(res.Series[0].Name, ShouldEqual, "serviceruntime.googleapis.com/api/request_count")
+ So(len(res.Series[0].Points), ShouldEqual, 3)
+
+ Convey("timestamps should be in ascending order", func() {
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 0.05)
+ So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1536670020000)
+
+ So(res.Series[0].Points[1][0].Float64, ShouldEqual, 1.05)
+ So(res.Series[0].Points[1][1].Float64, ShouldEqual, 1536670080000)
+
+ So(res.Series[0].Points[2][0].Float64, ShouldEqual, 1.0666666666667)
+ So(res.Series[0].Points[2][1].Float64, ShouldEqual, 1536670260000)
+ })
+ })
+
+ Convey("when data from query with no aggregation", func() {
+ data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+ So(err, ShouldBeNil)
+ So(len(data.TimeSeries), ShouldEqual, 3)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &StackdriverQuery{}
+ err = executor.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ Convey("Should add labels to metric name", func() {
+ So(len(res.Series), ShouldEqual, 3)
+ So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-asia-east-1")
+ So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-europe-west-1")
+ So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-us-east-1")
+ })
+
+ Convey("Should parse to time series", func() {
+ So(len(res.Series[0].Points), ShouldEqual, 3)
+ So(res.Series[0].Points[0][0].Float64, ShouldEqual, 9.8566497180145)
+ So(res.Series[0].Points[1][0].Float64, ShouldEqual, 9.7323568146676)
+ So(res.Series[0].Points[2][0].Float64, ShouldEqual, 9.7730520330369)
+ })
+
+ Convey("Should add meta for labels to the response", func() {
+ metricLabels := res.Meta.Get("metricLabels").Interface().(map[string][]string)
+ So(metricLabels, ShouldNotBeNil)
+ So(len(metricLabels["instance_name"]), ShouldEqual, 3)
+ So(metricLabels["instance_name"][0], ShouldEqual, "collector-asia-east-1")
+ So(metricLabels["instance_name"][1], ShouldEqual, "collector-europe-west-1")
+ So(metricLabels["instance_name"][2], ShouldEqual, "collector-us-east-1")
+
+ resourceLabels := res.Meta.Get("resourceLabels").Interface().(map[string][]string)
+ So(resourceLabels, ShouldNotBeNil)
+ So(len(resourceLabels["zone"]), ShouldEqual, 3)
+ So(resourceLabels["zone"][0], ShouldEqual, "asia-east1-a")
+ So(resourceLabels["zone"][1], ShouldEqual, "europe-west1-b")
+ So(resourceLabels["zone"][2], ShouldEqual, "us-east1-b")
+
+ So(len(resourceLabels["project_id"]), ShouldEqual, 1)
+ So(resourceLabels["project_id"][0], ShouldEqual, "grafana-prod")
+ })
+ })
+
+ Convey("when data from query with no aggregation and group bys", func() {
+ data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+ So(err, ShouldBeNil)
+ So(len(data.TimeSeries), ShouldEqual, 3)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &StackdriverQuery{GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}
+ err = executor.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ Convey("Should add instance name and zone labels to metric name", func() {
+ So(len(res.Series), ShouldEqual, 3)
+ So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-asia-east-1 asia-east1-a")
+ So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-europe-west-1 europe-west1-b")
+ So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time collector-us-east-1 us-east1-b")
+ })
+ })
+
+ Convey("when data from query with no aggregation and alias by", func() {
+ data, err := loadTestFile("./test-data/2-series-response-no-agg.json")
+ So(err, ShouldBeNil)
+ So(len(data.TimeSeries), ShouldEqual, 3)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+
+ Convey("and the alias pattern is for metric type, a metric label and a resource label", func() {
+
+ query := &StackdriverQuery{AliasBy: "{{metric.type}} - {{metric.label.instance_name}} - {{resource.label.zone}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}
+ err = executor.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ Convey("Should use alias by formatting and only show instance name", func() {
+ So(len(res.Series), ShouldEqual, 3)
+ So(res.Series[0].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time - collector-asia-east-1 - asia-east1-a")
+ So(res.Series[1].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time - collector-europe-west-1 - europe-west1-b")
+ So(res.Series[2].Name, ShouldEqual, "compute.googleapis.com/instance/cpu/usage_time - collector-us-east-1 - us-east1-b")
+ })
+ })
+
+ Convey("and the alias pattern is for metric name", func() {
+
+ query := &StackdriverQuery{AliasBy: "metric {{metric.name}} service {{metric.service}}", GroupBys: []string{"metric.label.instance_name", "resource.label.zone"}}
+ err = executor.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ Convey("Should use alias by formatting and only show instance name", func() {
+ So(len(res.Series), ShouldEqual, 3)
+ So(res.Series[0].Name, ShouldEqual, "metric instance/cpu/usage_time service compute")
+ So(res.Series[1].Name, ShouldEqual, "metric instance/cpu/usage_time service compute")
+ So(res.Series[2].Name, ShouldEqual, "metric instance/cpu/usage_time service compute")
+ })
+ })
+ })
+
+ Convey("when data from query is distribution", func() {
+ data, err := loadTestFile("./test-data/3-series-response-distribution.json")
+ So(err, ShouldBeNil)
+ So(len(data.TimeSeries), ShouldEqual, 1)
+
+ res := &tsdb.QueryResult{Meta: simplejson.New(), RefId: "A"}
+ query := &StackdriverQuery{AliasBy: "{{bucket}}"}
+ err = executor.parseResponse(res, data, query)
+ So(err, ShouldBeNil)
+
+ So(len(res.Series), ShouldEqual, 11)
+ for i := 0; i < 11; i++ {
+ if i == 0 {
+ So(res.Series[i].Name, ShouldEqual, "0")
+ } else {
+ So(res.Series[i].Name, ShouldEqual, strconv.FormatInt(int64(math.Pow(float64(2), float64(i-1))), 10))
+ }
+ So(len(res.Series[i].Points), ShouldEqual, 3)
+ }
+
+ Convey("timestamps should be in ascending order", func() {
+ So(res.Series[0].Points[0][1].Float64, ShouldEqual, 1536668940000)
+ So(res.Series[0].Points[1][1].Float64, ShouldEqual, 1536669000000)
+ So(res.Series[0].Points[2][1].Float64, ShouldEqual, 1536669060000)
+ })
+
+ Convey("value should be correct", func() {
+ So(res.Series[8].Points[0][0].Float64, ShouldEqual, 1)
+ So(res.Series[9].Points[0][0].Float64, ShouldEqual, 1)
+ So(res.Series[10].Points[0][0].Float64, ShouldEqual, 1)
+ So(res.Series[8].Points[1][0].Float64, ShouldEqual, 0)
+ So(res.Series[9].Points[1][0].Float64, ShouldEqual, 0)
+ So(res.Series[10].Points[1][0].Float64, ShouldEqual, 1)
+ So(res.Series[8].Points[2][0].Float64, ShouldEqual, 0)
+ So(res.Series[9].Points[2][0].Float64, ShouldEqual, 1)
+ So(res.Series[10].Points[2][0].Float64, ShouldEqual, 0)
+ })
+ })
+
+ })
+
+ Convey("when interpolating filter wildcards", func() {
+ Convey("and wildcard is used in the beginning and the end of the word", func() {
+ Convey("and theres no wildcard in the middle of the word", func() {
+ value := interpolateFilterWildcards("*-central1*")
+ So(value, ShouldEqual, `has_substring("-central1")`)
+ })
+ Convey("and there is a wildcard in the middle of the word", func() {
+ value := interpolateFilterWildcards("*-cent*ral1*")
+ So(value, ShouldNotStartWith, `has_substring`)
+ })
+ })
+
+ Convey("and wildcard is used in the beginning of the word", func() {
+ Convey("and there is not a wildcard elsewhere in the word", func() {
+ value := interpolateFilterWildcards("*-central1")
+ So(value, ShouldEqual, `ends_with("-central1")`)
+ })
+ Convey("and there is a wildcard elsewhere in the word", func() {
+ value := interpolateFilterWildcards("*-cent*al1")
+ So(value, ShouldNotStartWith, `ends_with`)
+ })
+ })
+
+ Convey("and wildcard is used at the end of the word", func() {
+ Convey("and there is not a wildcard elsewhere in the word", func() {
+ value := interpolateFilterWildcards("us-central*")
+ So(value, ShouldEqual, `starts_with("us-central")`)
+ })
+ Convey("and there is a wildcard elsewhere in the word", func() {
+ value := interpolateFilterWildcards("*us-central*")
+ So(value, ShouldNotStartWith, `starts_with`)
+ })
+ })
+
+ Convey("and wildcard is used in the middle of the word", func() {
+ Convey("and there is only one wildcard", func() {
+ value := interpolateFilterWildcards("us-ce*tral1-b")
+ So(value, ShouldEqual, `monitoring.regex.full_match("^us\\-ce.*tral1\\-b$")`)
+ })
+
+ Convey("and there is more than one wildcard", func() {
+ value := interpolateFilterWildcards("us-ce*tra*1-b")
+ So(value, ShouldEqual, `monitoring.regex.full_match("^us\\-ce.*tra.*1\\-b$")`)
+ })
+ })
+
+ Convey("and wildcard is used in the middle of the word and in the beginning of the word", func() {
+ value := interpolateFilterWildcards("*s-ce*tral1-b")
+ So(value, ShouldEqual, `monitoring.regex.full_match("^.*s\\-ce.*tral1\\-b$")`)
+ })
+
+ Convey("and wildcard is used in the middle of the word and in the ending of the word", func() {
+ value := interpolateFilterWildcards("us-ce*tral1-*")
+ So(value, ShouldEqual, `monitoring.regex.full_match("^us\\-ce.*tral1\\-.*$")`)
+ })
+
+ Convey("and no wildcard is used", func() {
+ value := interpolateFilterWildcards("us-central1-a}")
+ So(value, ShouldEqual, `us-central1-a}`)
+ })
+ })
+
+ Convey("when building filter string", func() {
+ Convey("and theres no regex operator", func() {
+ Convey("and there are wildcards in a filter value", func() {
+ filterParts := []interface{}{"zone", "=", "*-central1*"}
+ value := buildFilterString("somemetrictype", filterParts)
+ So(value, ShouldEqual, `metric.type="somemetrictype" zone=has_substring("-central1")`)
+ })
+
+ Convey("and there are no wildcards in any filter value", func() {
+ filterParts := []interface{}{"zone", "!=", "us-central1-a"}
+ value := buildFilterString("somemetrictype", filterParts)
+ So(value, ShouldEqual, `metric.type="somemetrictype" zone!="us-central1-a"`)
+ })
+ })
+
+ Convey("and there is a regex operator", func() {
+ filterParts := []interface{}{"zone", "=~", "us-central1-a~"}
+ value := buildFilterString("somemetrictype", filterParts)
+ Convey("it should remove the ~ character from the operator that belongs to the value", func() {
+ So(value, ShouldNotContainSubstring, `=~`)
+ So(value, ShouldContainSubstring, `zone=`)
+ })
+
+ Convey("it should insert monitoring.regex.full_match before filter value", func() {
+ So(value, ShouldContainSubstring, `zone=monitoring.regex.full_match("us-central1-a~")`)
+ })
+ })
+ })
+ })
+}
+
+func loadTestFile(path string) (StackdriverResponse, error) {
+ var data StackdriverResponse
+
+ jsonBody, err := ioutil.ReadFile(path)
+ if err != nil {
+ return data, err
+ }
+ err = json.Unmarshal(jsonBody, &data)
+ return data, err
+}
diff --git a/pkg/tsdb/stackdriver/test-data/1-series-response-agg-one-metric.json b/pkg/tsdb/stackdriver/test-data/1-series-response-agg-one-metric.json
new file mode 100644
index 00000000000..e1a84583cc4
--- /dev/null
+++ b/pkg/tsdb/stackdriver/test-data/1-series-response-agg-one-metric.json
@@ -0,0 +1,46 @@
+{
+ "timeSeries": [
+ {
+ "metric": {
+ "type": "serviceruntime.googleapis.com\/api\/request_count"
+ },
+ "resource": {
+ "type": "consumed_api",
+ "labels": {
+ "project_id": "grafana-prod"
+ }
+ },
+ "metricKind": "GAUGE",
+ "valueType": "DOUBLE",
+ "points": [
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:51:00Z",
+ "endTime": "2018-09-11T12:51:00Z"
+ },
+ "value": {
+ "doubleValue": 1.0666666666667
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:48:00Z",
+ "endTime": "2018-09-11T12:48:00Z"
+ },
+ "value": {
+ "doubleValue": 1.05
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:47:00Z",
+ "endTime": "2018-09-11T12:47:00Z"
+ },
+ "value": {
+ "doubleValue": 0.05
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/pkg/tsdb/stackdriver/test-data/2-series-response-no-agg.json b/pkg/tsdb/stackdriver/test-data/2-series-response-no-agg.json
new file mode 100644
index 00000000000..da615a168bf
--- /dev/null
+++ b/pkg/tsdb/stackdriver/test-data/2-series-response-no-agg.json
@@ -0,0 +1,145 @@
+{
+ "timeSeries": [
+ {
+ "metric": {
+ "labels": {
+ "instance_name": "collector-asia-east-1"
+ },
+ "type": "compute.googleapis.com\/instance\/cpu\/usage_time"
+ },
+ "resource": {
+ "type": "gce_instance",
+ "labels": {
+ "instance_id": "1119268429530133111",
+ "zone": "asia-east1-a",
+ "project_id": "grafana-prod"
+ }
+ },
+ "metricKind": "DELTA",
+ "valueType": "DOUBLE",
+ "points": [
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:30:00Z",
+ "endTime": "2018-09-11T12:31:00Z"
+ },
+ "value": {
+ "doubleValue": 9.7730520330369
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:29:00Z",
+ "endTime": "2018-09-11T12:30:00Z"
+ },
+ "value": {
+ "doubleValue": 9.7323568146676
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:28:00Z",
+ "endTime": "2018-09-11T12:29:00Z"
+ },
+ "value": {
+ "doubleValue": 9.8566497180145
+ }
+ }
+ ]
+ },
+ {
+ "metric": {
+ "labels": {
+ "instance_name": "collector-europe-west-1"
+ },
+ "type": "compute.googleapis.com\/instance\/cpu\/usage_time"
+ },
+ "resource": {
+ "type": "gce_instance",
+ "labels": {
+ "instance_id": "22241654114540837222",
+ "zone": "europe-west1-b",
+ "project_id": "grafana-prod"
+ }
+ },
+ "metricKind": "DELTA",
+ "valueType": "DOUBLE",
+ "points": [
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:30:00Z",
+ "endTime": "2018-09-11T12:31:00Z"
+ },
+ "value": {
+ "doubleValue": 8.8210971239023
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:29:00Z",
+ "endTime": "2018-09-11T12:30:00Z"
+ },
+ "value": {
+ "doubleValue": 8.9689492364414
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:28:00Z",
+ "endTime": "2018-09-11T12:29:00Z"
+ },
+ "value": {
+ "doubleValue": 9.0238475054502
+ }
+ }
+ ]
+ },
+ {
+ "metric": {
+ "labels": {
+ "instance_name": "collector-us-east-1"
+ },
+ "type": "compute.googleapis.com\/instance\/cpu\/usage_time"
+ },
+ "resource": {
+ "type": "gce_instance",
+ "labels": {
+ "instance_id": "3332264424035095333",
+ "zone": "us-east1-b",
+ "project_id": "grafana-prod"
+ }
+ },
+ "metricKind": "DELTA",
+ "valueType": "DOUBLE",
+ "points": [
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:30:00Z",
+ "endTime": "2018-09-11T12:31:00Z"
+ },
+ "value": {
+ "doubleValue": 30.807846801355
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:29:00Z",
+ "endTime": "2018-09-11T12:30:00Z"
+ },
+ "value": {
+ "doubleValue": 30.903974115849
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:28:00Z",
+ "endTime": "2018-09-11T12:29:00Z"
+ },
+ "value": {
+ "doubleValue": 30.829426143318
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/pkg/tsdb/stackdriver/test-data/3-series-response-distribution.json b/pkg/tsdb/stackdriver/test-data/3-series-response-distribution.json
new file mode 100644
index 00000000000..8603f78eab4
--- /dev/null
+++ b/pkg/tsdb/stackdriver/test-data/3-series-response-distribution.json
@@ -0,0 +1,112 @@
+{
+ "timeSeries": [
+ {
+ "metric": {
+ "type": "loadbalancing.googleapis.com\/https\/backend_latencies"
+ },
+ "resource": {
+ "type": "https_lb_rule",
+ "labels": {
+ "project_id": "grafana-prod"
+ }
+ },
+ "metricKind": "DELTA",
+ "valueType": "DISTRIBUTION",
+ "points": [
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:30:00Z",
+ "endTime": "2018-09-11T12:31:00Z"
+ },
+ "value": {
+ "distributionValue": {
+ "count": "1",
+ "bucketOptions": {
+ "exponentialBuckets": {
+ "numFiniteBuckets": 10,
+ "growthFactor": 2,
+ "scale": 1
+ }
+ },
+ "bucketCounts": [
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "1",
+ "0"
+ ]
+ }
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:29:00Z",
+ "endTime": "2018-09-11T12:30:00Z"
+ },
+ "value": {
+ "distributionValue": {
+ "count": "1",
+ "bucketOptions": {
+ "exponentialBuckets": {
+ "numFiniteBuckets": 10,
+ "growthFactor": 2,
+ "scale": 1
+ }
+ },
+ "bucketCounts": [
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "1"
+ ]
+ }
+ }
+ },
+ {
+ "interval": {
+ "startTime": "2018-09-11T12:28:00Z",
+ "endTime": "2018-09-11T12:29:00Z"
+ },
+ "value": {
+ "distributionValue": {
+ "count": "3",
+ "bucketOptions": {
+ "exponentialBuckets": {
+ "numFiniteBuckets": 10,
+ "growthFactor": 2,
+ "scale": 1
+ }
+ },
+ "bucketCounts": [
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "0",
+ "1",
+ "1",
+ "1"
+ ]
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/pkg/tsdb/stackdriver/types.go b/pkg/tsdb/stackdriver/types.go
new file mode 100644
index 00000000000..3821ce7ceda
--- /dev/null
+++ b/pkg/tsdb/stackdriver/types.go
@@ -0,0 +1,75 @@
+package stackdriver
+
+import (
+ "net/url"
+ "time"
+)
+
+// StackdriverQuery is the query that Grafana sends from the frontend
+type StackdriverQuery struct {
+ Target string
+ Params url.Values
+ RefID string
+ GroupBys []string
+ AliasBy string
+}
+
+type StackdriverBucketOptions struct {
+ LinearBuckets *struct {
+ NumFiniteBuckets int64 `json:"numFiniteBuckets"`
+ Width int64 `json:"width"`
+ Offset int64 `json:"offset"`
+ } `json:"linearBuckets"`
+ ExponentialBuckets *struct {
+ NumFiniteBuckets int64 `json:"numFiniteBuckets"`
+ GrowthFactor float64 `json:"growthFactor"`
+ Scale float64 `json:"scale"`
+ } `json:"exponentialBuckets"`
+ ExplicitBuckets *struct {
+ Bounds []int64 `json:"bounds"`
+ } `json:"explicitBuckets"`
+}
+
+// StackdriverResponse is the data returned from the external Google Stackdriver API
+type StackdriverResponse struct {
+ TimeSeries []struct {
+ Metric struct {
+ Labels map[string]string `json:"labels"`
+ Type string `json:"type"`
+ } `json:"metric"`
+ Resource struct {
+ Type string `json:"type"`
+ Labels map[string]string `json:"labels"`
+ } `json:"resource"`
+ MetricKind string `json:"metricKind"`
+ ValueType string `json:"valueType"`
+ Points []struct {
+ Interval struct {
+ StartTime time.Time `json:"startTime"`
+ EndTime time.Time `json:"endTime"`
+ } `json:"interval"`
+ Value struct {
+ DoubleValue float64 `json:"doubleValue"`
+ StringValue string `json:"stringValue"`
+ BoolValue bool `json:"boolValue"`
+ IntValue string `json:"int64Value"`
+ DistributionValue struct {
+ Count string `json:"count"`
+ Mean float64 `json:"mean"`
+ SumOfSquaredDeviation float64 `json:"sumOfSquaredDeviation"`
+ Range struct {
+ Min int `json:"min"`
+ Max int `json:"max"`
+ } `json:"range"`
+ BucketOptions StackdriverBucketOptions `json:"bucketOptions"`
+ BucketCounts []string `json:"bucketCounts"`
+ Examplars []struct {
+ Value float64 `json:"value"`
+ Timestamp string `json:"timestamp"`
+ // attachments
+ } `json:"examplars"`
+ } `json:"distributionValue"`
+ } `json:"value"`
+ } `json:"points"`
+ } `json:"timeSeries"`
+}
diff --git a/pkg/tsdb/testdata/scenarios.go b/pkg/tsdb/testdata/scenarios.go
index e907fa8aae0..421a907b5e9 100644
--- a/pkg/tsdb/testdata/scenarios.go
+++ b/pkg/tsdb/testdata/scenarios.go
@@ -95,27 +95,20 @@ func init() {
Id: "random_walk",
Name: "Random Walk",
- Handler: func(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
- timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
- to := tsdbQuery.TimeRange.GetToAsMsEpoch()
+ Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
+ return getRandomWalk(query, context)
+ },
+ })
- series := newSeriesForQuery(query)
-
- points := make(tsdb.TimeSeriesPoints, 0)
- walker := rand.Float64() * 100
-
- for i := int64(0); i < 10000 && timeWalkerMs < to; i++ {
- points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs)))
-
- walker += rand.Float64() - 0.5
- timeWalkerMs += query.IntervalMs
- }
-
- series.Points = points
-
- queryRes := tsdb.NewQueryResult()
- queryRes.Series = append(queryRes.Series, series)
- return queryRes
+ registerScenario(&Scenario{
+ Id: "slow_query",
+ Name: "Slow Query",
+ StringInput: "5s",
+ Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
+ stringInput := query.Model.Get("stringInput").MustString()
+ parsedInterval, _ := time.ParseDuration(stringInput)
+ time.Sleep(parsedInterval)
+ return getRandomWalk(query, context)
},
})
@@ -221,6 +214,57 @@ func init() {
return queryRes
},
})
+
+ registerScenario(&Scenario{
+ Id: "table_static",
+ Name: "Table Static",
+
+ Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult {
+ timeWalkerMs := context.TimeRange.GetFromAsMsEpoch()
+ to := context.TimeRange.GetToAsMsEpoch()
+
+ table := tsdb.Table{
+ Columns: []tsdb.TableColumn{
+ {Text: "Time"},
+ {Text: "Message"},
+ {Text: "Description"},
+ {Text: "Value"},
+ },
+ Rows: []tsdb.RowValues{},
+ }
+ for i := int64(0); i < 10 && timeWalkerMs < to; i++ {
+ table.Rows = append(table.Rows, tsdb.RowValues{float64(timeWalkerMs), "This is a message", "Description", 23.1})
+ timeWalkerMs += query.IntervalMs
+ }
+
+ queryRes := tsdb.NewQueryResult()
+ queryRes.Tables = append(queryRes.Tables, &table)
+ return queryRes
+ },
+ })
+}
+
+func getRandomWalk(query *tsdb.Query, tsdbQuery *tsdb.TsdbQuery) *tsdb.QueryResult {
+ timeWalkerMs := tsdbQuery.TimeRange.GetFromAsMsEpoch()
+ to := tsdbQuery.TimeRange.GetToAsMsEpoch()
+
+ series := newSeriesForQuery(query)
+
+ points := make(tsdb.TimeSeriesPoints, 0)
+ walker := rand.Float64() * 100
+
+ for i := int64(0); i < 10000 && timeWalkerMs < to; i++ {
+ points = append(points, tsdb.NewTimePoint(null.FloatFrom(walker), float64(timeWalkerMs)))
+
+ walker += rand.Float64() - 0.5
+ timeWalkerMs += query.IntervalMs
+ }
+
+ series.Points = points
+
+ queryRes := tsdb.NewQueryResult()
+ queryRes.Series = append(queryRes.Series, series)
+ return queryRes
}
func registerScenario(scenario *Scenario) {
diff --git a/public/app/app.ts b/public/app/app.ts
index 8e30747072e..9647fbe5416 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -26,10 +26,18 @@ _.move = (array, fromIndex, toIndex) => {
return array;
};
-import { coreModule, registerAngularDirectives } from './core/core';
-import { setupAngularRoutes } from './routes/routes';
+import { coreModule, angularModules } from 'app/core/core_module';
+import { registerAngularDirectives } from 'app/core/core';
+import { setupAngularRoutes } from 'app/routes/routes';
-declare var System: any;
+import 'app/routes/GrafanaCtrl';
+import 'app/features/all';
+
+// import symlinked extensions
+const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
+extensionsIndex.keys().forEach(key => {
+ extensionsIndex(key);
+});
export class GrafanaApp {
registerFunctions: any;
@@ -105,39 +113,26 @@ export class GrafanaApp {
'react',
];
- const moduleTypes = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes'];
-
- _.each(moduleTypes, type => {
- const moduleName = 'grafana.' + type;
- this.useModule(angular.module(moduleName, []));
- });
-
// makes it possible to add dynamic stuff
- this.useModule(coreModule);
+ _.each(angularModules, m => {
+ this.useModule(m);
+ });
// register react angular wrappers
coreModule.config(setupAngularRoutes);
registerAngularDirectives();
- const preBootRequires = [System.import('app/features/all')];
+ // disable tool tip animation
+ $.fn.tooltip.defaults.animation = false;
- Promise.all(preBootRequires)
- .then(() => {
- // disable tool tip animation
- $.fn.tooltip.defaults.animation = false;
-
- // bootstrap the app
- angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
- _.each(this.preBootModules, module => {
- _.extend(module, this.registerFunctions);
- });
-
- this.preBootModules = null;
- });
- })
- .catch(err => {
- console.log('Application boot failed:', err);
+ // bootstrap the app
+ angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
+ _.each(this.preBootModules, module => {
+ _.extend(module, this.registerFunctions);
});
+
+ this.preBootModules = null;
+ });
}
}
diff --git a/public/app/core/actions/appNotification.ts b/public/app/core/actions/appNotification.ts
new file mode 100644
index 00000000000..b79b642eef1
--- /dev/null
+++ b/public/app/core/actions/appNotification.ts
@@ -0,0 +1,28 @@
+import { AppNotification } from 'app/types/';
+
+export enum ActionTypes {
+ AddAppNotification = 'ADD_APP_NOTIFICATION',
+ ClearAppNotification = 'CLEAR_APP_NOTIFICATION',
+}
+
+interface AddAppNotificationAction {
+ type: ActionTypes.AddAppNotification;
+ payload: AppNotification;
+}
+
+interface ClearAppNotificationAction {
+ type: ActionTypes.ClearAppNotification;
+ payload: number;
+}
+
+export type Action = AddAppNotificationAction | ClearAppNotificationAction;
+
+export const clearAppNotification = (appNotificationId: number) => ({
+ type: ActionTypes.ClearAppNotification,
+ payload: appNotificationId,
+});
+
+export const notifyApp = (appNotification: AppNotification) => ({
+ type: ActionTypes.AddAppNotification,
+ payload: appNotification,
+});
diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts
index 451a13dae99..f7ce2dda945 100644
--- a/public/app/core/actions/index.ts
+++ b/public/app/core/actions/index.ts
@@ -1,4 +1,5 @@
import { updateLocation } from './location';
import { updateNavIndex, UpdateNavIndexAction } from './navModel';
+import { notifyApp, clearAppNotification } from './appNotification';
-export { updateLocation, updateNavIndex, UpdateNavIndexAction };
+export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification };
diff --git a/public/app/core/actions/user.ts b/public/app/core/actions/user.ts
new file mode 100644
index 00000000000..dba0588c058
--- /dev/null
+++ b/public/app/core/actions/user.ts
@@ -0,0 +1,28 @@
+import { ThunkAction } from 'redux-thunk';
+import { getBackendSrv } from '../services/backend_srv';
+import { DashboardAcl, DashboardSearchHit, StoreState } from '../../types';
+
+type ThunkResult = ThunkAction;
+
+export type Action = LoadStarredDashboardsAction;
+
+export enum ActionTypes {
+ LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
+}
+
+interface LoadStarredDashboardsAction {
+ type: ActionTypes.LoadStarredDashboards;
+ payload: DashboardSearchHit[];
+}
+
+const starredDashboardsLoaded = (dashboards: DashboardAcl[]) => ({
+ type: ActionTypes.LoadStarredDashboards,
+ payload: dashboards,
+});
+
+export function loadStarredDashboards(): ThunkResult {
+ return async dispatch => {
+ const starredDashboards = await getBackendSrv().search({ starred: true });
+ dispatch(starredDashboardsLoaded(starredDashboards));
+ };
+}
diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts
index 6974d40aac8..7be28272f11 100644
--- a/public/app/core/angular_wrappers.ts
+++ b/public/app/core/angular_wrappers.ts
@@ -5,10 +5,12 @@ 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 AppNotificationList from './components/AppNotifications/AppNotificationList';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
react2AngularDirective('sidemenu', SideMenu, []);
+ react2AngularDirective('appNotificationsList', AppNotificationList, []);
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('searchResult', SearchResult, []);
diff --git a/public/app/core/components/Animations/SlideDown.tsx b/public/app/core/components/Animations/SlideDown.tsx
index 4d515f98f16..70dacd73849 100644
--- a/public/app/core/components/Animations/SlideDown.tsx
+++ b/public/app/core/components/Animations/SlideDown.tsx
@@ -1,15 +1,22 @@
-import React from 'react';
+import React from 'react';
import Transition from 'react-transition-group/Transition';
-const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
+interface Style {
+ transition?: string;
+ overflow?: string;
+}
+
+// When animating using max-height we need to use a static value.
// If this is not enough, pass in
+
+
+
+
{appNotification.title}
+
{appNotification.text}
+
+ onClearNotification(appNotification.id)}>
+
+
+
+ );
+ }
+}
diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx
new file mode 100644
index 00000000000..c91f8372384
--- /dev/null
+++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx
@@ -0,0 +1,60 @@
+import React, { PureComponent } from 'react';
+import appEvents from 'app/core/app_events';
+import AppNotificationItem from './AppNotificationItem';
+import { notifyApp, clearAppNotification } from 'app/core/actions';
+import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
+import { AppNotification, StoreState } from 'app/types';
+import {
+ createErrorNotification,
+ createSuccessNotification,
+ createWarningNotification,
+} from '../../copy/appNotification';
+
+export interface Props {
+ appNotifications: AppNotification[];
+ notifyApp: typeof notifyApp;
+ clearAppNotification: typeof clearAppNotification;
+}
+
+export class AppNotificationList extends PureComponent {
+ componentDidMount() {
+ const { notifyApp } = this.props;
+
+ appEvents.on('alert-warning', options => notifyApp(createWarningNotification(options[0], options[1])));
+ appEvents.on('alert-success', options => notifyApp(createSuccessNotification(options[0], options[1])));
+ appEvents.on('alert-error', options => notifyApp(createErrorNotification(options[0], options[1])));
+ }
+
+ onClearAppNotification = id => {
+ this.props.clearAppNotification(id);
+ };
+
+ render() {
+ const { appNotifications } = this.props;
+
+ return (
+
+ {appNotifications.map((appNotification, index) => {
+ return (
+
this.onClearAppNotification(id)}
+ />
+ );
+ })}
+
+ );
+ }
+}
+
+const mapStateToProps = (state: StoreState) => ({
+ appNotifications: state.appNotifications.appNotifications,
+});
+
+const mapDispatchToProps = {
+ notifyApp,
+ clearAppNotification,
+};
+
+export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps);
diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx
index 4af60f3c839..21700bb4d03 100644
--- a/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx
+++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.test.tsx
@@ -7,6 +7,7 @@ const model = {
buttonIcon: 'ga css class',
buttonLink: 'http://url/to/destination',
buttonTitle: 'Click me',
+ onClick: jest.fn(),
proTip: 'This is a tip',
proTipLink: 'http://url/to/tip/destination',
proTipLinkTitle: 'Learn more',
diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
index 5ece360e36a..ae0e39cc26d 100644
--- a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
+++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
@@ -11,6 +11,7 @@ class EmptyListCTA extends Component {
buttonIcon,
buttonLink,
buttonTitle,
+ onClick,
proTip,
proTipLink,
proTipLinkTitle,
@@ -19,7 +20,7 @@ class EmptyListCTA extends Component {
return (
{title}
-
+
{buttonTitle}
diff --git a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap
index 6d47c984d5e..b85660bcc6f 100644
--- a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap
+++ b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.test.tsx.snap
@@ -12,6 +12,7 @@ exports[`EmptyListCTA renders correctly 1`] = `
= props => {
return (
-
+
{props.children}
{props.tooltip && (
diff --git a/public/app/core/components/LayoutSelector/LayoutSelector.tsx b/public/app/core/components/LayoutSelector/LayoutSelector.tsx
new file mode 100644
index 00000000000..d9e00102438
--- /dev/null
+++ b/public/app/core/components/LayoutSelector/LayoutSelector.tsx
@@ -0,0 +1,39 @@
+import React, { SFC } from 'react';
+
+export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
+
+export enum LayoutModes {
+ Grid = 'grid',
+ List = 'list',
+}
+
+interface Props {
+ mode: LayoutMode;
+ onLayoutModeChanged: (mode: LayoutMode) => {};
+}
+
+const LayoutSelector: SFC = props => {
+ const { mode, onLayoutModeChanged } = props;
+ return (
+
+ {
+ onLayoutModeChanged(LayoutModes.List);
+ }}
+ className={mode === LayoutModes.List ? 'active' : ''}
+ >
+
+
+ {
+ onLayoutModeChanged(LayoutModes.Grid);
+ }}
+ className={mode === LayoutModes.Grid ? 'active' : ''}
+ >
+
+
+
+ );
+};
+
+export default LayoutSelector;
diff --git a/public/app/core/components/OrgActionBar/OrgActionBar.test.tsx b/public/app/core/components/OrgActionBar/OrgActionBar.test.tsx
new file mode 100644
index 00000000000..9faf07f18d1
--- /dev/null
+++ b/public/app/core/components/OrgActionBar/OrgActionBar.test.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import OrgActionBar, { Props } from './OrgActionBar';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ searchQuery: '',
+ setSearchQuery: jest.fn(),
+ target: '_blank',
+ linkButton: { href: 'some/url', title: 'test' },
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/core/components/OrgActionBar/OrgActionBar.tsx b/public/app/core/components/OrgActionBar/OrgActionBar.tsx
new file mode 100644
index 00000000000..8fc34a018e1
--- /dev/null
+++ b/public/app/core/components/OrgActionBar/OrgActionBar.tsx
@@ -0,0 +1,44 @@
+import React, { PureComponent } from 'react';
+import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
+
+export interface Props {
+ searchQuery: string;
+ layoutMode?: LayoutMode;
+ onSetLayoutMode?: (mode: LayoutMode) => {};
+ setSearchQuery: (value: string) => {};
+ linkButton: { href: string; title: string };
+ target?: string;
+}
+
+export default class OrgActionBar extends PureComponent {
+ render() {
+ const { searchQuery, layoutMode, onSetLayoutMode, linkButton, setSearchQuery, target } = this.props;
+ const linkProps = { href: linkButton.href, target: undefined };
+
+ if (target) {
+ linkProps.target = target;
+ }
+
+ return (
+
+
+
+ setSearchQuery(event.target.value)}
+ placeholder="Filter by name or type"
+ />
+
+
+ onSetLayoutMode(mode)} />
+
+
+
+ {linkButton.title}
+
+
+ );
+ }
+}
diff --git a/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap b/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap
new file mode 100644
index 00000000000..dc53e7863ea
--- /dev/null
+++ b/public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
diff --git a/public/app/core/components/PageLoader/PageLoader.tsx b/public/app/core/components/PageLoader/PageLoader.tsx
new file mode 100644
index 00000000000..dcb67dde220
--- /dev/null
+++ b/public/app/core/components/PageLoader/PageLoader.tsx
@@ -0,0 +1,17 @@
+import React, { SFC } from 'react';
+
+interface Props {
+ pageName: string;
+}
+
+const PageLoader: SFC = ({ pageName }) => {
+ const loadingText = `Loading ${pageName}...`;
+ return (
+
+ );
+};
+
+export default PageLoader;
diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx
index 77ac6953b74..71cc937ddfa 100644
--- a/public/app/core/components/PermissionList/AddPermission.tsx
+++ b/public/app/core/components/PermissionList/AddPermission.tsx
@@ -1,7 +1,8 @@
import React, { Component } from 'react';
-import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
+import { UserPicker } from 'app/core/components/Picker/UserPicker';
import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
+import { User } from 'app/types';
import {
dashboardPermissionLevels,
dashboardAclTargets,
@@ -17,6 +18,10 @@ export interface Props {
}
class AddPermissions extends Component {
+ static defaultProps = {
+ showPermissionLevels: true,
+ };
+
constructor(props) {
super(props);
this.state = this.getCleanState();
@@ -49,11 +54,11 @@ class AddPermissions extends Component {
};
onUserSelected = (user: User) => {
- this.setState({ userId: user ? user.id : 0 });
+ this.setState({ userId: user && !Array.isArray(user) ? user.id : 0 });
};
onTeamSelected = (team: Team) => {
- this.setState({ teamId: team ? team.id : 0 });
+ this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
};
onPermissionChanged = (permission: OptionWithDescription) => {
@@ -81,7 +86,6 @@ class AddPermissions extends Component {
const newItem = this.state;
const pickerClassName = 'width-20';
const isValid = this.isValid();
-
return (
@@ -106,21 +110,13 @@ class AddPermissions extends Component {
{newItem.type === AclTarget.User ? (
-
+
) : null}
{newItem.type === AclTarget.Team ? (
-
+
) : null}
@@ -128,9 +124,8 @@ class AddPermissions extends Component {
diff --git a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
index d648d06e414..ff679f67ae2 100644
--- a/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
+++ b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
@@ -26,9 +26,9 @@ export default class DisabledPermissionListItem extends Component {
{}}
- value={item.permission}
disabled={true}
- className={'gf-form-input--form-dropdown-right'}
+ className={'gf-form-select-box__control--menu-right'}
+ value={item.permission}
/>
diff --git a/public/app/core/components/PermissionList/PermissionListItem.tsx b/public/app/core/components/PermissionList/PermissionListItem.tsx
index b846f98a063..56b6114d236 100644
--- a/public/app/core/components/PermissionList/PermissionListItem.tsx
+++ b/public/app/core/components/PermissionList/PermissionListItem.tsx
@@ -77,9 +77,9 @@ export default class PermissionsListItem extends PureComponent {
diff --git a/public/app/core/components/Picker/DescriptionOption.tsx b/public/app/core/components/Picker/DescriptionOption.tsx
index 1bcb7100489..9ddf13f7532 100644
--- a/public/app/core/components/Picker/DescriptionOption.tsx
+++ b/public/app/core/components/Picker/DescriptionOption.tsx
@@ -1,56 +1,25 @@
-import React, { Component } from 'react';
+import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
-export interface Props {
- onSelect: any;
- onFocus: any;
- option: any;
- isFocused: any;
- className: any;
+// https://github.com/JedWatson/react-select/issues/3038
+interface ExtendedOptionProps extends OptionProps {
+ data: any;
}
-class DescriptionOption extends Component {
- constructor(props) {
- super(props);
- this.handleMouseDown = this.handleMouseDown.bind(this);
- this.handleMouseEnter = this.handleMouseEnter.bind(this);
- this.handleMouseMove = this.handleMouseMove.bind(this);
- }
-
- handleMouseDown(event) {
- event.preventDefault();
- event.stopPropagation();
- this.props.onSelect(this.props.option, event);
- }
-
- handleMouseEnter(event) {
- this.props.onFocus(this.props.option, event);
- }
-
- handleMouseMove(event) {
- if (this.props.isFocused) {
- return;
- }
- this.props.onFocus(this.props.option, event);
- }
-
- render() {
- const { option, children, className } = this.props;
- return (
-
+export const Option = (props: ExtendedOptionProps) => {
+ const { children, isSelected, data, className } = props;
+ return (
+
+
+ {isSelected &&
}
{children}
-
{option.description}
- {className.indexOf('is-selected') > -1 &&
}
+
{data.description}
-
- );
- }
-}
+
+
+ );
+};
-export default DescriptionOption;
+export default Option;
diff --git a/public/app/core/components/Picker/DescriptionPicker.tsx b/public/app/core/components/Picker/DescriptionPicker.tsx
index 2e53d096e08..a917e3977f5 100644
--- a/public/app/core/components/Picker/DescriptionPicker.tsx
+++ b/public/app/core/components/Picker/DescriptionPicker.tsx
@@ -1,14 +1,9 @@
import React, { Component } from 'react';
import Select from 'react-select';
import DescriptionOption from './DescriptionOption';
-
-export interface Props {
- optionsWithDesc: OptionWithDescription[];
- onSelected: (permission) => void;
- value: number;
- disabled: boolean;
- className?: string;
-}
+import IndicatorsContainer from './IndicatorsContainer';
+import ResetStyles from './ResetStyles';
+import NoOptionsMessage from './NoOptionsMessage';
export interface OptionWithDescription {
value: any;
@@ -16,29 +11,38 @@ export interface OptionWithDescription {
description: string;
}
+export interface Props {
+ optionsWithDesc: OptionWithDescription[];
+ onSelected: (permission) => void;
+ disabled: boolean;
+ className?: string;
+ value?: any;
+}
+
+const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
+
class DescriptionPicker extends Component {
- constructor(props) {
- super(props);
- this.state = {};
- }
-
render() {
- const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
-
+ const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
+ const selectedOption = getSelectedOption(optionsWithDesc, value);
return (
i.value}
+ getOptionLabel={i => i.label}
+ value={selectedOption}
/>
);
diff --git a/public/app/core/components/Picker/IndicatorsContainer.tsx b/public/app/core/components/Picker/IndicatorsContainer.tsx
new file mode 100644
index 00000000000..a0998fe95b8
--- /dev/null
+++ b/public/app/core/components/Picker/IndicatorsContainer.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { components } from 'react-select';
+
+export const IndicatorsContainer = props => {
+ const isOpen = props.selectProps.menuIsOpen;
+ return (
+
+
+
+ );
+};
+
+export default IndicatorsContainer;
diff --git a/public/app/core/components/Picker/NoOptionsMessage.tsx b/public/app/core/components/Picker/NoOptionsMessage.tsx
new file mode 100644
index 00000000000..1d2ad4a179e
--- /dev/null
+++ b/public/app/core/components/Picker/NoOptionsMessage.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
+
+export interface Props {
+ children: Element;
+}
+
+export const PickerOption = (props: OptionProps) => {
+ const { children, className } = props;
+ return (
+
+ {children}
+
+ );
+};
+
+export default PickerOption;
diff --git a/public/app/core/components/Picker/PickerOption.test.tsx b/public/app/core/components/Picker/PickerOption.test.tsx
index 2f46f3197c5..7ebcac354e8 100644
--- a/public/app/core/components/Picker/PickerOption.test.tsx
+++ b/public/app/core/components/Picker/PickerOption.test.tsx
@@ -3,10 +3,26 @@ import renderer from 'react-test-renderer';
import PickerOption from './PickerOption';
const model = {
- onSelect: () => {},
- onFocus: () => {},
- isFocused: () => {},
- option: {
+ cx: jest.fn(),
+ clearValue: jest.fn(),
+ onSelect: jest.fn(),
+ getStyles: jest.fn(),
+ getValue: jest.fn(),
+ hasValue: true,
+ isMulti: false,
+ options: [],
+ selectOption: jest.fn(),
+ selectProps: {},
+ setValue: jest.fn(),
+ isDisabled: false,
+ isFocused: false,
+ isSelected: false,
+ innerRef: null,
+ innerProps: null,
+ label: 'Option label',
+ type: null,
+ children: 'Model title',
+ data: {
title: 'Model title',
avatarUrl: 'url/to/avatar',
label: 'User picker label',
diff --git a/public/app/core/components/Picker/PickerOption.tsx b/public/app/core/components/Picker/PickerOption.tsx
index f30a7c06d10..98bb5dc566e 100644
--- a/public/app/core/components/Picker/PickerOption.tsx
+++ b/public/app/core/components/Picker/PickerOption.tsx
@@ -1,54 +1,22 @@
-import React, { Component } from 'react';
+import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
-export interface Props {
- onSelect: any;
- onFocus: any;
- option: any;
- isFocused: any;
- className: any;
+// https://github.com/JedWatson/react-select/issues/3038
+interface ExtendedOptionProps extends OptionProps {
+ data: any;
}
-class UserPickerOption extends Component {
- constructor(props) {
- super(props);
- this.handleMouseDown = this.handleMouseDown.bind(this);
- this.handleMouseEnter = this.handleMouseEnter.bind(this);
- this.handleMouseMove = this.handleMouseMove.bind(this);
- }
-
- handleMouseDown(event) {
- event.preventDefault();
- event.stopPropagation();
- this.props.onSelect(this.props.option, event);
- }
-
- handleMouseEnter(event) {
- this.props.onFocus(this.props.option, event);
- }
-
- handleMouseMove(event) {
- if (this.props.isFocused) {
- return;
- }
- this.props.onFocus(this.props.option, event);
- }
-
- render() {
- const { option, children, className } = this.props;
-
- return (
-
-
+export const PickerOption = (props: ExtendedOptionProps) => {
+ const { children, data, className } = props;
+ return (
+
+
+ {data.avatarUrl &&
}
{children}
-
- );
- }
-}
+
+
+ );
+};
-export default UserPickerOption;
+export default PickerOption;
diff --git a/public/app/core/components/Picker/ResetStyles.tsx b/public/app/core/components/Picker/ResetStyles.tsx
new file mode 100644
index 00000000000..2a235b2892a
--- /dev/null
+++ b/public/app/core/components/Picker/ResetStyles.tsx
@@ -0,0 +1,23 @@
+export default {
+ clearIndicator: () => ({}),
+ container: () => ({}),
+ control: () => ({}),
+ dropdownIndicator: () => ({}),
+ group: () => ({}),
+ groupHeading: () => ({}),
+ indicatorsContainer: () => ({}),
+ indicatorSeparator: () => ({}),
+ input: () => ({}),
+ loadingIndicator: () => ({}),
+ loadingMessage: () => ({}),
+ menu: () => ({}),
+ menuList: () => ({}),
+ multiValue: () => ({}),
+ multiValueLabel: () => ({}),
+ multiValueRemove: () => ({}),
+ noOptionsMessage: () => ({}),
+ option: () => ({}),
+ placeholder: () => ({}),
+ singleValue: () => ({}),
+ valueContainer: () => ({}),
+};
diff --git a/public/app/core/components/Picker/SimplePicker.tsx b/public/app/core/components/Picker/SimplePicker.tsx
new file mode 100644
index 00000000000..6c9e8aca199
--- /dev/null
+++ b/public/app/core/components/Picker/SimplePicker.tsx
@@ -0,0 +1,46 @@
+import React, { SFC } from 'react';
+import Select from 'react-select';
+import DescriptionOption from './DescriptionOption';
+import ResetStyles from './ResetStyles';
+
+interface Props {
+ className?: string;
+ defaultValue: any;
+ getOptionLabel: (item: any) => string;
+ getOptionValue: (item: any) => string;
+ onSelected: (item: any) => {} | void;
+ options: any[];
+ placeholder?: string;
+ width: number;
+}
+
+const SimplePicker: SFC = ({
+ className,
+ defaultValue,
+ getOptionLabel,
+ getOptionValue,
+ onSelected,
+ options,
+ placeholder,
+ width,
+}) => {
+ return (
+
+ );
+};
+
+export default SimplePicker;
diff --git a/public/app/core/components/Picker/TeamPicker.tsx b/public/app/core/components/Picker/TeamPicker.tsx
index a177a13c45b..f44717acd28 100644
--- a/public/app/core/components/Picker/TeamPicker.tsx
+++ b/public/app/core/components/Picker/TeamPicker.tsx
@@ -1,18 +1,11 @@
import React, { Component } from 'react';
-import Select from 'react-select';
+import AsyncSelect from 'react-select/lib/Async';
import PickerOption from './PickerOption';
import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
-
-export interface Props {
- onSelected: (team: Team) => void;
- value?: string;
- className?: string;
-}
-
-export interface State {
- isLoading;
-}
+import ResetStyles from './ResetStyles';
+import IndicatorsContainer from './IndicatorsContainer';
+import NoOptionsMessage from './NoOptionsMessage';
export interface Team {
id: number;
@@ -21,6 +14,15 @@ export interface Team {
avatarUrl: string;
}
+export interface Props {
+ onSelected: (team: Team) => void;
+ className?: string;
+}
+
+export interface State {
+ isLoading: boolean;
+}
+
export class TeamPicker extends Component {
debouncedSearch: any;
@@ -31,7 +33,7 @@ export class TeamPicker extends Component {
this.debouncedSearch = debounce(this.search, 300, {
leading: true,
- trailing: false,
+ trailing: true,
});
}
@@ -39,7 +41,7 @@ export class TeamPicker extends Component {
const backendSrv = getBackendSrv();
this.setState({ isLoading: true });
- return backendSrv.get(`/api/teams/search?perpage=50&page=1&query=${query}`).then(result => {
+ return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
const teams = result.teams.map(team => {
return {
id: team.id,
@@ -50,31 +52,34 @@ export class TeamPicker extends Component {
});
this.setState({ isLoading: false });
- return { options: teams };
+ return teams;
});
}
render() {
- const { onSelected, value, className } = this.props;
+ const { onSelected, className } = this.props;
const { isLoading } = this.state;
-
return (
- 'Loading...'}
+ noOptionsMessage={() => 'No teams found'}
+ getOptionValue={i => i.id}
+ getOptionLabel={i => i.label}
/>
);
diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx
index e50513c44e1..f78cf69bf5e 100644
--- a/public/app/core/components/Picker/UserPicker.tsx
+++ b/public/app/core/components/Picker/UserPicker.tsx
@@ -1,12 +1,15 @@
import React, { Component } from 'react';
-import Select from 'react-select';
+import AsyncSelect from 'react-select/lib/Async';
import PickerOption from './PickerOption';
import { debounce } from 'lodash';
import { getBackendSrv } from 'app/core/services/backend_srv';
+import { User } from 'app/types';
+import ResetStyles from './ResetStyles';
+import IndicatorsContainer from './IndicatorsContainer';
+import NoOptionsMessage from './NoOptionsMessage';
export interface Props {
onSelected: (user: User) => void;
- value?: string;
className?: string;
}
@@ -14,13 +17,6 @@ export interface State {
isLoading: boolean;
}
-export interface User {
- id: number;
- label: string;
- avatarUrl: string;
- login: string;
-}
-
export class UserPicker extends Component {
debouncedSearch: any;
@@ -37,20 +33,17 @@ export class UserPicker extends Component {
search(query?: string) {
const backendSrv = getBackendSrv();
-
this.setState({ isLoading: true });
return backendSrv
.get(`/api/org/users?query=${query}&limit=10`)
.then(result => {
- return {
- options: result.map(user => ({
- id: user.userId,
- label: `${user.login} - ${user.email}`,
- avatarUrl: user.avatarUrl,
- login: user.login,
- })),
- };
+ return result.map(user => ({
+ id: user.userId,
+ label: `${user.login} - ${user.email}`,
+ avatarUrl: user.avatarUrl,
+ login: user.login,
+ }));
})
.finally(() => {
this.setState({ isLoading: false });
@@ -58,26 +51,30 @@ export class UserPicker extends Component {
}
render() {
- const { value, className } = this.props;
+ const { className, onSelected } = this.props;
const { isLoading } = this.state;
return (
- 'Loading...'}
+ noOptionsMessage={() => 'No users found'}
+ getOptionValue={i => i.id}
+ getOptionLabel={i => i.label}
/>
);
diff --git a/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap b/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap
index 764f7844cb7..748fcbee4aa 100644
--- a/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap
+++ b/public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap
@@ -1,17 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PickerOption renders correctly 1`] = `
-
-
-
+
+
+
+ Model title
+
+
`;
diff --git a/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap b/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap
index 9c21da5fdc4..011811f21ad 100644
--- a/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap
+++ b/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap
@@ -5,85 +5,115 @@ exports[`TeamPicker renders correctly 1`] = `
className="user-picker"
>
- Loading...
+ Select a team
-
-
-
-
-
+
diff --git a/public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap b/public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap
index 86b92213183..3e23bd435b1 100644
--- a/public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap
+++ b/public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap
@@ -5,85 +5,115 @@ exports[`UserPicker renders correctly 1`] = `
className="user-picker"
>
- Loading...
+ Select user
-
-
-
-
-
+
diff --git a/public/app/core/components/Switch/Switch.tsx b/public/app/core/components/Switch/Switch.tsx
new file mode 100644
index 00000000000..ba09267ebd2
--- /dev/null
+++ b/public/app/core/components/Switch/Switch.tsx
@@ -0,0 +1,46 @@
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+
+export interface Props {
+ label: string;
+ checked: boolean;
+ labelClass?: string;
+ switchClass?: string;
+ onChange: (event) => any;
+}
+
+export interface State {
+ id: any;
+}
+
+export class Switch extends PureComponent {
+ state = {
+ id: _.uniqueId(),
+ };
+
+ internalOnChange = event => {
+ event.stopPropagation();
+ this.props.onChange(event);
+ };
+
+ render() {
+ const { labelClass, switchClass, label, checked } = this.props;
+ const labelId = `check-${this.state.id}`;
+ const labelClassName = `gf-form-label ${labelClass} pointer`;
+ const switchClassName = `gf-form-switch ${switchClass}`;
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+
+
+
+ );
+ }
+}
diff --git a/public/app/core/components/TagFilter/TagBadge.tsx b/public/app/core/components/TagFilter/TagBadge.tsx
index d93b5fd1e74..b00b95b70cb 100644
--- a/public/app/core/components/TagFilter/TagBadge.tsx
+++ b/public/app/core/components/TagFilter/TagBadge.tsx
@@ -5,17 +5,12 @@ export interface Props {
label: string;
removeIcon: boolean;
count: number;
- onClick: any;
+ onClick?: any;
}
export class TagBadge extends React.Component {
constructor(props) {
super(props);
- this.onClick = this.onClick.bind(this);
- }
-
- onClick(event) {
- this.props.onClick(event);
}
render() {
@@ -28,7 +23,7 @@ export class TagBadge extends React.Component {
const countLabel = count !== 0 && {`(${count})`} ;
return (
-
+
{removeIcon && }
{label} {countLabel}
diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx
index a879f544da0..14b6ef4932b 100644
--- a/public/app/core/components/TagFilter/TagFilter.tsx
+++ b/public/app/core/components/TagFilter/TagFilter.tsx
@@ -1,8 +1,11 @@
-import _ from 'lodash';
import React from 'react';
-import { Async } from 'react-select';
-import { TagValue } from './TagValue';
+import AsyncSelect from 'react-select/lib/Async';
import { TagOption } from './TagOption';
+import { TagBadge } from './TagBadge';
+import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
+import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
+import { components } from 'react-select';
+import ResetStyles from 'app/core/components/Picker/ResetStyles';
export interface Props {
tags: string[];
@@ -18,15 +21,15 @@ export class TagFilter extends React.Component {
this.searchTags = this.searchTags.bind(this);
this.onChange = this.onChange.bind(this);
- this.onTagRemove = this.onTagRemove.bind(this);
}
searchTags(query) {
return this.props.tagOptions().then(options => {
- const tags = _.map(options, tagOption => {
- return { value: tagOption.term, label: tagOption.term, count: tagOption.count };
- });
- return { options: tags };
+ return options.map(option => ({
+ value: option.term,
+ label: option.term,
+ count: option.count,
+ }));
});
}
@@ -34,33 +37,44 @@ export class TagFilter extends React.Component {
this.props.onSelect(newTags);
}
- onTagRemove(tag) {
- let newTags = _.without(this.props.tags, tag.label);
- newTags = _.map(newTags, tag => {
- return { value: tag };
- });
- this.props.onSelect(newTags);
- }
-
render() {
const selectOptions = {
+ classNamePrefix: 'gf-form-select-box',
+ isMulti: true,
+ defaultOptions: true,
loadOptions: this.searchTags,
onChange: this.onChange,
- value: this.props.tags,
- multi: true,
className: 'gf-form-input gf-form-input--form-dropdown',
placeholder: 'Tags',
- loadingPlaceholder: 'Loading...',
- noResultsText: 'No tags found',
- optionComponent: TagOption,
- };
+ loadingMessage: () => 'Loading...',
+ noOptionsMessage: () => 'No tags found',
+ getOptionValue: i => i.value,
+ getOptionLabel: i => i.label,
+ value: this.props.tags,
+ styles: ResetStyles,
+ components: {
+ Option: TagOption,
+ IndicatorsContainer,
+ NoOptionsMessage,
+ MultiValueLabel: () => {
+ return null; // We want the whole tag to be clickable so we use MultiValueRemove instead
+ },
+ MultiValueRemove: props => {
+ const { data } = props;
- selectOptions['valueComponent'] = TagValue;
+ return (
+
+
+
+ );
+ },
+ },
+ };
return (
diff --git a/public/app/core/components/TagFilter/TagOption.tsx b/public/app/core/components/TagFilter/TagOption.tsx
index 5938c98f870..e4e91f93c00 100644
--- a/public/app/core/components/TagFilter/TagOption.tsx
+++ b/public/app/core/components/TagFilter/TagOption.tsx
@@ -1,52 +1,22 @@
import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
import { TagBadge } from './TagBadge';
-export interface Props {
- onSelect: any;
- onFocus: any;
- option: any;
- isFocused: any;
- className: any;
+// https://github.com/JedWatson/react-select/issues/3038
+interface ExtendedOptionProps extends OptionProps {
+ data: any;
}
-export class TagOption extends React.Component {
- constructor(props) {
- super(props);
- this.handleMouseDown = this.handleMouseDown.bind(this);
- this.handleMouseEnter = this.handleMouseEnter.bind(this);
- this.handleMouseMove = this.handleMouseMove.bind(this);
- }
+export const TagOption = (props: ExtendedOptionProps) => {
+ const { data, className, label } = props;
+ return (
+
+
+
+
+
+ );
+};
- handleMouseDown(event) {
- event.preventDefault();
- event.stopPropagation();
- this.props.onSelect(this.props.option, event);
- }
-
- handleMouseEnter(event) {
- this.props.onFocus(this.props.option, event);
- }
-
- handleMouseMove(event) {
- if (this.props.isFocused) {
- return;
- }
- this.props.onFocus(this.props.option, event);
- }
-
- render() {
- const { option, className } = this.props;
-
- return (
-
-
-
- );
- }
-}
+export default TagOption;
diff --git a/public/app/core/components/TagFilter/TagValue.tsx b/public/app/core/components/TagFilter/TagValue.tsx
index ca8ca9e4fba..43e41c7fdc2 100644
--- a/public/app/core/components/TagFilter/TagValue.tsx
+++ b/public/app/core/components/TagFilter/TagValue.tsx
@@ -21,6 +21,6 @@ export class TagValue extends React.Component {
render() {
const { value } = this.props;
- return ;
+ return ;
}
}
diff --git a/public/app/core/components/Tooltip/Tooltip.tsx b/public/app/core/components/Tooltip/Tooltip.tsx
index a265c8487d3..62be658f7cf 100644
--- a/public/app/core/components/Tooltip/Tooltip.tsx
+++ b/public/app/core/components/Tooltip/Tooltip.tsx
@@ -1,37 +1,28 @@
-import React from 'react';
+import React, { PureComponent } from 'react';
import withTooltip from './withTooltip';
import { Target } from 'react-popper';
-interface TooltipProps {
+interface Props {
tooltipSetState: (prevState: object) => void;
}
-class Tooltip extends React.Component {
- constructor(props) {
- super(props);
- this.showTooltip = this.showTooltip.bind(this);
- this.hideTooltip = this.hideTooltip.bind(this);
- }
-
- showTooltip() {
+class Tooltip extends PureComponent {
+ showTooltip = () => {
const { tooltipSetState } = this.props;
- tooltipSetState(prevState => {
- return {
- ...prevState,
- show: true,
- };
- });
- }
- hideTooltip() {
+ tooltipSetState(prevState => ({
+ ...prevState,
+ show: true,
+ }));
+ };
+
+ hideTooltip = () => {
const { tooltipSetState } = this.props;
- tooltipSetState(prevState => {
- return {
- ...prevState,
- show: false,
- };
- });
- }
+ tooltipSetState(prevState => ({
+ ...prevState,
+ show: false,
+ }));
+ };
render() {
return (
diff --git a/public/app/core/components/colorpicker/SeriesColorPicker.tsx b/public/app/core/components/colorpicker/SeriesColorPicker.tsx
index b514899e2e2..d6feaa31965 100644
--- a/public/app/core/components/colorpicker/SeriesColorPicker.tsx
+++ b/public/app/core/components/colorpicker/SeriesColorPicker.tsx
@@ -1,53 +1,84 @@
import React from 'react';
-import { ColorPickerPopover } from './ColorPickerPopover';
-import { react2AngularDirective } from 'app/core/utils/react2angular';
+import ReactDOM from 'react-dom';
+import Drop from 'tether-drop';
+import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
-export interface Props {
- series: any;
- onColorChange: (color: string) => void;
- onToggleAxis: () => void;
+export interface SeriesColorPickerProps {
+ color: string;
+ yaxis?: number;
+ optionalClass?: string;
+ onColorChange: (newColor: string) => void;
+ onToggleAxis?: () => void;
}
-export class SeriesColorPicker extends React.Component {
+export class SeriesColorPicker extends React.Component {
+ pickerElem: any;
+ colorPickerDrop: any;
+
+ static defaultProps = {
+ optionalClass: '',
+ yaxis: undefined,
+ onToggleAxis: () => {},
+ };
+
constructor(props) {
super(props);
- this.onColorChange = this.onColorChange.bind(this);
- this.onToggleAxis = this.onToggleAxis.bind(this);
}
- onColorChange(color) {
- this.props.onColorChange(color);
+ componentWillUnmount() {
+ this.destroyDrop();
}
- onToggleAxis() {
- this.props.onToggleAxis();
- }
+ onClickToOpen = () => {
+ if (this.colorPickerDrop) {
+ this.destroyDrop();
+ }
- renderAxisSelection() {
- const leftButtonClass = this.props.series.yaxis === 1 ? 'btn-success' : 'btn-inverse';
- const rightButtonClass = this.props.series.yaxis === 2 ? 'btn-success' : 'btn-inverse';
-
- return (
-
- Y Axis:
-
- Left
-
-
- Right
-
-
+ const { color, yaxis, onColorChange, onToggleAxis } = this.props;
+ const dropContent = (
+
);
+ const dropContentElem = document.createElement('div');
+ ReactDOM.render(dropContent, dropContentElem);
+
+ const drop = new Drop({
+ target: this.pickerElem,
+ content: dropContentElem,
+ position: 'top center',
+ classes: 'drop-popover',
+ openOn: 'hover',
+ hoverCloseDelay: 200,
+ remove: true,
+ tetherOptions: {
+ constraints: [{ to: 'scrollParent', attachment: 'none both' }],
+ },
+ });
+
+ drop.on('close', this.closeColorPicker.bind(this));
+
+ this.colorPickerDrop = drop;
+ this.colorPickerDrop.open();
+ };
+
+ closeColorPicker() {
+ setTimeout(() => {
+ this.destroyDrop();
+ }, 100);
+ }
+
+ destroyDrop() {
+ if (this.colorPickerDrop && this.colorPickerDrop.tether) {
+ this.colorPickerDrop.destroy();
+ this.colorPickerDrop = null;
+ }
}
render() {
+ const { optionalClass, children } = this.props;
return (
-
- {this.props.series.yaxis && this.renderAxisSelection()}
-
+
(this.pickerElem = e)} onClick={this.onClickToOpen}>
+ {children}
);
}
}
-
-react2AngularDirective('seriesColorPicker', SeriesColorPicker, ['series', 'onColorChange', 'onToggleAxis']);
diff --git a/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx b/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx
new file mode 100644
index 00000000000..085d554300d
--- /dev/null
+++ b/public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { ColorPickerPopover } from './ColorPickerPopover';
+import { react2AngularDirective } from 'app/core/utils/react2angular';
+
+export interface SeriesColorPickerPopoverProps {
+ color: string;
+ yaxis?: number;
+ onColorChange: (color: string) => void;
+ onToggleAxis?: () => void;
+}
+
+export class SeriesColorPickerPopover extends React.PureComponent
{
+ render() {
+ return (
+
+ {this.props.yaxis &&
}
+
+
+ );
+ }
+}
+
+interface AxisSelectorProps {
+ yaxis: number;
+ onToggleAxis: () => void;
+}
+
+interface AxisSelectorState {
+ yaxis: number;
+}
+
+export class AxisSelector extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ yaxis: this.props.yaxis,
+ };
+ this.onToggleAxis = this.onToggleAxis.bind(this);
+ }
+
+ onToggleAxis() {
+ this.setState({
+ yaxis: this.state.yaxis === 2 ? 1 : 2,
+ });
+ this.props.onToggleAxis();
+ }
+
+ render() {
+ const leftButtonClass = this.state.yaxis === 1 ? 'btn-success' : 'btn-inverse';
+ const rightButtonClass = this.state.yaxis === 2 ? 'btn-success' : 'btn-inverse';
+
+ return (
+
+ Y Axis:
+
+ Left
+
+
+ Right
+
+
+ );
+ }
+}
+
+react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
+ 'series',
+ 'onColorChange',
+ 'onToggleAxis',
+]);
diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts
index eac47b6e0a2..8e8a5ed45d2 100644
--- a/public/app/core/components/help/help.ts
+++ b/public/app/core/components/help/help.ts
@@ -34,6 +34,7 @@ export class HelpCtrl {
{ keys: ['p', 's'], description: 'Open Panel Share Modal' },
{ keys: ['p', 'd'], description: 'Duplicate Panel' },
{ keys: ['p', 'r'], description: 'Remove Panel' },
+ { keys: ['p', 'l'], description: 'Toggle panel legend' },
],
'Time Range': [
{ keys: ['t', 'z'], description: 'Zoom out time range' },
diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts
index da3fa2f8ab8..25a69b1f5e4 100644
--- a/public/app/core/components/manage_dashboards/manage_dashboards.ts
+++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts
@@ -207,7 +207,7 @@ export class ManageDashboardsCtrl {
const template =
'' +
- ' `';
+ '';
appEvents.emit('show-modal', {
templateHtml: template,
modalClass: 'modal--narrow',
diff --git a/public/app/core/components/query_part/query_part_editor.ts b/public/app/core/components/query_part/query_part_editor.ts
index 6181d020471..2cab966ed46 100644
--- a/public/app/core/components/query_part/query_part_editor.ts
+++ b/public/app/core/components/query_part/query_part_editor.ts
@@ -103,7 +103,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
$scope.$apply(() => {
$scope.handleEvent({ $event: { name: 'get-param-options' } }).then(result => {
const dynamicOptions = _.map(result, op => {
- return op.value;
+ return _.escape(op.value);
});
callback(dynamicOptions);
});
@@ -117,6 +117,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
minLength: 0,
items: 1000,
updater: value => {
+ value = _.unescape(value);
setTimeout(() => {
inputBlur.call($input[0], paramIndex);
}, 0);
diff --git a/public/app/core/components/scroll/scroll.ts b/public/app/core/components/scroll/scroll.ts
index bd355817f92..49931ecaac4 100644
--- a/public/app/core/components/scroll/scroll.ts
+++ b/public/app/core/components/scroll/scroll.ts
@@ -18,6 +18,7 @@ export function geminiScrollbar() {
let scrollRoot = elem.parent();
const scroller = elem;
+ console.log('scroll');
if (attrs.grafanaScrollbar && attrs.grafanaScrollbar === 'scrollonroot') {
scrollRoot = scroller;
}
diff --git a/public/app/core/components/search/search.ts b/public/app/core/components/search/search.ts
index e347fcd829a..322dd2bdf10 100644
--- a/public/app/core/components/search/search.ts
+++ b/public/app/core/components/search/search.ts
@@ -160,8 +160,12 @@ export class SearchCtrl {
searchDashboards() {
this.currentSearchId = this.currentSearchId + 1;
const localSearchId = this.currentSearchId;
+ const query = {
+ ...this.query,
+ tag: this.query.tag.map(i => i.value),
+ };
- return this.searchSrv.search(this.query).then(results => {
+ return this.searchSrv.search(query).then(results => {
if (localSearchId < this.currentSearchId) {
return;
}
@@ -196,7 +200,7 @@ export class SearchCtrl {
}
onTagSelect(newTags) {
- this.query.tag = _.map(newTags, tag => tag.value);
+ this.query.tag = newTags;
this.search();
}
diff --git a/public/app/core/components/sidemenu/SideMenu.tsx b/public/app/core/components/sidemenu/SideMenu.tsx
index 0092c1e3842..fd3e0d95564 100644
--- a/public/app/core/components/sidemenu/SideMenu.tsx
+++ b/public/app/core/components/sidemenu/SideMenu.tsx
@@ -17,7 +17,7 @@ export class SideMenu extends PureComponent {
render() {
return [
-
+
,
diff --git a/public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
index 6e3c474d245..ec2fa845c6d 100644
--- a/public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
+++ b/public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
@@ -8,7 +8,7 @@ Array [
onClick={[Function]}
>
,
diff --git a/public/app/core/components/sql_part/sql_part_editor.ts b/public/app/core/components/sql_part/sql_part_editor.ts
index 8097dddeb3b..1d29c577560 100644
--- a/public/app/core/components/sql_part/sql_part_editor.ts
+++ b/public/app/core/components/sql_part/sql_part_editor.ts
@@ -109,12 +109,12 @@ export function sqlPartEditorDirective($compile, templateSrv) {
$scope.$apply(() => {
$scope.handleEvent({ $event: { name: 'get-param-options', param: param } }).then(result => {
const dynamicOptions = _.map(result, op => {
- return op.value;
+ return _.escape(op.value);
});
// add current value to dropdown if it's not in dynamicOptions
if (_.indexOf(dynamicOptions, part.params[paramIndex]) === -1) {
- dynamicOptions.unshift(part.params[paramIndex]);
+ dynamicOptions.unshift(_.escape(part.params[paramIndex]));
}
callback(dynamicOptions);
@@ -129,6 +129,7 @@ export function sqlPartEditorDirective($compile, templateSrv) {
minLength: 0,
items: 1000,
updater: value => {
+ value = _.unescape(value);
if (value === part.params[paramIndex]) {
clearTimeout(cancelBlur);
$input.focus();
diff --git a/public/app/core/config.ts b/public/app/core/config.ts
index bf5abe37d7f..1473f8a91f8 100644
--- a/public/app/core/config.ts
+++ b/public/app/core/config.ts
@@ -1,4 +1,5 @@
import _ from 'lodash';
+import { PanelPlugin } from 'app/types/plugins';
export interface BuildInfo {
version: string;
@@ -9,7 +10,7 @@ export interface BuildInfo {
export class Settings {
datasources: any;
- panels: any;
+ panels: PanelPlugin[];
appSubUrl: string;
windowTitlePrefix: string;
buildInfo: BuildInfo;
diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts
index 2642c5e400a..00981156614 100644
--- a/public/app/core/constants.ts
+++ b/public/app/core/constants.ts
@@ -8,3 +8,6 @@ export const DEFAULT_ROW_HEIGHT = 250;
export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3;
export const LS_PANEL_COPY_KEY = 'panel-copy';
+
+export const DASHBOARD_TOOLBAR_HEIGHT = 55;
+export const DASHBOARD_TOP_PADDING = 20;
diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts
new file mode 100644
index 00000000000..c34480d7aad
--- /dev/null
+++ b/public/app/core/copy/appNotification.ts
@@ -0,0 +1,46 @@
+import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types';
+
+const defaultSuccessNotification: AppNotification = {
+ title: '',
+ text: '',
+ severity: AppNotificationSeverity.Success,
+ icon: 'fa fa-check',
+ timeout: AppNotificationTimeout.Success,
+};
+
+const defaultWarningNotification: AppNotification = {
+ title: '',
+ text: '',
+ severity: AppNotificationSeverity.Warning,
+ icon: 'fa fa-exclamation',
+ timeout: AppNotificationTimeout.Warning,
+};
+
+const defaultErrorNotification: AppNotification = {
+ title: '',
+ text: '',
+ severity: AppNotificationSeverity.Error,
+ icon: 'fa fa-exclamation-triangle',
+ timeout: AppNotificationTimeout.Error,
+};
+
+export const createSuccessNotification = (title: string, text?: string): AppNotification => ({
+ ...defaultSuccessNotification,
+ title: title,
+ text: text,
+ id: Date.now(),
+});
+
+export const createErrorNotification = (title: string, text?: string): AppNotification => ({
+ ...defaultErrorNotification,
+ title: title,
+ text: text,
+ id: Date.now(),
+});
+
+export const createWarningNotification = (title: string, text?: string): AppNotification => ({
+ ...defaultWarningNotification,
+ title: title,
+ text: text,
+ id: Date.now(),
+});
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index 173d6b80b15..257a2077c97 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -14,12 +14,11 @@ import './components/jsontree/jsontree';
import './components/code_editor/code_editor';
import './utils/outline';
import './components/colorpicker/ColorPicker';
-import './components/colorpicker/SeriesColorPicker';
+import './components/colorpicker/SeriesColorPickerPopover';
import './components/colorpicker/spectrum_picker';
import './services/search_srv';
import './services/ng_react';
-import { grafanaAppDirective } from './components/grafana_app';
import { searchDirective } from './components/search/search';
import { infoPopover } from './components/info_popover';
import { navbarDirective } from './components/navbar/navbar';
@@ -60,7 +59,6 @@ export {
registerAngularDirectives,
arrayJoin,
coreModule,
- grafanaAppDirective,
navbarDirective,
searchDirective,
liveSrv,
diff --git a/public/app/core/core_module.ts b/public/app/core/core_module.ts
index f6c30e6cf15..c8401975c18 100644
--- a/public/app/core/core_module.ts
+++ b/public/app/core/core_module.ts
@@ -1,2 +1,18 @@
import angular from 'angular';
-export default angular.module('grafana.core', ['ngRoute']);
+
+const coreModule = angular.module('grafana.core', ['ngRoute']);
+
+// legacy modules
+const angularModules = [
+ coreModule,
+ angular.module('grafana.controllers', []),
+ angular.module('grafana.directives', []),
+ angular.module('grafana.factories', []),
+ angular.module('grafana.services', []),
+ angular.module('grafana.filters', []),
+ angular.module('grafana.routes', []),
+];
+
+export { angularModules, coreModule };
+
+export default coreModule;
diff --git a/public/app/core/directives/dash_class.ts b/public/app/core/directives/dash_class.ts
index 224bc2c772d..37124eb7d4b 100644
--- a/public/app/core/directives/dash_class.ts
+++ b/public/app/core/directives/dash_class.ts
@@ -2,16 +2,21 @@ import _ from 'lodash';
import coreModule from '../core_module';
/** @ngInject */
-export function dashClass() {
+function dashClass($timeout) {
return {
link: ($scope, elem) => {
- $scope.onAppEvent('panel-fullscreen-enter', () => {
- elem.toggleClass('panel-in-fullscreen', true);
+ $scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
+ console.log('view-mode-changed', panel.fullscreen);
+ if (panel.fullscreen) {
+ elem.addClass('panel-in-fullscreen');
+ } else {
+ $timeout(() => {
+ elem.removeClass('panel-in-fullscreen');
+ });
+ }
});
- $scope.onAppEvent('panel-fullscreen-exit', () => {
- elem.toggleClass('panel-in-fullscreen', false);
- });
+ elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
$scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
if (newValue) {
diff --git a/public/app/core/directives/metric_segment.ts b/public/app/core/directives/metric_segment.ts
index de904e95fc6..85576dcffee 100644
--- a/public/app/core/directives/metric_segment.ts
+++ b/public/app/core/directives/metric_segment.ts
@@ -3,7 +3,7 @@ import $ from 'jquery';
import coreModule from '../core_module';
/** @ngInject */
-export function metricSegment($compile, $sce) {
+export function metricSegment($compile, $sce, templateSrv) {
const inputTemplate =
' {
const selected = _.find($scope.altSegments, { value: value });
if (selected) {
segment.value = selected.value;
- segment.html = selected.html || selected.value;
+ segment.html = selected.html || $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(selected.value));
segment.fake = false;
segment.expandable = selected.expandable;
@@ -56,7 +54,7 @@ export function metricSegment($compile, $sce) {
}
} else if (segment.custom !== 'false') {
segment.value = value;
- segment.html = $sce.trustAsHtml(value);
+ segment.html = $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(value));
segment.expandable = true;
segment.fake = false;
}
@@ -95,7 +93,7 @@ export function metricSegment($compile, $sce) {
// add custom values
if (segment.custom !== 'false') {
if (!segment.fake && _.indexOf(options, segment.value) === -1) {
- options.unshift(segment.value);
+ options.unshift(_.escape(segment.value));
}
}
@@ -105,6 +103,7 @@ export function metricSegment($compile, $sce) {
};
$scope.updater = value => {
+ value = _.unescape(value);
if (value === segment.value) {
clearTimeout(cancelBlur);
$input.focus();
diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts
index 46e95a471ce..e6f317dbeb7 100644
--- a/public/app/core/logs_model.ts
+++ b/public/app/core/logs_model.ts
@@ -1,3 +1,5 @@
+import _ from 'lodash';
+
export enum LogLevel {
crit = 'crit',
warn = 'warn',
@@ -11,7 +13,7 @@ export enum LogLevel {
export interface LogSearchMatch {
start: number;
length: number;
- text?: string;
+ text: string;
}
export interface LogRow {
@@ -21,9 +23,21 @@ export interface LogRow {
timestamp: string;
timeFromNow: string;
timeLocal: string;
- searchMatches?: LogSearchMatch[];
+ searchWords?: string[];
}
export interface LogsModel {
rows: LogRow[];
}
+
+export function mergeStreams(streams: LogsModel[], limit?: number): LogsModel {
+ const combinedEntries = streams.reduce((acc, stream) => {
+ return [...acc, ...stream.rows];
+ }, []);
+ const sortedEntries = _.chain(combinedEntries)
+ .sortBy('timestamp')
+ .reverse()
+ .slice(0, limit || combinedEntries.length)
+ .value();
+ return { rows: sortedEntries };
+}
diff --git a/public/app/core/reducers/appNotification.test.ts b/public/app/core/reducers/appNotification.test.ts
new file mode 100644
index 00000000000..183b699f5fc
--- /dev/null
+++ b/public/app/core/reducers/appNotification.test.ts
@@ -0,0 +1,51 @@
+import { appNotificationsReducer } from './appNotification';
+import { ActionTypes } from '../actions/appNotification';
+import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/';
+
+describe('clear alert', () => {
+ it('should filter alert', () => {
+ const id1 = 1540301236048;
+ const id2 = 1540301248293;
+
+ const initialState = {
+ appNotifications: [
+ {
+ id: id1,
+ severity: AppNotificationSeverity.Success,
+ icon: 'success',
+ title: 'test',
+ text: 'test alert',
+ timeout: AppNotificationTimeout.Success,
+ },
+ {
+ id: id2,
+ severity: AppNotificationSeverity.Warning,
+ icon: 'warning',
+ title: 'test2',
+ text: 'test alert fail 2',
+ timeout: AppNotificationTimeout.Warning,
+ },
+ ],
+ };
+
+ const result = appNotificationsReducer(initialState, {
+ type: ActionTypes.ClearAppNotification,
+ payload: id2,
+ });
+
+ const expectedResult = {
+ appNotifications: [
+ {
+ id: id1,
+ severity: AppNotificationSeverity.Success,
+ icon: 'success',
+ title: 'test',
+ text: 'test alert',
+ timeout: AppNotificationTimeout.Success,
+ },
+ ],
+ };
+
+ expect(result).toEqual(expectedResult);
+ });
+});
diff --git a/public/app/core/reducers/appNotification.ts b/public/app/core/reducers/appNotification.ts
new file mode 100644
index 00000000000..2c8bbbbd84d
--- /dev/null
+++ b/public/app/core/reducers/appNotification.ts
@@ -0,0 +1,19 @@
+import { AppNotification, AppNotificationsState } from 'app/types/';
+import { Action, ActionTypes } from '../actions/appNotification';
+
+export const initialState: AppNotificationsState = {
+ appNotifications: [] as AppNotification[],
+};
+
+export const appNotificationsReducer = (state = initialState, action: Action): AppNotificationsState => {
+ switch (action.type) {
+ case ActionTypes.AddAppNotification:
+ return { ...state, appNotifications: state.appNotifications.concat([action.payload]) };
+ case ActionTypes.ClearAppNotification:
+ return {
+ ...state,
+ appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload),
+ };
+ }
+ return state;
+};
diff --git a/public/app/core/reducers/index.ts b/public/app/core/reducers/index.ts
index be13528c91c..6455849ab46 100644
--- a/public/app/core/reducers/index.ts
+++ b/public/app/core/reducers/index.ts
@@ -1,7 +1,11 @@
import { navIndexReducer as navIndex } from './navModel';
import { locationReducer as location } from './location';
+import { appNotificationsReducer as appNotifications } from './appNotification';
+import { userReducer as user } from './user';
export default {
navIndex,
location,
+ appNotifications,
+ user,
};
diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts
index 6a356c4ea5a..7c7dffd04b9 100644
--- a/public/app/core/reducers/location.ts
+++ b/public/app/core/reducers/location.ts
@@ -1,6 +1,7 @@
import { Action } from 'app/core/actions/location';
-import { LocationState, UrlQueryMap } from 'app/types';
-import { toUrlParams } from 'app/core/utils/url';
+import { LocationState } from 'app/types';
+import { renderUrl } from 'app/core/utils/url';
+import _ from 'lodash';
export const initialState: LocationState = {
url: '',
@@ -9,21 +10,20 @@ export const initialState: LocationState = {
routeParams: {},
};
-function renderUrl(path: string, query: UrlQueryMap | undefined): string {
- if (query && Object.keys(query).length > 0) {
- path += '?' + toUrlParams(query);
- }
- return path;
-}
-
export const locationReducer = (state = initialState, action: Action): LocationState => {
switch (action.type) {
case 'UPDATE_LOCATION': {
- const { path, query, routeParams } = action.payload;
+ const { path, routeParams } = action.payload;
+ let query = action.payload.query || state.query;
+
+ if (action.payload.partial) {
+ query = _.defaults(query, state.query);
+ }
+
return {
url: renderUrl(path || state.path, query),
path: path || state.path,
- query: query || state.query,
+ query: query,
routeParams: routeParams || state.routeParams,
};
}
diff --git a/public/app/core/reducers/user.ts b/public/app/core/reducers/user.ts
new file mode 100644
index 00000000000..d49395060ce
--- /dev/null
+++ b/public/app/core/reducers/user.ts
@@ -0,0 +1,15 @@
+import { DashboardSearchHit, UserState } from '../../types';
+import { Action, ActionTypes } from '../actions/user';
+
+const initialState: UserState = {
+ starredDashboards: [] as DashboardSearchHit[],
+};
+
+export const userReducer = (state: UserState = initialState, action: Action): UserState => {
+ switch (action.type) {
+ case ActionTypes.LoadStarredDashboards:
+ return { ...state, starredDashboards: action.payload };
+ }
+
+ return state;
+};
diff --git a/public/app/core/services/AngularLoader.ts b/public/app/core/services/AngularLoader.ts
new file mode 100644
index 00000000000..e3a7dec4351
--- /dev/null
+++ b/public/app/core/services/AngularLoader.ts
@@ -0,0 +1,42 @@
+import angular from 'angular';
+import coreModule from 'app/core/core_module';
+import _ from 'lodash';
+
+export interface AngularComponent {
+ destroy();
+}
+
+export class AngularLoader {
+ /** @ngInject */
+ constructor(private $compile, private $rootScope) {}
+
+ load(elem, scopeProps, template): AngularComponent {
+ const scope = this.$rootScope.$new();
+
+ _.assign(scope, scopeProps);
+
+ const compiledElem = this.$compile(template)(scope);
+ const rootNode = angular.element(elem);
+ rootNode.append(compiledElem);
+
+ return {
+ destroy: () => {
+ scope.$destroy();
+ compiledElem.remove();
+ },
+ };
+ }
+}
+
+coreModule.service('angularLoader', AngularLoader);
+
+let angularLoaderInstance: AngularLoader;
+
+export function setAngularLoader(pl: AngularLoader) {
+ angularLoaderInstance = pl;
+}
+
+// away to access it from react
+export function getAngularLoader(): AngularLoader {
+ return angularLoaderInstance;
+}
diff --git a/public/app/core/services/alert_srv.ts b/public/app/core/services/alert_srv.ts
index 2d447651b75..4995b148abd 100644
--- a/public/app/core/services/alert_srv.ts
+++ b/public/app/core/services/alert_srv.ts
@@ -1,100 +1,12 @@
-import angular from 'angular';
-import _ from 'lodash';
import coreModule from 'app/core/core_module';
-import appEvents from 'app/core/app_events';
export class AlertSrv {
- list: any[];
+ constructor() {}
- /** @ngInject */
- constructor(private $timeout, private $rootScope) {
- this.list = [];
- }
-
- init() {
- this.$rootScope.onAppEvent(
- 'alert-error',
- (e, alert) => {
- this.set(alert[0], alert[1], 'error', 12000);
- },
- this.$rootScope
- );
-
- this.$rootScope.onAppEvent(
- 'alert-warning',
- (e, alert) => {
- this.set(alert[0], alert[1], 'warning', 5000);
- },
- this.$rootScope
- );
-
- this.$rootScope.onAppEvent(
- 'alert-success',
- (e, alert) => {
- this.set(alert[0], alert[1], 'success', 3000);
- },
- this.$rootScope
- );
-
- appEvents.on('alert-warning', options => this.set(options[0], options[1], 'warning', 5000));
- appEvents.on('alert-success', options => this.set(options[0], options[1], 'success', 3000));
- appEvents.on('alert-error', options => this.set(options[0], options[1], 'error', 7000));
- }
-
- getIconForSeverity(severity) {
- switch (severity) {
- case 'success':
- return 'fa fa-check';
- case 'error':
- return 'fa fa-exclamation-triangle';
- default:
- return 'fa fa-exclamation';
- }
- }
-
- set(title, text, severity, timeout) {
- if (_.isObject(text)) {
- console.log('alert error', text);
- if (text.statusText) {
- text = `HTTP Error (${text.status}) ${text.statusText}`;
- }
- }
-
- const newAlert = {
- title: title || '',
- text: text || '',
- severity: severity || 'info',
- icon: this.getIconForSeverity(severity),
- };
-
- const newAlertJson = angular.toJson(newAlert);
-
- // remove same alert if it already exists
- _.remove(this.list, value => {
- return angular.toJson(value) === newAlertJson;
- });
-
- this.list.push(newAlert);
- if (timeout > 0) {
- this.$timeout(() => {
- this.list = _.without(this.list, newAlert);
- }, timeout);
- }
-
- if (!this.$rootScope.$$phase) {
- this.$rootScope.$digest();
- }
-
- return newAlert;
- }
-
- clear(alert) {
- this.list = _.without(this.list, alert);
- }
-
- clearAll() {
- this.list = [];
+ set() {
+ console.log('old depricated alert srv being used');
}
}
+// this is just added to not break old plugins that might be using it
coreModule.service('alertSrv', AlertSrv);
diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts
index 3e8132a695b..144567efeb9 100644
--- a/public/app/core/services/backend_srv.ts
+++ b/public/app/core/services/backend_srv.ts
@@ -9,7 +9,7 @@ export class BackendSrv {
private noBackendCache: boolean;
/** @ngInject */
- constructor(private $http, private alertSrv, private $q, private $timeout, private contextSrv) {}
+ constructor(private $http, private $q, private $timeout, private contextSrv) {}
get(url, params?) {
return this.request({ method: 'GET', url: url, params: params });
@@ -49,14 +49,14 @@ export class BackendSrv {
}
if (err.status === 422) {
- this.alertSrv.set('Validation failed', data.message, 'warning', 4000);
+ appEvents.emit('alert-warning', ['Validation failed', data.message]);
throw data;
}
- data.severity = 'error';
+ let severity = 'error';
if (err.status < 500) {
- data.severity = 'warning';
+ severity = 'warning';
}
if (data.message) {
@@ -66,7 +66,8 @@ export class BackendSrv {
description = message;
message = 'Error';
}
- this.alertSrv.set(message, description, data.severity, 10000);
+
+ appEvents.emit('alert-' + severity, [message, description]);
}
throw data;
@@ -93,7 +94,7 @@ export class BackendSrv {
if (options.method !== 'GET') {
if (results && results.data.message) {
if (options.showSuccessAlert !== false) {
- this.alertSrv.set(results.data.message, '', 'success', 3000);
+ appEvents.emit('alert-success', [results.data.message]);
}
}
}
diff --git a/public/app/core/services/dynamic_directive_srv.ts b/public/app/core/services/dynamic_directive_srv.ts
index ccd86856755..9b7ede59853 100644
--- a/public/app/core/services/dynamic_directive_srv.ts
+++ b/public/app/core/services/dynamic_directive_srv.ts
@@ -3,7 +3,7 @@ import coreModule from '../core_module';
class DynamicDirectiveSrv {
/** @ngInject */
- constructor(private $compile, private $rootScope) {}
+ constructor(private $compile) {}
addDirective(element, name, scope) {
const child = angular.element(document.createElement(name));
@@ -14,25 +14,19 @@ class DynamicDirectiveSrv {
}
link(scope, elem, attrs, options) {
- options
- .directive(scope)
- .then(directiveInfo => {
- if (!directiveInfo || !directiveInfo.fn) {
- elem.empty();
- return;
- }
+ const directiveInfo = options.directive(scope);
+ if (!directiveInfo || !directiveInfo.fn) {
+ elem.empty();
+ return;
+ }
- if (!directiveInfo.fn.registered) {
- coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn);
- directiveInfo.fn.registered = true;
- }
+ if (!directiveInfo.fn.registered) {
+ console.log('register panel tab');
+ coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn);
+ directiveInfo.fn.registered = true;
+ }
- this.addDirective(elem, directiveInfo.name, scope);
- })
- .catch(err => {
- console.log('Plugin load:', err);
- this.$rootScope.appEvent('alert-error', ['Plugin error', err.toString()]);
- });
+ this.addDirective(elem, directiveInfo.name, scope);
}
create(options) {
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index d05e9b0c21c..6fe57dfa77a 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/public/app/core/services/keybindingSrv.ts
@@ -4,7 +4,7 @@ import _ from 'lodash';
import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
-import { encodePathComponent } from 'app/core/utils/location_util';
+import { getExploreUrl } from 'app/core/utils/explore';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
@@ -15,7 +15,14 @@ export class KeybindingSrv {
timepickerOpen = false;
/** @ngInject */
- constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv, private contextSrv) {
+ constructor(
+ private $rootScope,
+ private $location,
+ private $timeout,
+ private datasourceSrv,
+ private timeSrv,
+ private contextSrv
+ ) {
// clear out all shortcuts on route change
$rootScope.$on('$routeChangeSuccess', () => {
Mousetrap.reset();
@@ -141,7 +148,7 @@ export class KeybindingSrv {
this.bind('mod+o', () => {
dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3;
appEvents.emit('graph-hover-clear');
- this.$rootScope.$broadcast('refresh');
+ dashboard.startRefresh();
});
this.bind('mod+s', e => {
@@ -194,14 +201,9 @@ export class KeybindingSrv {
if (dashboard.meta.focusPanelId) {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
const datasource = await this.datasourceSrv.get(panel.datasource);
- if (datasource && datasource.supportsExplore) {
- const range = this.timeSrv.timeRangeForUrl();
- const state = {
- ...datasource.getExploreState(panel),
- range,
- };
- const exploreState = encodePathComponent(JSON.stringify(state));
- this.$location.url(`/explore?state=${exploreState}`);
+ const url = await getExploreUrl(panel, panel.targets, datasource, this.datasourceSrv, this.timeSrv);
+ if (url) {
+ this.$timeout(() => this.$location.url(url));
}
}
});
@@ -240,6 +242,18 @@ export class KeybindingSrv {
}
});
+ // toggle panel legend
+ this.bind('p l', () => {
+ if (dashboard.meta.focusPanelId) {
+ const panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
+ if (panelInfo.panel.legend) {
+ const panelRef = dashboard.getPanelById(dashboard.meta.focusPanelId);
+ panelRef.legend.show = !panelRef.legend.show;
+ panelRef.refresh();
+ }
+ }
+ });
+
// collapse all rows
this.bind('d shift+c', () => {
dashboard.collapseRows();
@@ -255,7 +269,7 @@ export class KeybindingSrv {
});
this.bind('d r', () => {
- this.$rootScope.$broadcast('refresh');
+ dashboard.startRefresh();
});
this.bind('d s', () => {
diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts
index 2e35b87deb4..a6cb5a7d331 100644
--- a/public/app/core/specs/backend_srv.test.ts
+++ b/public/app/core/specs/backend_srv.test.ts
@@ -9,7 +9,7 @@ describe('backend_srv', () => {
return Promise.resolve({});
};
- const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}, {});
+ const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {});
describe('when handling errors', () => {
it('should return the http status code', async () => {
diff --git a/public/app/core/specs/kbn.test.ts b/public/app/core/specs/kbn.test.ts
index dfa665e3205..e621cdef632 100644
--- a/public/app/core/specs/kbn.test.ts
+++ b/public/app/core/specs/kbn.test.ts
@@ -399,6 +399,77 @@ describe('duration', () => {
});
});
+describe('clock', () => {
+ it('null', () => {
+ const str = kbn.toClock(null, 0);
+ expect(str).toBe('');
+ });
+ it('size less than 1 second', () => {
+ const str = kbn.toClock(999, 0);
+ expect(str).toBe('999ms');
+ });
+ describe('size less than 1 minute', () => {
+ it('default', () => {
+ const str = kbn.toClock(59999);
+ expect(str).toBe('59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = kbn.toClock(59999, 0);
+ expect(str).toBe('59s');
+ });
+ });
+ describe('size less than 1 hour', () => {
+ it('default', () => {
+ const str = kbn.toClock(3599999);
+ expect(str).toBe('59m:59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = kbn.toClock(3599999, 0);
+ expect(str).toBe('59m');
+ });
+ it('decimals equals 1', () => {
+ const str = kbn.toClock(3599999, 1);
+ expect(str).toBe('59m:59s');
+ });
+ });
+ describe('size greater than or equal 1 hour', () => {
+ it('default', () => {
+ const str = kbn.toClock(7199999);
+ expect(str).toBe('01h:59m:59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = kbn.toClock(7199999, 0);
+ expect(str).toBe('01h');
+ });
+ it('decimals equals 1', () => {
+ const str = kbn.toClock(7199999, 1);
+ expect(str).toBe('01h:59m');
+ });
+ it('decimals equals 2', () => {
+ const str = kbn.toClock(7199999, 2);
+ expect(str).toBe('01h:59m:59s');
+ });
+ });
+ describe('size greater than or equal 1 day', () => {
+ it('default', () => {
+ const str = kbn.toClock(89999999);
+ expect(str).toBe('24h:59m:59s:999ms');
+ });
+ it('decimals equals 0', () => {
+ const str = kbn.toClock(89999999, 0);
+ expect(str).toBe('24h');
+ });
+ it('decimals equals 1', () => {
+ const str = kbn.toClock(89999999, 1);
+ expect(str).toBe('24h:59m');
+ });
+ it('decimals equals 2', () => {
+ const str = kbn.toClock(89999999, 2);
+ expect(str).toBe('24h:59m:59s');
+ });
+ });
+});
+
describe('volume', () => {
it('1000m3', () => {
const str = kbn.valueFormats['m3'](1000, 1, null);
diff --git a/public/app/core/specs/table_model.test.ts b/public/app/core/specs/table_model.test.ts
index 990daaaa2da..19b2a7543fb 100644
--- a/public/app/core/specs/table_model.test.ts
+++ b/public/app/core/specs/table_model.test.ts
@@ -1,4 +1,4 @@
-import TableModel from 'app/core/table_model';
+import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
describe('when sorting table desc', () => {
let table;
@@ -79,3 +79,118 @@ describe('when sorting with nulls', () => {
expect(values).toEqual([null, null, 'd', 'c', 'b', 'a', '', '']);
});
});
+
+describe('mergeTables', () => {
+ const time = new Date().getTime();
+
+ const singleTable = new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value' }],
+ rows: [[time, 'Label Value 1', 42]],
+ });
+
+ const multipleTablesSameColumns = [
+ new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #A' }],
+ rows: [[time, 'Label Value 1', 'Label Value 2', 42]],
+ }),
+ new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #B' }],
+ rows: [[time, 'Label Value 1', 'Label Value 2', 13]],
+ }),
+ new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #C' }],
+ rows: [[time, 'Label Value 1', 'Label Value 2', 4]],
+ }),
+ new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #C' }],
+ rows: [[time, 'Label Value 1', 'Label Value 2', 7]],
+ }),
+ ];
+
+ const multipleTablesDifferentColumns = [
+ new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #A' }],
+ rows: [[time, 'Label Value 1', 42]],
+ }),
+ new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 2' }, { text: 'Value #B' }],
+ rows: [[time, 'Label Value 2', 13]],
+ }),
+ new TableModel({
+ type: 'table',
+ columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #C' }],
+ rows: [[time, 'Label Value 3', 7]],
+ }),
+ ];
+
+ it('should return the single table as is', () => {
+ const table = mergeTablesIntoModel(new TableModel(), singleTable);
+ expect(table.columns.length).toBe(3);
+ expect(table.columns[0].text).toBe('Time');
+ expect(table.columns[1].text).toBe('Label Key 1');
+ expect(table.columns[2].text).toBe('Value');
+ });
+
+ it('should return the union of columns for multiple tables', () => {
+ const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns);
+ expect(table.columns.length).toBe(6);
+ expect(table.columns[0].text).toBe('Time');
+ expect(table.columns[1].text).toBe('Label Key 1');
+ expect(table.columns[2].text).toBe('Label Key 2');
+ expect(table.columns[3].text).toBe('Value #A');
+ expect(table.columns[4].text).toBe('Value #B');
+ expect(table.columns[5].text).toBe('Value #C');
+ });
+
+ it('should return 1 row for a single table', () => {
+ const table = mergeTablesIntoModel(new TableModel(), singleTable);
+ expect(table.rows.length).toBe(1);
+ expect(table.rows[0][0]).toBe(time);
+ expect(table.rows[0][1]).toBe('Label Value 1');
+ expect(table.rows[0][2]).toBe(42);
+ });
+
+ it('should return 2 rows for a multiple tables with same column values plus one extra row', () => {
+ const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns);
+ expect(table.rows.length).toBe(2);
+ expect(table.rows[0][0]).toBe(time);
+ expect(table.rows[0][1]).toBe('Label Value 1');
+ expect(table.rows[0][2]).toBe('Label Value 2');
+ expect(table.rows[0][3]).toBe(42);
+ expect(table.rows[0][4]).toBe(13);
+ expect(table.rows[0][5]).toBe(4);
+ expect(table.rows[1][0]).toBe(time);
+ expect(table.rows[1][1]).toBe('Label Value 1');
+ expect(table.rows[1][2]).toBe('Label Value 2');
+ expect(table.rows[1][3]).toBeUndefined();
+ expect(table.rows[1][4]).toBeUndefined();
+ expect(table.rows[1][5]).toBe(7);
+ });
+
+ it('should return 2 rows for multiple tables with different column values', () => {
+ const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesDifferentColumns);
+ expect(table.rows.length).toBe(2);
+ expect(table.columns.length).toBe(6);
+
+ expect(table.rows[0][0]).toBe(time);
+ expect(table.rows[0][1]).toBe('Label Value 1');
+ expect(table.rows[0][2]).toBe(42);
+ expect(table.rows[0][3]).toBe('Label Value 2');
+ expect(table.rows[0][4]).toBe(13);
+ expect(table.rows[0][5]).toBeUndefined();
+
+ expect(table.rows[1][0]).toBe(time);
+ expect(table.rows[1][1]).toBe('Label Value 3');
+ expect(table.rows[1][2]).toBeUndefined();
+ expect(table.rows[1][3]).toBeUndefined();
+ expect(table.rows[1][4]).toBeUndefined();
+ expect(table.rows[1][5]).toBe(7);
+ });
+});
diff --git a/public/app/core/table_model.ts b/public/app/core/table_model.ts
index f8b96d0537b..91a7cd0c1fb 100644
--- a/public/app/core/table_model.ts
+++ b/public/app/core/table_model.ts
@@ -1,3 +1,5 @@
+import _ from 'lodash';
+
interface Column {
text: string;
title?: string;
@@ -14,11 +16,20 @@ export default class TableModel {
type: string;
columnMap: any;
- constructor() {
+ constructor(table?: any) {
this.columns = [];
this.columnMap = {};
this.rows = [];
this.type = 'table';
+
+ if (table) {
+ if (table.columns) {
+ table.columns.forEach(col => this.addColumn(col));
+ }
+ if (table.rows) {
+ table.rows.forEach(row => this.addRow(row));
+ }
+ }
}
sort(options) {
@@ -52,3 +63,104 @@ export default class TableModel {
this.rows.push(row);
}
}
+
+// Returns true if both rows have matching non-empty fields as well as matching
+// indexes where one field is empty and the other is not
+function areRowsMatching(columns, row, otherRow) {
+ let foundFieldToMatch = false;
+ for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
+ if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) {
+ if (row[columnIndex] !== otherRow[columnIndex]) {
+ return false;
+ }
+ } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) {
+ foundFieldToMatch = true;
+ }
+ }
+ return foundFieldToMatch;
+}
+
+export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel {
+ const model = dst || new TableModel();
+
+ if (arguments.length === 1) {
+ return model;
+ }
+
+ // Single query returns data columns and rows as is
+ if (arguments.length === 2) {
+ model.columns = [...tables[0].columns];
+ model.rows = [...tables[0].rows];
+ return model;
+ }
+
+ // Track column indexes of union: name -> index
+ const columnNames = {};
+
+ // Union of all non-value columns
+ const columnsUnion = tables.slice().reduce((acc, series) => {
+ series.columns.forEach(col => {
+ const { text } = col;
+ if (columnNames[text] === undefined) {
+ columnNames[text] = acc.length;
+ acc.push(col);
+ }
+ });
+ return acc;
+ }, []);
+
+ // Map old column index to union index per series, e.g.,
+ // given columnNames {A: 0, B: 1} and
+ // data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]]
+ const columnIndexMapper = tables.map(series => series.columns.map(col => columnNames[col.text]));
+
+ // Flatten rows of all series and adjust new column indexes
+ const flattenedRows = tables.reduce((acc, series, seriesIndex) => {
+ const mapper = columnIndexMapper[seriesIndex];
+ series.rows.forEach(row => {
+ const alteredRow = [];
+ // Shifting entries according to index mapper
+ mapper.forEach((to, from) => {
+ alteredRow[to] = row[from];
+ });
+ acc.push(alteredRow);
+ });
+ return acc;
+ }, []);
+
+ // Merge rows that have same values for columns
+ const mergedRows = {};
+ const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => {
+ if (!mergedRows[rowIndex]) {
+ // Look from current row onwards
+ let offset = rowIndex + 1;
+ // More than one row can be merged into current row
+ while (offset < flattenedRows.length) {
+ // Find next row that could be merged
+ const match = _.findIndex(flattenedRows, otherRow => areRowsMatching(columnsUnion, row, otherRow), offset);
+ if (match > -1) {
+ const matchedRow = flattenedRows[match];
+ // Merge values from match into current row if there is a gap in the current row
+ for (let columnIndex = 0; columnIndex < columnsUnion.length; columnIndex++) {
+ if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) {
+ row[columnIndex] = matchedRow[columnIndex];
+ }
+ }
+ // Don't visit this row again
+ mergedRows[match] = matchedRow;
+ // Keep looking for more rows to merge
+ offset = match + 1;
+ } else {
+ // No match found, stop looking
+ break;
+ }
+ }
+ acc.push(row);
+ }
+ return acc;
+ }, []);
+
+ model.columns = columnsUnion;
+ model.rows = compactedRows;
+ return model;
+}
diff --git a/public/app/core/utils/connectWithReduxStore.tsx b/public/app/core/utils/connectWithReduxStore.tsx
new file mode 100644
index 00000000000..92c61db4e77
--- /dev/null
+++ b/public/app/core/utils/connectWithReduxStore.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { store } from '../../store/configureStore';
+
+export function connectWithStore(WrappedComponent, ...args) {
+ const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
+
+ return props => {
+ return ;
+ };
+}
diff --git a/public/app/core/utils/dag.test.ts b/public/app/core/utils/dag.test.ts
index 064da13806b..4ee0dd7134b 100644
--- a/public/app/core/utils/dag.test.ts
+++ b/public/app/core/utils/dag.test.ts
@@ -104,5 +104,17 @@ describe('Directed acyclic graph', () => {
const actual = nodeH.getOptimizedInputEdges();
expect(actual).toHaveLength(0);
});
+
+ it('when linking non-existing input node with existing output node should throw error', () => {
+ expect(() => {
+ dag.link('non-existing', 'A');
+ }).toThrowError("cannot link input node named non-existing since it doesn't exist in graph");
+ });
+
+ it('when linking existing input node with non-existing output node should throw error', () => {
+ expect(() => {
+ dag.link('A', 'non-existing');
+ }).toThrowError("cannot link output node named non-existing since it doesn't exist in graph");
+ });
});
});
diff --git a/public/app/core/utils/dag.ts b/public/app/core/utils/dag.ts
index eb7ff1c3b1a..48c00a4c8c3 100644
--- a/public/app/core/utils/dag.ts
+++ b/public/app/core/utils/dag.ts
@@ -15,6 +15,14 @@ export class Edge {
}
link(inputNode: Node, outputNode: Node) {
+ if (!inputNode) {
+ throw Error('inputNode is required');
+ }
+
+ if (!outputNode) {
+ throw Error('outputNode is required');
+ }
+
this.unlink();
this.inputNode = inputNode;
this.outputNode = outputNode;
@@ -152,7 +160,11 @@ export class Graph {
for (let n = 0; n < inputArr.length; n++) {
const i = inputArr[n];
if (typeof i === 'string') {
- inputNodes.push(this.getNode(i));
+ const n = this.getNode(i);
+ if (!n) {
+ throw Error(`cannot link input node named ${i} since it doesn't exist in graph`);
+ }
+ inputNodes.push(n);
} else {
inputNodes.push(i);
}
@@ -161,7 +173,11 @@ export class Graph {
for (let n = 0; n < outputArr.length; n++) {
const i = outputArr[n];
if (typeof i === 'string') {
- outputNodes.push(this.getNode(i));
+ const n = this.getNode(i);
+ if (!n) {
+ throw Error(`cannot link output node named ${i} since it doesn't exist in graph`);
+ }
+ outputNodes.push(n);
} else {
outputNodes.push(i);
}
diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts
new file mode 100644
index 00000000000..4252730338d
--- /dev/null
+++ b/public/app/core/utils/explore.test.ts
@@ -0,0 +1,139 @@
+import { DEFAULT_RANGE, serializeStateToUrlParam, parseUrlState } from './explore';
+import { ExploreState } from 'app/types/explore';
+
+const DEFAULT_EXPLORE_STATE: ExploreState = {
+ datasource: null,
+ datasourceError: null,
+ datasourceLoading: null,
+ datasourceMissing: false,
+ datasourceName: '',
+ exploreDatasources: [],
+ graphRange: DEFAULT_RANGE,
+ history: [],
+ queries: [],
+ queryTransactions: [],
+ range: DEFAULT_RANGE,
+ showingGraph: true,
+ showingLogs: true,
+ showingTable: true,
+ supportsGraph: null,
+ supportsLogs: null,
+ supportsTable: null,
+};
+
+describe('state functions', () => {
+ describe('parseUrlState', () => {
+ it('returns default state on empty string', () => {
+ expect(parseUrlState('')).toMatchObject({
+ datasource: null,
+ queries: [],
+ range: DEFAULT_RANGE,
+ });
+ });
+
+ it('returns a valid Explore state from URL parameter', () => {
+ const paramValue =
+ '%7B"datasource":"Local","queries":%5B%7B"query":"metric"%7D%5D,"range":%7B"from":"now-1h","to":"now"%7D%7D';
+ expect(parseUrlState(paramValue)).toMatchObject({
+ datasource: 'Local',
+ queries: [{ query: 'metric' }],
+ range: {
+ from: 'now-1h',
+ to: 'now',
+ },
+ });
+ });
+
+ it('returns a valid Explore state from a compact URL parameter', () => {
+ const paramValue = '%5B"now-1h","now","Local","metric"%5D';
+ expect(parseUrlState(paramValue)).toMatchObject({
+ datasource: 'Local',
+ queries: [{ query: 'metric' }],
+ range: {
+ from: 'now-1h',
+ to: 'now',
+ },
+ });
+ });
+ });
+
+ describe('serializeStateToUrlParam', () => {
+ it('returns url parameter value for a state object', () => {
+ const state = {
+ ...DEFAULT_EXPLORE_STATE,
+ datasourceName: 'foo',
+ range: {
+ from: 'now-5h',
+ to: 'now',
+ },
+ queries: [
+ {
+ query: 'metric{test="a/b"}',
+ },
+ {
+ query: 'super{foo="x/z"}',
+ },
+ ],
+ };
+ expect(serializeStateToUrlParam(state)).toBe(
+ '{"datasource":"foo","queries":[{"query":"metric{test=\\"a/b\\"}"},' +
+ '{"query":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
+ );
+ });
+
+ it('returns url parameter value for a state object', () => {
+ const state = {
+ ...DEFAULT_EXPLORE_STATE,
+ datasourceName: 'foo',
+ range: {
+ from: 'now-5h',
+ to: 'now',
+ },
+ queries: [
+ {
+ query: 'metric{test="a/b"}',
+ },
+ {
+ query: 'super{foo="x/z"}',
+ },
+ ],
+ };
+ expect(serializeStateToUrlParam(state, true)).toBe(
+ '["now-5h","now","foo","metric{test=\\"a/b\\"}","super{foo=\\"x/z\\"}"]'
+ );
+ });
+ });
+
+ describe('interplay', () => {
+ it('can parse the serialized state into the original state', () => {
+ const state = {
+ ...DEFAULT_EXPLORE_STATE,
+ datasourceName: 'foo',
+ range: {
+ from: 'now - 5h',
+ to: 'now',
+ },
+ queries: [
+ {
+ query: 'metric{test="a/b"}',
+ },
+ {
+ query: 'super{foo="x/z"}',
+ },
+ ],
+ };
+ const serialized = serializeStateToUrlParam(state);
+ const parsed = parseUrlState(serialized);
+
+ // Account for datasource vs datasourceName
+ const { datasource, ...rest } = parsed;
+ const sameState = {
+ ...rest,
+ datasource: DEFAULT_EXPLORE_STATE.datasource,
+ datasourceName: datasource,
+ };
+
+ expect(state).toMatchObject(sameState);
+ });
+ });
+});
diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts
new file mode 100644
index 00000000000..ecd11a495ad
--- /dev/null
+++ b/public/app/core/utils/explore.ts
@@ -0,0 +1,99 @@
+import { renderUrl } from 'app/core/utils/url';
+import { ExploreState, ExploreUrlState } from 'app/types/explore';
+
+export const DEFAULT_RANGE = {
+ from: 'now-6h',
+ to: 'now',
+};
+
+/**
+ * Returns an Explore-URL that contains a panel's queries and the dashboard time range.
+ *
+ * @param panel Origin panel of the jump to Explore
+ * @param panelTargets The origin panel's query targets
+ * @param panelDatasource The origin panel's datasource
+ * @param datasourceSrv Datasource service to query other datasources in case the panel datasource is mixed
+ * @param timeSrv Time service to get the current dashboard range from
+ */
+export async function getExploreUrl(
+ panel: any,
+ panelTargets: any[],
+ panelDatasource: any,
+ datasourceSrv: any,
+ timeSrv: any
+) {
+ let exploreDatasource = panelDatasource;
+ let exploreTargets = panelTargets;
+ let url;
+
+ // Mixed datasources need to choose only one datasource
+ if (panelDatasource.meta.id === 'mixed' && panelTargets) {
+ // Find first explore datasource among targets
+ let mixedExploreDatasource;
+ for (const t of panel.targets) {
+ const datasource = await datasourceSrv.get(t.datasource);
+ if (datasource && datasource.meta.explore) {
+ mixedExploreDatasource = datasource;
+ break;
+ }
+ }
+
+ // Add all its targets
+ if (mixedExploreDatasource) {
+ exploreDatasource = mixedExploreDatasource;
+ exploreTargets = panelTargets.filter(t => t.datasource === mixedExploreDatasource.name);
+ }
+ }
+
+ if (exploreDatasource && exploreDatasource.meta.explore) {
+ const range = timeSrv.timeRangeForUrl();
+ const state = {
+ ...exploreDatasource.getExploreState(exploreTargets),
+ range,
+ };
+ const exploreState = JSON.stringify(state);
+ url = renderUrl('/explore', { state: exploreState });
+ }
+ return url;
+}
+
+export function parseUrlState(initial: string | undefined): ExploreUrlState {
+ if (initial) {
+ try {
+ const parsed = JSON.parse(decodeURI(initial));
+ if (Array.isArray(parsed)) {
+ if (parsed.length <= 3) {
+ throw new Error('Error parsing compact URL state for Explore.');
+ }
+ const range = {
+ from: parsed[0],
+ to: parsed[1],
+ };
+ const datasource = parsed[2];
+ const queries = parsed.slice(3).map(query => ({ query }));
+ return { datasource, queries, range };
+ }
+ return parsed;
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ return { datasource: null, queries: [], range: DEFAULT_RANGE };
+}
+
+export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string {
+ const urlState: ExploreUrlState = {
+ datasource: state.datasourceName,
+ queries: state.queries.map(q => ({ query: q.query })),
+ range: state.range,
+ };
+ if (compact) {
+ return JSON.stringify([
+ urlState.range.from,
+ urlState.range.to,
+ urlState.datasource,
+ ...urlState.queries.map(q => q.query),
+ ]);
+ }
+ return JSON.stringify(urlState);
+}
diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts
index bd69f2e89d9..398ad9bb3e7 100644
--- a/public/app/core/utils/kbn.ts
+++ b/public/app/core/utils/kbn.ts
@@ -808,6 +808,51 @@ kbn.toDuration = (size, decimals, timeScale) => {
return strings.join(', ');
};
+kbn.toClock = (size, decimals) => {
+ if (size === null) {
+ return '';
+ }
+
+ // < 1 second
+ if (size < 1000) {
+ return moment.utc(size).format('SSS\\m\\s');
+ }
+
+ // < 1 minute
+ if (size < 60000) {
+ let format = 'ss\\s:SSS\\m\\s';
+ if (decimals === 0) {
+ format = 'ss\\s';
+ }
+ return moment.utc(size).format(format);
+ }
+
+ // < 1 hour
+ if (size < 3600000) {
+ let format = 'mm\\m:ss\\s:SSS\\m\\s';
+ if (decimals === 0) {
+ format = 'mm\\m';
+ } else if (decimals === 1) {
+ format = 'mm\\m:ss\\s';
+ }
+ return moment.utc(size).format(format);
+ }
+
+ let format = 'mm\\m:ss\\s:SSS\\m\\s';
+
+ const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
+
+ if (decimals === 0) {
+ format = '';
+ } else if (decimals === 1) {
+ format = 'mm\\m';
+ } else if (decimals === 2) {
+ format = 'mm\\m:ss\\s';
+ }
+
+ return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
+};
+
kbn.valueFormats.dtdurationms = (size, decimals) => {
return kbn.toDuration(size, decimals, 'millisecond');
};
@@ -824,6 +869,14 @@ kbn.valueFormats.timeticks = (size, decimals, scaledDecimals) => {
return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
};
+kbn.valueFormats.clockms = (size, decimals) => {
+ return kbn.toClock(size, decimals);
+};
+
+kbn.valueFormats.clocks = (size, decimals) => {
+ return kbn.toClock(size * 1000, decimals);
+};
+
kbn.valueFormats.dateTimeAsIso = (epoch, isUtc) => {
const time = isUtc ? moment.utc(epoch) : moment(epoch);
@@ -901,6 +954,8 @@ kbn.getUnitFormats = () => {
{ text: 'duration (s)', value: 'dtdurations' },
{ text: 'duration (hh:mm:ss)', value: 'dthms' },
{ text: 'Timeticks (s/100)', value: 'timeticks' },
+ { text: 'clock (ms)', value: 'clockms' },
+ { text: 'clock (s)', value: 'clocks' },
],
},
{
diff --git a/public/app/core/utils/location_util.ts b/public/app/core/utils/location_util.ts
index 735272285ff..76f2fc5881f 100644
--- a/public/app/core/utils/location_util.ts
+++ b/public/app/core/utils/location_util.ts
@@ -1,10 +1,5 @@
import config from 'app/core/config';
-// Slash encoding for angular location provider, see https://github.com/angular/angular.js/issues/10479
-const SLASH = '';
-export const decodePathComponent = (pc: string) => decodeURIComponent(pc).replace(new RegExp(SLASH, 'g'), '/');
-export const encodePathComponent = (pc: string) => encodeURIComponent(pc.replace(/\//g, SLASH));
-
export const stripBaseFromUrl = url => {
const appSubUrl = config.appSubUrl;
const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
diff --git a/public/app/core/utils/rangeutil.ts b/public/app/core/utils/rangeutil.ts
index 484dd0e3327..2079aa39006 100644
--- a/public/app/core/utils/rangeutil.ts
+++ b/public/app/core/utils/rangeutil.ts
@@ -1,5 +1,8 @@
import _ from 'lodash';
import moment from 'moment';
+
+import { RawTimeRange } from 'app/types/series';
+
import * as dateMath from './datemath';
const spans = {
@@ -129,7 +132,7 @@ export function describeTextRange(expr: any) {
return opt;
}
-export function describeTimeRange(range) {
+export function describeTimeRange(range: RawTimeRange): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()];
if (option) {
return option.display;
diff --git a/public/app/core/utils/text.test.ts b/public/app/core/utils/text.test.ts
new file mode 100644
index 00000000000..4f9d8367218
--- /dev/null
+++ b/public/app/core/utils/text.test.ts
@@ -0,0 +1,24 @@
+import { findMatchesInText } from './text';
+
+describe('findMatchesInText()', () => {
+ it('gets no matches for when search and or line are empty', () => {
+ expect(findMatchesInText('', '')).toEqual([]);
+ expect(findMatchesInText('foo', '')).toEqual([]);
+ expect(findMatchesInText('', 'foo')).toEqual([]);
+ });
+
+ it('gets no matches for unmatched search string', () => {
+ expect(findMatchesInText('foo', 'bar')).toEqual([]);
+ });
+
+ it('gets matches for matched search string', () => {
+ expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]);
+ expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
+ });
+
+ expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
+ { length: 3, start: 1, text: 'foo', end: 4 },
+ { length: 3, start: 5, text: 'foo', end: 8 },
+ { length: 3, start: 9, text: 'bar', end: 12 },
+ ]);
+});
diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts
new file mode 100644
index 00000000000..5d7591a31e2
--- /dev/null
+++ b/public/app/core/utils/text.ts
@@ -0,0 +1,32 @@
+import { TextMatch } from 'app/types/explore';
+
+/**
+ * Adapt findMatchesInText for react-highlight-words findChunks handler.
+ * See https://github.com/bvaughn/react-highlight-words#props
+ */
+export function findHighlightChunksInText({ searchWords, textToHighlight }) {
+ return findMatchesInText(textToHighlight, searchWords.join(' '));
+}
+
+/**
+ * Returns a list of substring regexp matches.
+ */
+export function findMatchesInText(haystack: string, needle: string): TextMatch[] {
+ // Empty search can send re.exec() into infinite loop, exit early
+ if (!haystack || !needle) {
+ return [];
+ }
+ const regexp = new RegExp(`(?:${needle})`, 'g');
+ const matches = [];
+ let match = regexp.exec(haystack);
+ while (match) {
+ matches.push({
+ text: match[0],
+ start: match.index,
+ length: match[0].length,
+ end: match.index + match[0].length,
+ });
+ match = regexp.exec(haystack);
+ }
+ return matches;
+}
diff --git a/public/app/core/utils/url.ts b/public/app/core/utils/url.ts
index 198029b0e9f..ab8be8ad222 100644
--- a/public/app/core/utils/url.ts
+++ b/public/app/core/utils/url.ts
@@ -2,6 +2,15 @@
* @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT
*/
+import { UrlQueryMap } from 'app/types';
+
+export function renderUrl(path: string, query: UrlQueryMap | undefined): string {
+ if (query && Object.keys(query).length > 0) {
+ path += '?' + toUrlParams(query);
+ }
+ return path;
+}
+
export function toUrlParams(a) {
const s = [];
const rbracket = /\[\]$/;
diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts
index c91ff5cd6c3..146b7026353 100644
--- a/public/app/features/alerting/AlertTabCtrl.ts
+++ b/public/app/features/alerting/AlertTabCtrl.ts
@@ -166,7 +166,7 @@ export class AlertTabCtrl {
alert.noDataState = alert.noDataState || config.alertingNoDataOrNullValues;
alert.executionErrorState = alert.executionErrorState || config.alertingErrorOrTimeout;
- alert.frequency = alert.frequency || '60s';
+ alert.frequency = alert.frequency || '1m';
alert.handler = alert.handler || 1;
alert.notifications = alert.notifications || [];
@@ -217,7 +217,7 @@ export class AlertTabCtrl {
buildDefaultCondition() {
return {
type: 'query',
- query: { params: ['A', '5m', 'now'] },
+ query: { params: ['A', '15m', 'now'] },
reducer: { type: 'avg', params: [] },
evaluator: { type: 'gt', params: [null] },
operator: { type: 'and' },
diff --git a/public/app/features/alerting/NotificationsEditCtrl.ts b/public/app/features/alerting/NotificationsEditCtrl.ts
index 315a9a619a1..2607121bb0e 100644
--- a/public/app/features/alerting/NotificationsEditCtrl.ts
+++ b/public/app/features/alerting/NotificationsEditCtrl.ts
@@ -12,6 +12,7 @@ export class AlertNotificationEditCtrl {
defaults: any = {
type: 'email',
sendReminder: false,
+ disableResolveMessage: false,
frequency: '15m',
settings: {
httpMethod: 'POST',
diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html
index 7b198736b83..b2cd2f21e4d 100644
--- a/public/app/features/alerting/partials/notification_edit.html
+++ b/public/app/features/alerting/partials/notification_edit.html
@@ -21,21 +21,28 @@
+
+
diff --git a/public/app/features/all.ts b/public/app/features/all.ts
index 7f6f84b7676..ccdc7d7a820 100644
--- a/public/app/features/all.ts
+++ b/public/app/features/all.ts
@@ -9,3 +9,6 @@ import './admin';
import './alerting/NotificationsEditCtrl';
import './alerting/NotificationsListCtrl';
import './manage-dashboards';
+import './teams/CreateTeamCtrl';
+import './profile/ProfileCtrl';
+import './profile/ChangePasswordCtrl';
diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts
index f9820fe566d..4fe6e10b2cf 100644
--- a/public/app/features/annotations/annotations_srv.ts
+++ b/public/app/features/annotations/annotations_srv.ts
@@ -1,23 +1,32 @@
-import './editor_ctrl';
-
+// Libaries
import angular from 'angular';
import _ from 'lodash';
+
+// Components
+import './editor_ctrl';
import coreModule from 'app/core/core_module';
+
+// Utils & Services
import { makeRegions, dedupAnnotations } from './events_processing';
+// Types
+import { DashboardModel } from '../dashboard/dashboard_model';
+
export class AnnotationsSrv {
globalAnnotationsPromise: any;
alertStatesPromise: any;
+ datasourcePromises: any;
/** @ngInject */
- constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
- $rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope);
- $rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
- }
+ constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {}
- clearCache() {
- this.globalAnnotationsPromise = null;
- this.alertStatesPromise = null;
+ init(dashboard: DashboardModel) {
+ // clear promises on refresh events
+ dashboard.on('refresh', () => {
+ this.globalAnnotationsPromise = null;
+ this.alertStatesPromise = null;
+ this.datasourcePromises = null;
+ });
}
getAnnotations(options) {
@@ -90,6 +99,7 @@ export class AnnotationsSrv {
const range = this.timeSrv.timeRange();
const promises = [];
+ const dsPromises = [];
for (const annotation of dashboard.annotations.list) {
if (!annotation.enable) {
@@ -99,10 +109,10 @@ export class AnnotationsSrv {
if (annotation.snapshotData) {
return this.translateQueryResult(annotation, annotation.snapshotData);
}
-
+ const datasourcePromise = this.datasourceSrv.get(annotation.datasource);
+ dsPromises.push(datasourcePromise);
promises.push(
- this.datasourceSrv
- .get(annotation.datasource)
+ datasourcePromise
.then(datasource => {
// issue query against data source
return datasource.annotationQuery({
@@ -122,7 +132,7 @@ export class AnnotationsSrv {
})
);
}
-
+ this.datasourcePromises = this.$q.all(dsPromises);
this.globalAnnotationsPromise = this.$q.all(promises);
return this.globalAnnotationsPromise;
}
diff --git a/public/app/features/api-keys/ApiKeysAddedModal.test.tsx b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx
new file mode 100644
index 00000000000..160418a7ab8
--- /dev/null
+++ b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { ApiKeysAddedModal, Props } from './ApiKeysAddedModal';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ apiKey: 'api key test',
+ rootPath: 'test/path',
+ };
+
+ Object.assign(props, propOverrides);
+
+ const wrapper = shallow( );
+
+ return {
+ wrapper,
+ };
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const { wrapper } = setup();
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/api-keys/ApiKeysAddedModal.tsx b/public/app/features/api-keys/ApiKeysAddedModal.tsx
new file mode 100644
index 00000000000..995aa46c773
--- /dev/null
+++ b/public/app/features/api-keys/ApiKeysAddedModal.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+
+export interface Props {
+ apiKey: string;
+ rootPath: string;
+}
+
+export const ApiKeysAddedModal = (props: Props) => {
+ return (
+
+
+
+
+ API Key Created
+
+
+
+
+
+
+
+
+
+
+ Key
+ {props.apiKey}
+
+
+
+
+ You will only be able to view this key here once! It is not stored in this form. So be sure to copy it now.
+
+
+ You can authenticate request using the Authorization HTTP header, example:
+
+
+
+ curl -H "Authorization: Bearer {props.apiKey}" {props.rootPath}/api/dashboards/home
+
+
+
+
+ );
+};
+
+export default ApiKeysAddedModal;
diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx
new file mode 100644
index 00000000000..54200234ddc
--- /dev/null
+++ b/public/app/features/api-keys/ApiKeysPage.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { Props, ApiKeysPage } from './ApiKeysPage';
+import { NavModel, ApiKey } from 'app/types';
+import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ navModel: {} as NavModel,
+ apiKeys: [] as ApiKey[],
+ searchQuery: '',
+ hasFetched: false,
+ loadApiKeys: jest.fn(),
+ deleteApiKey: jest.fn(),
+ setSearchQuery: jest.fn(),
+ addApiKey: jest.fn(),
+ apiKeysCount: 0,
+ };
+
+ Object.assign(props, propOverrides);
+
+ const wrapper = shallow( );
+ const instance = wrapper.instance() as ApiKeysPage;
+
+ return {
+ wrapper,
+ instance,
+ };
+};
+
+describe('Render', () => {
+ it('should render API keys table if there are any keys', () => {
+ const { wrapper } = setup({
+ apiKeys: getMultipleMockKeys(5),
+ apiKeysCount: 5,
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render CTA if there are no API keys', () => {
+ const { wrapper } = setup({
+ apiKeys: getMultipleMockKeys(0),
+ apiKeysCount: 0,
+ hasFetched: true,
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
+
+describe('Life cycle', () => {
+ it('should call loadApiKeys', () => {
+ const { instance } = setup();
+
+ instance.componentDidMount();
+
+ expect(instance.props.loadApiKeys).toHaveBeenCalled();
+ });
+});
+
+describe('Functions', () => {
+ describe('Delete team', () => {
+ it('should call delete team', () => {
+ const { instance } = setup();
+ instance.onDeleteApiKey(getMockKey());
+ expect(instance.props.deleteApiKey).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('on search query change', () => {
+ it('should call setSearchQuery', () => {
+ const { instance } = setup();
+ const mockEvent = { target: { value: 'test' } };
+
+ instance.onSearchQueryChange(mockEvent);
+
+ expect(instance.props.setSearchQuery).toHaveBeenCalledWith('test');
+ });
+ });
+});
diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx
new file mode 100644
index 00000000000..d2aa1f24c57
--- /dev/null
+++ b/public/app/features/api-keys/ApiKeysPage.tsx
@@ -0,0 +1,276 @@
+import React, { PureComponent } from 'react';
+import ReactDOMServer from 'react-dom/server';
+import { connect } from 'react-redux';
+import { hot } from 'react-hot-loader';
+import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types';
+import { getNavModel } from 'app/core/selectors/navModel';
+import { getApiKeys, getApiKeysCount } from './state/selectors';
+import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
+import SlideDown from 'app/core/components/Animations/SlideDown';
+import ApiKeysAddedModal from './ApiKeysAddedModal';
+import config from 'app/core/config';
+import appEvents from 'app/core/app_events';
+import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
+import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
+
+export interface Props {
+ navModel: NavModel;
+ apiKeys: ApiKey[];
+ searchQuery: string;
+ hasFetched: boolean;
+ loadApiKeys: typeof loadApiKeys;
+ deleteApiKey: typeof deleteApiKey;
+ setSearchQuery: typeof setSearchQuery;
+ addApiKey: typeof addApiKey;
+ apiKeysCount: number;
+}
+
+export interface State {
+ isAdding: boolean;
+ newApiKey: NewApiKey;
+}
+
+enum ApiKeyStateProps {
+ Name = 'name',
+ Role = 'role',
+}
+
+const initialApiKeyState = {
+ name: '',
+ role: OrgRole.Viewer,
+};
+
+export class ApiKeysPage extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { isAdding: false, newApiKey: initialApiKeyState };
+ }
+
+ componentDidMount() {
+ this.fetchApiKeys();
+ }
+
+ async fetchApiKeys() {
+ await this.props.loadApiKeys();
+ }
+
+ onDeleteApiKey(key: ApiKey) {
+ this.props.deleteApiKey(key.id);
+ }
+
+ onSearchQueryChange = evt => {
+ this.props.setSearchQuery(evt.target.value);
+ };
+
+ onToggleAdding = () => {
+ this.setState({ isAdding: !this.state.isAdding });
+ };
+
+ onAddApiKey = async evt => {
+ evt.preventDefault();
+
+ const openModal = (apiKey: string) => {
+ const rootPath = window.location.origin + config.appSubUrl;
+ const modalTemplate = ReactDOMServer.renderToString( );
+
+ appEvents.emit('show-modal', {
+ templateHtml: modalTemplate,
+ });
+ };
+
+ this.props.addApiKey(this.state.newApiKey, openModal);
+ this.setState((prevState: State) => {
+ return {
+ ...prevState,
+ newApiKey: initialApiKeyState,
+ isAdding: false,
+ };
+ });
+ };
+
+ onApiKeyStateUpdate = (evt, prop: string) => {
+ const value = evt.currentTarget.value;
+ this.setState((prevState: State) => {
+ const newApiKey = {
+ ...prevState.newApiKey,
+ };
+ newApiKey[prop] = value;
+
+ return {
+ ...prevState,
+ newApiKey: newApiKey,
+ };
+ });
+ };
+
+ renderEmptyList() {
+ const { isAdding } = this.state;
+ return (
+
+ {!isAdding && (
+
+ )}
+ {this.renderAddApiKeyForm()}
+
+ );
+ }
+
+ renderAddApiKeyForm() {
+ const { newApiKey, isAdding } = this.state;
+
+ return (
+
+
+
+ );
+ }
+
+ renderApiKeyList() {
+ const { isAdding } = this.state;
+ const { apiKeys, searchQuery } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Add API Key
+
+
+
+ {this.renderAddApiKeyForm()}
+
+
Existing Keys
+
+
+
+ Name
+ Role
+
+
+
+ {apiKeys.length > 0 ? (
+
+ {apiKeys.map(key => {
+ return (
+
+ {key.name}
+ {key.role}
+
+ this.onDeleteApiKey(key)} />
+
+
+ );
+ })}
+
+ ) : null}
+
+
+ );
+ }
+
+ render() {
+ const { hasFetched, navModel, apiKeysCount } = this.props;
+
+ return (
+
+
+ {hasFetched ? (
+ apiKeysCount > 0 ? (
+ this.renderApiKeyList()
+ ) : (
+ this.renderEmptyList()
+ )
+ ) : (
+
+ )}
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ navModel: getNavModel(state.navIndex, 'apikeys'),
+ apiKeys: getApiKeys(state.apiKeys),
+ searchQuery: state.apiKeys.searchQuery,
+ apiKeysCount: getApiKeysCount(state.apiKeys),
+ hasFetched: state.apiKeys.hasFetched,
+ };
+}
+
+const mapDispatchToProps = {
+ loadApiKeys,
+ deleteApiKey,
+ setSearchQuery,
+ addApiKey,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ApiKeysPage));
diff --git a/public/app/features/api-keys/__mocks__/apiKeysMock.ts b/public/app/features/api-keys/__mocks__/apiKeysMock.ts
new file mode 100644
index 00000000000..117f0d6d0c6
--- /dev/null
+++ b/public/app/features/api-keys/__mocks__/apiKeysMock.ts
@@ -0,0 +1,22 @@
+import { ApiKey, OrgRole } from 'app/types';
+
+export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => {
+ const keys: ApiKey[] = [];
+ for (let i = 1; i <= numberOfKeys; i++) {
+ keys.push({
+ id: i,
+ name: `test-${i}`,
+ role: OrgRole.Viewer,
+ });
+ }
+
+ return keys;
+};
+
+export const getMockKey = (): ApiKey => {
+ return {
+ id: 1,
+ name: 'test',
+ role: OrgRole.Admin,
+ };
+};
diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap
new file mode 100644
index 00000000000..0fcb13308eb
--- /dev/null
+++ b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+
+
+
+ API Key Created
+
+
+
+
+
+
+
+
+
+
+ Key
+
+
+ api key test
+
+
+
+
+ You will only be able to view this key here once! It is not stored in this form. So be sure to copy it now.
+
+
+ You can authenticate request using the Authorization HTTP header, example:
+
+
+
+ curl -H "Authorization: Bearer
+ api key test
+ "
+ test/path
+ /api/dashboards/home
+
+
+
+
+`;
diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
new file mode 100644
index 00000000000..7ede9618250
--- /dev/null
+++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
@@ -0,0 +1,132 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render API keys table if there are any keys 1`] = `
+
+`;
+
+exports[`Render should render CTA if there are no API keys 1`] = `
+
+
+
+
+
+
+
+
+
+
+ Add API Key
+
+
+
+
+
+
+`;
diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts
new file mode 100644
index 00000000000..63e91088476
--- /dev/null
+++ b/public/app/features/api-keys/state/actions.ts
@@ -0,0 +1,56 @@
+import { ThunkAction } from 'redux-thunk';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { StoreState, ApiKey } from 'app/types';
+
+export enum ActionTypes {
+ LoadApiKeys = 'LOAD_API_KEYS',
+ SetApiKeysSearchQuery = 'SET_API_KEYS_SEARCH_QUERY',
+}
+
+export interface LoadApiKeysAction {
+ type: ActionTypes.LoadApiKeys;
+ payload: ApiKey[];
+}
+
+export interface SetSearchQueryAction {
+ type: ActionTypes.SetApiKeysSearchQuery;
+ payload: string;
+}
+
+export type Action = LoadApiKeysAction | SetSearchQueryAction;
+
+type ThunkResult = ThunkAction;
+
+const apiKeysLoaded = (apiKeys: ApiKey[]): LoadApiKeysAction => ({
+ type: ActionTypes.LoadApiKeys,
+ payload: apiKeys,
+});
+
+export function addApiKey(apiKey: ApiKey, openModal: (key: string) => void): ThunkResult {
+ return async dispatch => {
+ const result = await getBackendSrv().post('/api/auth/keys', apiKey);
+ dispatch(setSearchQuery(''));
+ dispatch(loadApiKeys());
+ openModal(result.key);
+ };
+}
+
+export function loadApiKeys(): ThunkResult {
+ return async dispatch => {
+ const response = await getBackendSrv().get('/api/auth/keys');
+ dispatch(apiKeysLoaded(response));
+ };
+}
+
+export function deleteApiKey(id: number): ThunkResult {
+ return async dispatch => {
+ getBackendSrv()
+ .delete('/api/auth/keys/' + id)
+ .then(dispatch(loadApiKeys()));
+ };
+}
+
+export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({
+ type: ActionTypes.SetApiKeysSearchQuery,
+ payload: searchQuery,
+});
diff --git a/public/app/features/api-keys/state/reducers.test.ts b/public/app/features/api-keys/state/reducers.test.ts
new file mode 100644
index 00000000000..3b2c831a5a3
--- /dev/null
+++ b/public/app/features/api-keys/state/reducers.test.ts
@@ -0,0 +1,31 @@
+import { Action, ActionTypes } from './actions';
+import { initialApiKeysState, apiKeysReducer } from './reducers';
+import { getMultipleMockKeys } from '../__mocks__/apiKeysMock';
+
+describe('API Keys reducer', () => {
+ it('should set keys', () => {
+ const payload = getMultipleMockKeys(4);
+
+ const action: Action = {
+ type: ActionTypes.LoadApiKeys,
+ payload,
+ };
+
+ const result = apiKeysReducer(initialApiKeysState, action);
+
+ expect(result.keys).toEqual(payload);
+ });
+
+ it('should set search query', () => {
+ const payload = 'test query';
+
+ const action: Action = {
+ type: ActionTypes.SetApiKeysSearchQuery,
+ payload,
+ };
+
+ const result = apiKeysReducer(initialApiKeysState, action);
+
+ expect(result.searchQuery).toEqual('test query');
+ });
+});
diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts
new file mode 100644
index 00000000000..57849b20d4f
--- /dev/null
+++ b/public/app/features/api-keys/state/reducers.ts
@@ -0,0 +1,22 @@
+import { ApiKeysState } from 'app/types';
+import { Action, ActionTypes } from './actions';
+
+export const initialApiKeysState: ApiKeysState = {
+ keys: [],
+ searchQuery: '',
+ hasFetched: false,
+};
+
+export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
+ switch (action.type) {
+ case ActionTypes.LoadApiKeys:
+ return { ...state, hasFetched: true, keys: action.payload };
+ case ActionTypes.SetApiKeysSearchQuery:
+ return { ...state, searchQuery: action.payload };
+ }
+ return state;
+};
+
+export default {
+ apiKeys: apiKeysReducer,
+};
diff --git a/public/app/features/api-keys/state/selectors.test.ts b/public/app/features/api-keys/state/selectors.test.ts
new file mode 100644
index 00000000000..5e9ba51462f
--- /dev/null
+++ b/public/app/features/api-keys/state/selectors.test.ts
@@ -0,0 +1,25 @@
+import { getApiKeys } from './selectors';
+import { getMultipleMockKeys } from '../__mocks__/apiKeysMock';
+import { ApiKeysState } from 'app/types';
+
+describe('API Keys selectors', () => {
+ describe('Get API Keys', () => {
+ const mockKeys = getMultipleMockKeys(5);
+
+ it('should return all keys if no search query', () => {
+ const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false };
+
+ const keys = getApiKeys(mockState);
+
+ expect(keys).toEqual(mockKeys);
+ });
+
+ it('should filter keys if search query exists', () => {
+ const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false };
+
+ const keys = getApiKeys(mockState);
+
+ expect(keys.length).toEqual(1);
+ });
+ });
+});
diff --git a/public/app/features/api-keys/state/selectors.ts b/public/app/features/api-keys/state/selectors.ts
new file mode 100644
index 00000000000..789237ae9a3
--- /dev/null
+++ b/public/app/features/api-keys/state/selectors.ts
@@ -0,0 +1,11 @@
+import { ApiKeysState } from 'app/types';
+
+export const getApiKeysCount = (state: ApiKeysState) => state.keys.length;
+
+export const getApiKeys = (state: ApiKeysState) => {
+ const regex = RegExp(state.searchQuery, 'i');
+
+ return state.keys.filter(key => {
+ return regex.test(key.name) || regex.test(key.role);
+ });
+};
diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts
index f75743513f1..5ec4e5e3929 100644
--- a/public/app/features/dashboard/all.ts
+++ b/public/app/features/dashboard/all.ts
@@ -22,7 +22,6 @@ import './export_data/export_data_modal';
import './ad_hoc_filters';
import './repeat_option/repeat_option';
import './dashgrid/DashboardGridDirective';
-import './dashgrid/PanelLoader';
import './dashgrid/RowOptions';
import './folder_picker/folder_picker';
import './move_to_folder_modal/move_to_folder';
diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts
index d61eb08d5b9..5871a579f3c 100644
--- a/public/app/features/dashboard/dashboard_ctrl.ts
+++ b/public/app/features/dashboard/dashboard_ctrl.ts
@@ -1,11 +1,16 @@
+// Utils
import config from 'app/core/config';
-
+import appEvents from 'app/core/app_events';
import coreModule from 'app/core/core_module';
-import { PanelContainer } from './dashgrid/PanelContainer';
+
+// Services
+import { AnnotationsSrv } from '../annotations/annotations_srv';
+
+// Types
import { DashboardModel } from './dashboard_model';
import { PanelModel } from './panel_model';
-export class DashboardCtrl implements PanelContainer {
+export class DashboardCtrl {
dashboard: DashboardModel;
dashboardViewState: any;
loadedFallbackDashboard: boolean;
@@ -22,8 +27,8 @@ export class DashboardCtrl implements PanelContainer {
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
- public playlistSrv,
- private panelLoader
+ private annotationsSrv: AnnotationsSrv,
+ public playlistSrv
) {
// temp hack due to way dashboards are loaded
// can't use controllerAs on route yet
@@ -51,6 +56,7 @@ export class DashboardCtrl implements PanelContainer {
// init services
this.timeSrv.init(dashboard);
this.alertingSrv.init(dashboard, data.alerts);
+ this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
@@ -74,7 +80,7 @@ export class DashboardCtrl implements PanelContainer {
this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
this.setWindowTitleAndTheme();
- this.$scope.appEvent('dashboard-initialized', dashboard);
+ appEvents.emit('dashboard-initialized', dashboard);
})
.catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
}
@@ -119,14 +125,6 @@ export class DashboardCtrl implements PanelContainer {
return this.dashboard;
}
- getPanelLoader() {
- return this.panelLoader;
- }
-
- timezoneChanged() {
- this.$rootScope.$broadcast('refresh');
- }
-
getPanelContainer() {
return this;
}
@@ -168,10 +166,17 @@ export class DashboardCtrl implements PanelContainer {
this.dashboard.removePanel(panel);
}
+ onDestroy() {
+ if (this.dashboard) {
+ this.dashboard.destroy();
+ }
+ }
+
init(dashboard) {
this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
+ this.$scope.$on('$destroy', this.onDestroy.bind(this));
this.setupDashboard(dashboard);
}
}
diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts
index 818765124bf..65a234a2b94 100644
--- a/public/app/features/dashboard/dashboard_model.ts
+++ b/public/app/features/dashboard/dashboard_model.ts
@@ -200,6 +200,43 @@ export class DashboardModel {
this.events.emit('view-mode-changed', panel);
}
+ timeRangeUpdated() {
+ this.events.emit('time-range-updated');
+ }
+
+ startRefresh() {
+ this.events.emit('refresh');
+
+ for (const panel of this.panels) {
+ if (!this.otherPanelInFullscreen(panel)) {
+ panel.refresh();
+ }
+ }
+ }
+
+ render() {
+ this.events.emit('render');
+
+ for (const panel of this.panels) {
+ panel.render();
+ }
+ }
+
+ panelInitialized(panel: PanelModel) {
+ if (!this.otherPanelInFullscreen(panel)) {
+ panel.refresh();
+ }
+ }
+
+ otherPanelInFullscreen(panel: PanelModel) {
+ return this.meta.fullscreen && !panel.fullscreen;
+ }
+
+ changePanelType(panel: PanelModel, pluginId: string) {
+ panel.changeType(pluginId);
+ this.events.emit('panel-type-changed', panel);
+ }
+
private ensureListExist(data) {
if (!data) {
data = {};
diff --git a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
index a26a0401d56..68cee112f42 100644
--- a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
@@ -3,7 +3,7 @@ import _ from 'lodash';
import classNames from 'classnames';
import config from 'app/core/config';
import { PanelModel } from '../panel_model';
-import { PanelContainer } from './PanelContainer';
+import { DashboardModel } from '../dashboard_model';
import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
@@ -11,7 +11,7 @@ import Highlighter from 'react-highlight-words';
export interface AddPanelPanelProps {
panel: PanelModel;
- getPanelContainer: () => PanelContainer;
+ dashboard: DashboardModel;
}
export interface AddPanelPanelState {
@@ -93,8 +93,7 @@ export class AddPanelPanel extends React.Component {
- const panelContainer = this.props.getPanelContainer();
- const dashboard = panelContainer.getDashboard();
+ const dashboard = this.props.dashboard;
const { gridPos } = this.props.panel;
const newPanel: any = {
@@ -123,9 +122,7 @@ export class AddPanelPanel extends React.Component 0 ? size.width : lastGridWidth;
if (width !== lastGridWidth) {
- onWidthChange();
- lastGridWidth = width;
+ if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
+ onWidthChange();
+ lastGridWidth = width;
+ }
}
return (
@@ -60,18 +58,15 @@ function GridWrapper({
const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
export interface DashboardGridProps {
- getPanelContainer: () => PanelContainer;
+ dashboard: DashboardModel;
}
export class DashboardGrid extends React.Component {
gridToPanelMap: any;
- panelContainer: PanelContainer;
- dashboard: DashboardModel;
panelMap: { [id: string]: PanelModel };
constructor(props) {
super(props);
- this.panelContainer = this.props.getPanelContainer();
this.onLayoutChange = this.onLayoutChange.bind(this);
this.onResize = this.onResize.bind(this);
this.onResizeStop = this.onResizeStop.bind(this);
@@ -81,20 +76,21 @@ export class DashboardGrid extends React.Component {
this.state = { animated: false };
// subscribe to dashboard events
- this.dashboard = this.panelContainer.getDashboard();
- this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
- this.dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
- this.dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
- this.dashboard.on('view-mode-changed', this.triggerForceUpdate.bind(this));
- this.dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
- this.dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
+ const dashboard = this.props.dashboard;
+ dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
+ dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
+ dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
+ dashboard.on('view-mode-changed', this.onViewModeChanged.bind(this));
+ dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
+ dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
+ dashboard.on('panel-type-changed', this.triggerForceUpdate.bind(this));
}
buildLayout() {
const layout = [];
this.panelMap = {};
- for (const panel of this.dashboard.panels) {
+ for (const panel of this.props.dashboard.panels) {
const stringId = panel.id.toString();
this.panelMap[stringId] = panel;
@@ -129,7 +125,7 @@ export class DashboardGrid extends React.Component {
this.panelMap[newPos.i].updateGridPos(newPos);
}
- this.dashboard.sortPanelsByGridPos();
+ this.props.dashboard.sortPanelsByGridPos();
}
triggerForceUpdate() {
@@ -137,11 +133,15 @@ export class DashboardGrid extends React.Component {
}
onWidthChange() {
- for (const panel of this.dashboard.panels) {
+ for (const panel of this.props.dashboard.panels) {
panel.resizeDone();
}
}
+ onViewModeChanged(payload) {
+ this.setState({ animated: !payload.fullscreen });
+ }
+
updateGridPos(item, layout) {
this.panelMap[item.i].updateGridPos(item);
@@ -165,21 +165,18 @@ export class DashboardGrid extends React.Component {
componentDidMount() {
setTimeout(() => {
- this.setState(() => {
- return { animated: true };
- });
+ this.setState({ animated: true });
});
}
renderPanels() {
const panelElements = [];
- for (const panel of this.dashboard.panels) {
+ for (const panel of this.props.dashboard.panels) {
const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen });
panelElements.push(
- /** panel-id is set for html bookmarks */
-
-
+
+
);
}
@@ -192,13 +189,14 @@ export class DashboardGrid extends React.Component
{
{this.renderPanels()}
diff --git a/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts b/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts
index 366b04a01f5..a8e4b0dcae9 100644
--- a/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts
+++ b/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts
@@ -1,6 +1,4 @@
import { react2AngularDirective } from 'app/core/utils/react2angular';
import { DashboardGrid } from './DashboardGrid';
-react2AngularDirective('dashboardGrid', DashboardGrid, [
- ['getPanelContainer', { watchDepth: 'reference', wrapApply: false }],
-]);
+react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]);
diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
index 27fe64d4660..7dd8a06996d 100644
--- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx
@@ -1,54 +1,161 @@
import React from 'react';
-import {PanelModel} from '../panel_model';
-import {PanelContainer} from './PanelContainer';
-import {AttachedPanel} from './PanelLoader';
-import {DashboardRow} from './DashboardRow';
-import {AddPanelPanel} from './AddPanelPanel';
+import config from 'app/core/config';
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import { DashboardRow } from './DashboardRow';
+import { AddPanelPanel } from './AddPanelPanel';
+import { importPluginModule } from 'app/features/plugins/plugin_loader';
+import { PluginExports, PanelPlugin } from 'app/types/plugins';
+import { PanelChrome } from './PanelChrome';
+import { PanelEditor } from './PanelEditor';
-export interface DashboardPanelProps {
+export interface Props {
+ panelType: string;
panel: PanelModel;
- getPanelContainer: () => PanelContainer;
+ dashboard: DashboardModel;
}
-export class DashboardPanel extends React.Component {
+export interface State {
+ pluginExports: PluginExports;
+}
+
+export class DashboardPanel extends React.Component {
element: any;
- attachedPanel: AttachedPanel;
+ angularPanel: AngularComponent;
+ pluginInfo: any;
+ specialPanels = {};
constructor(props) {
super(props);
- this.state = {};
+
+ this.state = {
+ pluginExports: null,
+ };
+
+ this.specialPanels['row'] = this.renderRow.bind(this);
+ this.specialPanels['add-panel'] = this.renderAddPanel.bind(this);
}
- componentDidMount() {
- if (!this.element) {
+ isSpecial() {
+ return this.specialPanels[this.props.panel.type];
+ }
+
+ renderRow() {
+ return ;
+ }
+
+ renderAddPanel() {
+ return ;
+ }
+
+ onPluginTypeChanged = (plugin: PanelPlugin) => {
+ this.props.panel.changeType(plugin.id);
+ this.loadPlugin();
+ };
+
+ onAngularPluginTypeChanged = () => {
+ this.loadPlugin();
+ };
+
+ loadPlugin() {
+ if (this.isSpecial()) {
return;
}
- const panelContainer = this.props.getPanelContainer();
- const dashboard = panelContainer.getDashboard();
- const loader = panelContainer.getPanelLoader();
- this.attachedPanel = loader.load(this.element, this.props.panel, dashboard);
+ // handle plugin loading & changing of plugin type
+ if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
+ this.pluginInfo = config.panels[this.props.panel.type];
+
+ if (this.pluginInfo.exports) {
+ this.cleanUpAngularPanel();
+ this.setState({ pluginExports: this.pluginInfo.exports });
+ } else {
+ importPluginModule(this.pluginInfo.module).then(pluginExports => {
+ this.cleanUpAngularPanel();
+ // cache plugin exports (saves a promise async cycle next time)
+ this.pluginInfo.exports = pluginExports;
+ // update panel state
+ this.setState({ pluginExports: pluginExports });
+ });
+ }
+ }
+ }
+
+ componentDidMount() {
+ this.loadPlugin();
+ }
+
+ componentDidUpdate() {
+ this.loadPlugin();
+
+ // handle angular plugin loading
+ if (!this.element || this.angularPanel) {
+ return;
+ }
+
+ const loader = getAngularLoader();
+ const template = ' ';
+ const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard };
+ this.angularPanel = loader.load(this.element, scopeProps, template);
+ }
+
+ cleanUpAngularPanel() {
+ if (this.angularPanel) {
+ this.angularPanel.destroy();
+ this.angularPanel = null;
+ }
}
componentWillUnmount() {
- if (this.attachedPanel) {
- this.attachedPanel.destroy();
- }
+ this.cleanUpAngularPanel();
+ }
+
+ renderReactPanel() {
+ const { pluginExports } = this.state;
+ const containerClass = this.props.panel.isEditing ? 'panel-editor-container' : 'panel-height-helper';
+ const panelWrapperClass = this.props.panel.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper';
+
+ // this might look strange with these classes that change when edit, but
+ // I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel
+ return (
+
+
+ {this.props.panel.isEditing && (
+
+ )}
+
+ );
}
render() {
- // special handling for rows
- if (this.props.panel.type === 'row') {
- return ;
+ if (this.isSpecial()) {
+ return this.specialPanels[this.props.panel.type]();
}
- if (this.props.panel.type === 'add-panel') {
- return ;
+ if (!this.state.pluginExports) {
+ return null;
}
- return (
- this.element = element} className="panel-height-helper" />
- );
+ if (this.state.pluginExports.PanelComponent) {
+ return this.renderReactPanel();
+ }
+
+ // legacy angular rendering
+ return
(this.element = element)} className="panel-height-helper" />;
}
}
-
diff --git a/public/app/features/dashboard/dashgrid/DashboardRow.tsx b/public/app/features/dashboard/dashgrid/DashboardRow.tsx
index 74630ac8f47..5b8ced9b2b1 100644
--- a/public/app/features/dashboard/dashgrid/DashboardRow.tsx
+++ b/public/app/features/dashboard/dashgrid/DashboardRow.tsx
@@ -1,19 +1,16 @@
import React from 'react';
import classNames from 'classnames';
import { PanelModel } from '../panel_model';
-import { PanelContainer } from './PanelContainer';
+import { DashboardModel } from '../dashboard_model';
import templateSrv from 'app/features/templating/template_srv';
import appEvents from 'app/core/app_events';
export interface DashboardRowProps {
panel: PanelModel;
- getPanelContainer: () => PanelContainer;
+ dashboard: DashboardModel;
}
export class DashboardRow extends React.Component
{
- dashboard: any;
- panelContainer: any;
-
constructor(props) {
super(props);
@@ -21,9 +18,6 @@ export class DashboardRow extends React.Component {
collapsed: this.props.panel.collapsed,
};
- this.panelContainer = this.props.getPanelContainer();
- this.dashboard = this.panelContainer.getDashboard();
-
this.toggle = this.toggle.bind(this);
this.openSettings = this.openSettings.bind(this);
this.delete = this.delete.bind(this);
@@ -31,7 +25,7 @@ export class DashboardRow extends React.Component {
}
toggle() {
- this.dashboard.toggleRow(this.props.panel);
+ this.props.dashboard.toggleRow(this.props.panel);
this.setState(prevState => {
return { collapsed: !prevState.collapsed };
@@ -39,7 +33,7 @@ export class DashboardRow extends React.Component {
}
update() {
- this.dashboard.processRepeats();
+ this.props.dashboard.processRepeats();
this.forceUpdate();
}
@@ -61,14 +55,10 @@ export class DashboardRow extends React.Component {
altActionText: 'Delete row only',
icon: 'fa-trash',
onConfirm: () => {
- const panelContainer = this.props.getPanelContainer();
- const dashboard = panelContainer.getDashboard();
- dashboard.removeRow(this.props.panel, true);
+ this.props.dashboard.removeRow(this.props.panel, true);
},
onAltAction: () => {
- const panelContainer = this.props.getPanelContainer();
- const dashboard = panelContainer.getDashboard();
- dashboard.removeRow(this.props.panel, false);
+ this.props.dashboard.removeRow(this.props.panel, false);
},
});
}
@@ -87,6 +77,7 @@ export class DashboardRow extends React.Component {
const title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars);
const count = this.props.panel.panels ? this.props.panel.panels.length : 0;
const panels = count === 1 ? 'panel' : 'panels';
+ const canEdit = this.props.dashboard.meta.canEdit === true;
return (
@@ -97,7 +88,7 @@ export class DashboardRow extends React.Component
{
({count} {panels})
- {this.dashboard.meta.canEdit === true && (
+ {canEdit && (
)}
-
+ {canEdit &&
}
);
}
diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx
new file mode 100644
index 00000000000..d0122363668
--- /dev/null
+++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx
@@ -0,0 +1,151 @@
+// Library
+import React, { Component } from 'react';
+
+// Services
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+
+// Types
+import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
+
+interface RenderProps {
+ loading: LoadingState;
+ timeSeries: TimeSeries[];
+}
+
+export interface Props {
+ datasource: string | null;
+ queries: any[];
+ panelId?: number;
+ dashboardId?: number;
+ isVisible?: boolean;
+ timeRange?: TimeRange;
+ refreshCounter: number;
+ children: (r: RenderProps) => JSX.Element;
+}
+
+export interface State {
+ isFirstLoad: boolean;
+ loading: LoadingState;
+ response: DataQueryResponse;
+}
+
+export class DataPanel extends Component {
+ static defaultProps = {
+ isVisible: true,
+ panelId: 1,
+ dashboardId: 1,
+ };
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ loading: LoadingState.NotStarted,
+ response: {
+ data: [],
+ },
+ isFirstLoad: true,
+ };
+ }
+
+ componentDidMount() {
+ console.log('DataPanel mount');
+ }
+
+ async componentDidUpdate(prevProps: Props) {
+ if (!this.hasPropsChanged(prevProps)) {
+ return;
+ }
+
+ this.issueQueries();
+ }
+
+ hasPropsChanged(prevProps: Props) {
+ return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible;
+ }
+
+ issueQueries = async () => {
+ const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props;
+
+ if (!isVisible) {
+ return;
+ }
+
+ if (!queries.length) {
+ this.setState({ loading: LoadingState.Done });
+ return;
+ }
+
+ this.setState({ loading: LoadingState.Loading });
+
+ try {
+ const dataSourceSrv = getDatasourceSrv();
+ const ds = await dataSourceSrv.get(datasource);
+
+ const queryOptions: DataQueryOptions = {
+ timezone: 'browser',
+ panelId: panelId,
+ dashboardId: dashboardId,
+ range: timeRange,
+ rangeRaw: timeRange.raw,
+ interval: '1s',
+ intervalMs: 60000,
+ targets: queries,
+ maxDataPoints: 500,
+ scopedVars: {},
+ cacheTimeout: null,
+ };
+
+ console.log('Issuing DataPanel query', queryOptions);
+ const resp = await ds.query(queryOptions);
+ console.log('Issuing DataPanel query Resp', resp);
+
+ this.setState({
+ loading: LoadingState.Done,
+ response: resp,
+ isFirstLoad: false,
+ });
+ } catch (err) {
+ console.log('Loading error', err);
+ this.setState({ loading: LoadingState.Error, isFirstLoad: false });
+ }
+ };
+
+ render() {
+ const { response, loading, isFirstLoad } = this.state;
+ console.log('data panel render');
+ const timeSeries = response.data;
+
+ if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {this.loadingSpinner}
+ {this.props.children({
+ timeSeries,
+ loading,
+ })}
+ >
+ );
+ }
+
+ private get loadingSpinner(): JSX.Element {
+ const { loading } = this.state;
+
+ if (loading === LoadingState.Loading) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ }
+}
diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx
new file mode 100644
index 00000000000..82b366d8126
--- /dev/null
+++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx
@@ -0,0 +1,84 @@
+// Libraries
+import React, { ComponentClass, PureComponent } from 'react';
+
+// Services
+import { getTimeSrv } from '../time_srv';
+
+// Components
+import { PanelHeader } from './PanelHeader';
+import { DataPanel } from './DataPanel';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { TimeRange, PanelProps } from 'app/types';
+
+export interface Props {
+ panel: PanelModel;
+ dashboard: DashboardModel;
+ component: ComponentClass;
+}
+
+export interface State {
+ refreshCounter: number;
+ timeRange?: TimeRange;
+}
+
+export class PanelChrome extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ refreshCounter: 0,
+ };
+ }
+
+ componentDidMount() {
+ this.props.panel.events.on('refresh', this.onRefresh);
+ this.props.dashboard.panelInitialized(this.props.panel);
+ }
+
+ componentWillUnmount() {
+ this.props.panel.events.off('refresh', this.onRefresh);
+ }
+
+ onRefresh = () => {
+ const timeSrv = getTimeSrv();
+ const timeRange = timeSrv.timeRange();
+
+ this.setState({
+ refreshCounter: this.state.refreshCounter + 1,
+ timeRange: timeRange,
+ });
+ };
+
+ get isVisible() {
+ return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
+ }
+
+ render() {
+ const { panel, dashboard } = this.props;
+ const { datasource, targets } = panel;
+ const { refreshCounter, timeRange } = this.state;
+ const PanelComponent = this.props.component;
+
+ return (
+
+
+
+
+ {({ loading, timeSeries }) => {
+ return ;
+ }}
+
+
+
+ );
+ }
+}
diff --git a/public/app/features/dashboard/dashgrid/PanelContainer.ts b/public/app/features/dashboard/dashgrid/PanelContainer.ts
deleted file mode 100644
index 87f3235a176..00000000000
--- a/public/app/features/dashboard/dashgrid/PanelContainer.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { DashboardModel } from '../dashboard_model';
-import { PanelLoader } from './PanelLoader';
-
-export interface PanelContainer {
- getPanelLoader(): PanelLoader;
- getDashboard(): DashboardModel;
-}
diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/dashgrid/PanelEditor.tsx
new file mode 100644
index 00000000000..26ac8b7d2c1
--- /dev/null
+++ b/public/app/features/dashboard/dashgrid/PanelEditor.tsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import classNames from 'classnames';
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { store } from 'app/store/configureStore';
+import { QueriesTab } from './QueriesTab';
+import { PanelPlugin, PluginExports } from 'app/types/plugins';
+import { VizTypePicker } from './VizTypePicker';
+import { updateLocation } from 'app/core/actions';
+
+interface PanelEditorProps {
+ panel: PanelModel;
+ dashboard: DashboardModel;
+ panelType: string;
+ pluginExports: PluginExports;
+ onTypeChanged: (newType: PanelPlugin) => void;
+}
+
+interface PanelEditorTab {
+ id: string;
+ text: string;
+ icon: string;
+}
+
+export class PanelEditor extends React.Component {
+ tabs: PanelEditorTab[];
+
+ constructor(props) {
+ super(props);
+
+ this.tabs = [
+ { id: 'queries', text: 'Queries', icon: 'fa fa-database' },
+ { id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' },
+ ];
+ }
+
+ renderQueriesTab() {
+ return ;
+ }
+
+ renderPanelOptions() {
+ const { pluginExports } = this.props;
+
+ if (pluginExports.PanelOptions) {
+ const PanelOptions = pluginExports.PanelOptions;
+ return ;
+ } else {
+ return Visualization has no options
;
+ }
+ }
+
+ renderVizTab() {
+ return (
+
+
+
+
+
+
Options
+ {this.renderPanelOptions()}
+
+
+ );
+ }
+
+ onChangeTab = (tab: PanelEditorTab) => {
+ store.dispatch(
+ updateLocation({
+ query: { tab: tab.id },
+ partial: true,
+ })
+ );
+ };
+
+ render() {
+ const { location } = store.getState();
+ const activeTab = location.query.tab || 'queries';
+
+ return (
+
+
+
+ {this.tabs.map(tab => {
+ return ;
+ })}
+
+
+
+
+
+
+
+
+ {activeTab === 'queries' && this.renderQueriesTab()}
+ {activeTab === 'visualization' && this.renderVizTab()}
+
+
+ );
+ }
+}
+
+interface TabItemParams {
+ tab: PanelEditorTab;
+ activeTab: string;
+ onClick: (tab: PanelEditorTab) => void;
+}
+
+function TabItem({ tab, activeTab, onClick }: TabItemParams) {
+ const tabClasses = classNames({
+ 'gf-tabs-link': true,
+ active: activeTab === tab.id,
+ });
+
+ return (
+
+ onClick(tab)}>
+ {tab.text}
+
+
+ );
+}
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader.tsx
new file mode 100644
index 00000000000..12d5cd37253
--- /dev/null
+++ b/public/app/features/dashboard/dashgrid/PanelHeader.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import classNames from 'classnames';
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { store } from 'app/store/configureStore';
+import { updateLocation } from 'app/core/actions';
+
+interface PanelHeaderProps {
+ panel: PanelModel;
+ dashboard: DashboardModel;
+}
+
+export class PanelHeader extends React.Component {
+ onEditPanel = () => {
+ store.dispatch(
+ updateLocation({
+ query: {
+ panelId: this.props.panel.id,
+ edit: true,
+ fullscreen: true,
+ },
+ })
+ );
+ };
+
+ onViewPanel = () => {
+ store.dispatch(
+ updateLocation({
+ query: {
+ panelId: this.props.panel.id,
+ edit: false,
+ fullscreen: true,
+ },
+ })
+ );
+ };
+
+ render() {
+ const isFullscreen = false;
+ const isLoading = false;
+ const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
+
+ return (
+
+
+
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+
+
+
+ {this.props.panel.title}
+
+
+
+
+
+ 4m
+
+
+
+
+ );
+ }
+}
diff --git a/public/app/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx
new file mode 100644
index 00000000000..f13f212826a
--- /dev/null
+++ b/public/app/features/dashboard/dashgrid/QueriesTab.tsx
@@ -0,0 +1,53 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Services & utils
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+
+interface Props {
+ panel: PanelModel;
+ dashboard: DashboardModel;
+}
+
+export class QueriesTab extends PureComponent {
+ element: any;
+ component: AngularComponent;
+
+ constructor(props) {
+ super(props);
+ }
+
+ componentDidMount() {
+ if (!this.element) {
+ return;
+ }
+
+ const { panel, dashboard } = this.props;
+
+ const loader = getAngularLoader();
+ const template = ' ';
+ const scopeProps = {
+ ctrl: {
+ panel: panel,
+ dashboard: dashboard,
+ refresh: () => panel.refresh(),
+ },
+ };
+
+ this.component = loader.load(this.element, scopeProps, template);
+ }
+
+ componentWillUnmount() {
+ if (this.component) {
+ this.component.destroy();
+ }
+ }
+
+ render() {
+ return (this.element = element)} className="panel-height-helper" />;
+ }
+}
diff --git a/public/app/features/dashboard/dashgrid/VizTypePicker.tsx b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx
new file mode 100644
index 00000000000..9402133df34
--- /dev/null
+++ b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx
@@ -0,0 +1,69 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import config from 'app/core/config';
+import { PanelPlugin } from 'app/types/plugins';
+import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
+import _ from 'lodash';
+
+interface Props {
+ currentType: string;
+ onTypeChanged: (newType: PanelPlugin) => void;
+}
+
+interface State {
+ pluginList: PanelPlugin[];
+}
+
+export class VizTypePicker extends PureComponent
{
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ pluginList: this.getPanelPlugins(''),
+ };
+ }
+
+ getPanelPlugins(filter) {
+ const panels = _.chain(config.panels)
+ .filter({ hideFromList: false })
+ .map(item => item)
+ .value();
+
+ // add sort by sort property
+ return _.sortBy(panels, 'sort');
+ }
+
+ renderVizPlugin = (plugin, index) => {
+ const cssClass = classNames({
+ 'viz-picker__item': true,
+ 'viz-picker__item--selected': plugin.id === this.props.currentType,
+ });
+
+ return (
+ this.props.onTypeChanged(plugin)} title={plugin.name}>
+
+
{plugin.name}
+
+ );
+ };
+
+ render() {
+ return (
+
+
+
+
+ {this.state.pluginList.map(this.renderVizPlugin)}
+
+
+
+ );
+ }
+}
diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts
index c4095e7948b..7312d6db784 100644
--- a/public/app/features/dashboard/dashnav/dashnav.ts
+++ b/public/app/features/dashboard/dashnav/dashnav.ts
@@ -42,6 +42,8 @@ export class DashNavCtrl {
} else if (search.fullscreen) {
delete search.fullscreen;
delete search.edit;
+ delete search.tab;
+ delete search.panelId;
}
this.$location.search(search);
}
diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts
index 9a1e7fb9200..d82368d8dd7 100644
--- a/public/app/features/dashboard/panel_model.ts
+++ b/public/app/features/dashboard/panel_model.ts
@@ -13,6 +13,13 @@ const notPersistedProperties: { [str: string]: boolean } = {
events: true,
fullscreen: true,
isEditing: true,
+ hasRefreshed: true,
+};
+
+const defaults: any = {
+ gridPos: { x: 0, y: 0, h: 3, w: 6 },
+ datasource: null,
+ targets: [{}],
};
export class PanelModel {
@@ -31,10 +38,14 @@ export class PanelModel {
collapsed?: boolean;
panels?: any;
soloMode?: boolean;
+ targets: any[];
+ datasource: string;
+ thresholds?: any;
// non persisted
fullscreen: boolean;
isEditing: boolean;
+ hasRefreshed: boolean;
events: Emitter;
constructor(model) {
@@ -45,9 +56,8 @@ export class PanelModel {
this[property] = model[property];
}
- if (!this.gridPos) {
- this.gridPos = { x: 0, y: 0, h: 3, w: 6 };
- }
+ // defaults
+ _.defaultsDeep(this, _.cloneDeep(defaults));
}
getSaveModel() {
@@ -57,6 +67,10 @@ export class PanelModel {
continue;
}
+ if (_.isEqual(this[property], defaults[property])) {
+ continue;
+ }
+
model[property] = _.cloneDeep(this[property]);
}
@@ -82,7 +96,6 @@ export class PanelModel {
this.gridPos.h = newPos.h;
if (sizeChanged) {
- console.log('PanelModel sizeChanged event and render events fired');
this.events.emit('panel-size-changed');
}
}
@@ -91,7 +104,36 @@ export class PanelModel {
this.events.emit('panel-size-changed');
}
+ refresh() {
+ this.hasRefreshed = true;
+ this.events.emit('refresh');
+ }
+
+ render() {
+ if (!this.hasRefreshed) {
+ this.refresh();
+ } else {
+ this.events.emit('render');
+ }
+ }
+
+ panelInitialized() {
+ this.events.emit('panel-initialized');
+ }
+
+ initEditMode() {
+ this.events.emit('panel-init-edit-mode');
+ }
+
+ changeType(pluginId: string) {
+ this.type = pluginId;
+
+ delete this.thresholds;
+ delete this.alert;
+ }
+
destroy() {
+ this.events.emit('panel-teardown');
this.events.removeAllListeners();
}
}
diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/permissions/DashboardPermissions.tsx
index 5651242a485..c07bef42930 100644
--- a/public/app/features/dashboard/permissions/DashboardPermissions.tsx
+++ b/public/app/features/dashboard/permissions/DashboardPermissions.tsx
@@ -1,5 +1,4 @@
import React, { PureComponent } from 'react';
-import { connect } from 'react-redux';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import SlideDown from 'app/core/components/Animations/SlideDown';
import { StoreState, FolderInfo } from 'app/types';
@@ -13,7 +12,7 @@ import {
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 { store } from 'app/store/configureStore';
+import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
export interface Props {
dashboardId: number;
@@ -95,13 +94,6 @@ export class DashboardPermissions extends PureComponent {
}
}
-function connectWithStore(WrappedComponent, ...args) {
- const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
- return props => {
- return ;
- };
-}
-
const mapStateToProps = (state: StoreState) => ({
permissions: state.dashboard.permissions,
});
diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/settings/settings.ts
index 048a51efead..b6a70ee4b98 100755
--- a/public/app/features/dashboard/settings/settings.ts
+++ b/public/app/features/dashboard/settings/settings.ts
@@ -32,7 +32,7 @@ export class SettingsCtrl {
this.$scope.$on('$destroy', () => {
this.dashboard.updateSubmenuVisibility();
- this.$rootScope.$broadcast('refresh');
+ this.dashboard.startRefresh();
setTimeout(() => {
this.$rootScope.appEvent('dash-scroll', { restore: true });
});
diff --git a/public/app/features/dashboard/share_snapshot_ctrl.ts b/public/app/features/dashboard/share_snapshot_ctrl.ts
index ec487801948..ac09d63054d 100644
--- a/public/app/features/dashboard/share_snapshot_ctrl.ts
+++ b/public/app/features/dashboard/share_snapshot_ctrl.ts
@@ -46,8 +46,7 @@ export class ShareSnapshotCtrl {
$scope.loading = true;
$scope.snapshot.external = external;
-
- $rootScope.$broadcast('refresh');
+ $scope.dashboard.startRefresh();
$timeout(() => {
$scope.saveSnapshot(external);
diff --git a/public/app/features/dashboard/specs/AddPanelPanel.test.tsx b/public/app/features/dashboard/specs/AddPanelPanel.test.tsx
index 872d9296d12..c5f66fed32a 100644
--- a/public/app/features/dashboard/specs/AddPanelPanel.test.tsx
+++ b/public/app/features/dashboard/specs/AddPanelPanel.test.tsx
@@ -14,7 +14,7 @@ jest.mock('app/core/store', () => ({
}));
describe('AddPanelPanel', () => {
- let wrapper, dashboardMock, getPanelContainer, panel;
+ let wrapper, dashboardMock, panel;
beforeEach(() => {
config.panels = [
@@ -23,6 +23,9 @@ describe('AddPanelPanel', () => {
hideFromList: false,
name: 'Singlestat',
sort: 2,
+ module: '',
+ baseUrl: '',
+ meta: {},
info: {
logos: {
small: '',
@@ -34,6 +37,9 @@ describe('AddPanelPanel', () => {
hideFromList: true,
name: 'Hidden',
sort: 100,
+ meta: {},
+ module: '',
+ baseUrl: '',
info: {
logos: {
small: '',
@@ -45,6 +51,9 @@ describe('AddPanelPanel', () => {
hideFromList: false,
name: 'Graph',
sort: 1,
+ meta: {},
+ module: '',
+ baseUrl: '',
info: {
logos: {
small: '',
@@ -56,6 +65,9 @@ describe('AddPanelPanel', () => {
hideFromList: false,
name: 'Zabbix',
sort: 100,
+ meta: {},
+ module: '',
+ baseUrl: '',
info: {
logos: {
small: '',
@@ -67,6 +79,9 @@ describe('AddPanelPanel', () => {
hideFromList: false,
name: 'Piechart',
sort: 100,
+ meta: {},
+ module: '',
+ baseUrl: '',
info: {
logos: {
small: '',
@@ -77,13 +92,8 @@ describe('AddPanelPanel', () => {
dashboardMock = { toggleRow: jest.fn() };
- getPanelContainer = jest.fn().mockReturnValue({
- getDashboard: jest.fn().mockReturnValue(dashboardMock),
- getPanelLoader: jest.fn(),
- });
-
panel = new PanelModel({ collapsed: false });
- wrapper = shallow( );
+ wrapper = shallow( );
});
it('should fetch all panels sorted with core plugins first', () => {
diff --git a/public/app/features/dashboard/specs/DashboardRow.test.tsx b/public/app/features/dashboard/specs/DashboardRow.test.tsx
index 8424346b0c5..77c6cb39d9d 100644
--- a/public/app/features/dashboard/specs/DashboardRow.test.tsx
+++ b/public/app/features/dashboard/specs/DashboardRow.test.tsx
@@ -4,7 +4,7 @@ import { DashboardRow } from '../dashgrid/DashboardRow';
import { PanelModel } from '../panel_model';
describe('DashboardRow', () => {
- let wrapper, panel, getPanelContainer, dashboardMock;
+ let wrapper, panel, dashboardMock;
beforeEach(() => {
dashboardMock = {
@@ -14,13 +14,8 @@ describe('DashboardRow', () => {
},
};
- getPanelContainer = jest.fn().mockReturnValue({
- getDashboard: jest.fn().mockReturnValue(dashboardMock),
- getPanelLoader: jest.fn(),
- });
-
panel = new PanelModel({ collapsed: false });
- wrapper = shallow( );
+ wrapper = shallow( );
});
it('Should not have collapsed class when collaped is false', () => {
@@ -39,10 +34,16 @@ describe('DashboardRow', () => {
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2);
});
+ it('should not show row drag handle when cannot edit', () => {
+ dashboardMock.meta.canEdit = false;
+ wrapper = shallow( );
+ expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0);
+ });
+
it('should have zero actions when cannot edit', () => {
dashboardMock.meta.canEdit = false;
panel = new PanelModel({ collapsed: false });
- wrapper = shallow( );
+ wrapper = shallow( );
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0);
});
});
diff --git a/public/app/features/dashboard/specs/exporter.test.ts b/public/app/features/dashboard/specs/exporter.test.ts
index c7a232f925b..f21e151f3dd 100644
--- a/public/app/features/dashboard/specs/exporter.test.ts
+++ b/public/app/features/dashboard/specs/exporter.test.ts
@@ -240,5 +240,5 @@ stubs['-- Grafana --'] = {
};
function getStub(arg) {
- return Promise.resolve(stubs[arg]);
+ return Promise.resolve(stubs[arg || 'gfdb']);
}
diff --git a/public/app/features/dashboard/specs/time_srv.test.ts b/public/app/features/dashboard/specs/time_srv.test.ts
index 514e0b90792..db0d11f2ebe 100644
--- a/public/app/features/dashboard/specs/time_srv.test.ts
+++ b/public/app/features/dashboard/specs/time_srv.test.ts
@@ -29,6 +29,7 @@ describe('timeSrv', () => {
beforeEach(() => {
timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
timeSrv.init(_dashboard);
+ _dashboard.refresh = false;
});
describe('timeRange', () => {
@@ -79,6 +80,23 @@ describe('timeSrv', () => {
expect(time.to.valueOf()).toEqual(new Date('2014-05-20T03:10:22Z').getTime());
});
+ it('should ignore refresh if time absolute', () => {
+ location = {
+ search: jest.fn(() => ({
+ from: '20140410T052010',
+ to: '20140520T031022',
+ })),
+ };
+
+ timeSrv = new TimeSrv(rootScope, jest.fn(), location, timer, { isGrafanaVisibile: jest.fn() });
+
+ // dashboard saved with refresh on
+ _dashboard.refresh = true;
+ timeSrv.init(_dashboard);
+
+ expect(timeSrv.refresh).toBe(false);
+ });
+
it('should handle formatted dates without time', () => {
location = {
search: jest.fn(() => ({
diff --git a/public/app/features/dashboard/specs/viewstate_srv.test.ts b/public/app/features/dashboard/specs/viewstate_srv.test.ts
index 905ffb8b355..f9963afbf85 100644
--- a/public/app/features/dashboard/specs/viewstate_srv.test.ts
+++ b/public/app/features/dashboard/specs/viewstate_srv.test.ts
@@ -2,6 +2,7 @@
import 'app/features/dashboard/view_state_srv';
import config from 'app/core/config';
import { DashboardViewState } from '../view_state_srv';
+import { DashboardModel } from '../dashboard_model';
describe('when updating view state', () => {
const location = {
@@ -10,14 +11,13 @@ describe('when updating view state', () => {
};
const $scope = {
+ appEvent: jest.fn(),
onAppEvent: jest.fn(() => {}),
- dashboard: {
- meta: {},
- panels: [],
- },
+ dashboard: new DashboardModel({
+ panels: [{ id: 1 }],
+ }),
};
- const $rootScope = {};
let viewState;
beforeEach(() => {
@@ -33,7 +33,7 @@ describe('when updating view state', () => {
location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 };
});
- viewState = new DashboardViewState($scope, location, {}, $rootScope);
+ viewState = new DashboardViewState($scope, location, {});
});
it('should update querystring and view state', () => {
@@ -55,7 +55,7 @@ describe('when updating view state', () => {
describe('to fullscreen false', () => {
beforeEach(() => {
- viewState = new DashboardViewState($scope, location, {}, $rootScope);
+ viewState = new DashboardViewState($scope, location, {});
});
it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true });
diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts
index 82333817b2b..bc35ff31ff0 100644
--- a/public/app/features/dashboard/state/actions.ts
+++ b/public/app/features/dashboard/state/actions.ts
@@ -1,7 +1,8 @@
import { StoreState } from 'app/types';
import { ThunkAction } from 'redux-thunk';
import { getBackendSrv } from 'app/core/services/backend_srv';
-
+import appEvents from 'app/core/app_events';
+import { loadPluginDashboards } from '../../plugins/state/actions';
import {
DashboardAcl,
DashboardAclDTO,
@@ -12,6 +13,7 @@ import {
export enum ActionTypes {
LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS',
+ LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
}
export interface LoadDashboardPermissionsAction {
@@ -19,7 +21,12 @@ export interface LoadDashboardPermissionsAction {
payload: DashboardAcl[];
}
-export type Action = LoadDashboardPermissionsAction;
+export interface LoadStarredDashboardsAction {
+ type: ActionTypes.LoadStarredDashboards;
+ payload: DashboardAcl[];
+}
+
+export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
type ThunkResult = ThunkAction;
@@ -58,7 +65,7 @@ export function updateDashboardPermission(
continue;
}
- const updated = toUpdateItem(itemToUpdate);
+ const updated = toUpdateItem(item);
// if this is the item we want to update, update it's permisssion
if (itemToUpdate === item) {
@@ -113,3 +120,18 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
await dispatch(getDashboardPermissions(dashboardId));
};
}
+
+export function importDashboard(data, dashboardTitle: string): ThunkResult {
+ return async dispatch => {
+ await getBackendSrv().post('/api/dashboards/import', data);
+ appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
+ dispatch(loadPluginDashboards());
+ };
+}
+
+export function removeDashboard(uri: string): ThunkResult {
+ return async dispatch => {
+ await getBackendSrv().delete(`/api/dashboards/${uri}`);
+ dispatch(loadPluginDashboards());
+ };
+}
diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts
index c5b67f58ac9..ced8866aad8 100644
--- a/public/app/features/dashboard/state/reducers.test.ts
+++ b/public/app/features/dashboard/state/reducers.test.ts
@@ -1,6 +1,6 @@
import { Action, ActionTypes } from './actions';
import { OrgRole, PermissionLevel, DashboardState } from 'app/types';
-import { inititalState, dashboardReducer } from './reducers';
+import { initialState, dashboardReducer } from './reducers';
describe('dashboard reducer', () => {
describe('loadDashboardPermissions', () => {
@@ -14,7 +14,7 @@ describe('dashboard reducer', () => {
{ id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit },
],
};
- state = dashboardReducer(inititalState, action);
+ state = dashboardReducer(initialState, action);
});
it('should add permissions to state', async () => {
diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts
index 5100529d973..8a79a6c9f77 100644
--- a/public/app/features/dashboard/state/reducers.ts
+++ b/public/app/features/dashboard/state/reducers.ts
@@ -2,11 +2,11 @@ import { DashboardState } from 'app/types';
import { Action, ActionTypes } from './actions';
import { processAclItems } from 'app/core/utils/acl';
-export const inititalState: DashboardState = {
+export const initialState: DashboardState = {
permissions: [],
};
-export const dashboardReducer = (state = inititalState, action: Action): DashboardState => {
+export const dashboardReducer = (state = initialState, action: Action): DashboardState => {
switch (action.type) {
case ActionTypes.LoadDashboardPermissions:
return {
diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/submenu/submenu.html
index f240a86efba..d7cee33e6c3 100644
--- a/public/app/features/dashboard/submenu/submenu.html
+++ b/public/app/features/dashboard/submenu/submenu.html
@@ -4,7 +4,8 @@
{{variable.label || variable.name}}
-
+
+
diff --git a/public/app/features/dashboard/submenu/submenu.ts b/public/app/features/dashboard/submenu/submenu.ts
index e1288b2b2ed..184d29facee 100644
--- a/public/app/features/dashboard/submenu/submenu.ts
+++ b/public/app/features/dashboard/submenu/submenu.ts
@@ -7,13 +7,13 @@ export class SubmenuCtrl {
dashboard: any;
/** @ngInject */
- constructor(private $rootScope, private variableSrv, private $location) {
+ constructor(private variableSrv, private $location) {
this.annotations = this.dashboard.templating.list;
this.variables = this.variableSrv.variables;
}
annotationStateChanged() {
- this.$rootScope.$broadcast('refresh');
+ this.dashboard.startRefresh();
}
variableUpdated(variable) {
diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/time_srv.ts
index dd5a0ba758f..03b4a408125 100644
--- a/public/app/features/dashboard/time_srv.ts
+++ b/public/app/features/dashboard/time_srv.ts
@@ -1,8 +1,14 @@
+// Libraries
import moment from 'moment';
import _ from 'lodash';
-import coreModule from 'app/core/core_module';
+
+// Utils
import kbn from 'app/core/utils/kbn';
+import coreModule from 'app/core/core_module';
import * as dateMath from 'app/core/utils/datemath';
+// Types
+
+import { TimeRange } from 'app/types';
export class TimeSrv {
time: any;
@@ -24,7 +30,6 @@ export class TimeSrv {
document.addEventListener('visibilitychange', () => {
if (this.autoRefreshBlocked && document.visibilityState === 'visible') {
this.autoRefreshBlocked = false;
-
this.refreshDashboard();
}
});
@@ -85,6 +90,12 @@ export class TimeSrv {
if (params.to) {
this.time.to = this.parseUrlParam(params.to) || this.time.to;
}
+ // if absolute ignore refresh option saved to dashboard
+ if (params.to && params.to.indexOf('now') === -1) {
+ this.refresh = false;
+ this.dashboard.refresh = false;
+ }
+ // but if refresh explicitly set then use that
if (params.refresh) {
this.refresh = params.refresh || this.refresh;
}
@@ -107,7 +118,7 @@ export class TimeSrv {
}
private timeHasChangedSinceLoad() {
- return this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to;
+ return this.timeAtLoad && (this.timeAtLoad.from !== this.time.from || this.timeAtLoad.to !== this.time.to);
}
setAutoRefresh(interval) {
@@ -136,7 +147,7 @@ export class TimeSrv {
}
refreshDashboard() {
- this.$rootScope.$broadcast('refresh');
+ this.dashboard.timeRangeUpdated();
}
private startNextRefreshTimer(afterMs) {
@@ -195,7 +206,7 @@ export class TimeSrv {
return range;
}
- timeRange() {
+ timeRange(): TimeRange {
// make copies if they are moment (do not want to return out internal moment, because they are mutable!)
const raw = {
from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from,
@@ -217,17 +228,21 @@ export class TimeSrv {
const timespan = range.to.valueOf() - range.from.valueOf();
const center = range.to.valueOf() - timespan / 2;
- let to = center + timespan * factor / 2;
- let from = center - timespan * factor / 2;
-
- if (to > Date.now() && range.to <= Date.now()) {
- const offset = to - Date.now();
- from = from - offset;
- to = Date.now();
- }
+ const to = center + timespan * factor / 2;
+ const from = center - timespan * factor / 2;
this.setTime({ from: moment.utc(from), to: moment.utc(to) });
}
}
+let singleton;
+
+export function setTimeSrv(srv: TimeSrv) {
+ singleton = srv;
+}
+
+export function getTimeSrv(): TimeSrv {
+ return singleton;
+}
+
coreModule.service('timeSrv', TimeSrv);
diff --git a/public/app/features/dashboard/timepicker/settings.html b/public/app/features/dashboard/timepicker/settings.html
index 3cb8ca061fb..fd5170013c2 100644
--- a/public/app/features/dashboard/timepicker/settings.html
+++ b/public/app/features/dashboard/timepicker/settings.html
@@ -5,7 +5,7 @@
diff --git a/public/app/features/dashboard/timepicker/timepicker.ts b/public/app/features/dashboard/timepicker/timepicker.ts
index c133203cefc..c89e49b54b3 100644
--- a/public/app/features/dashboard/timepicker/timepicker.ts
+++ b/public/app/features/dashboard/timepicker/timepicker.ts
@@ -31,9 +31,10 @@ export class TimePickerCtrl {
$rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
$rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope);
- $rootScope.onAppEvent('refresh', this.onRefresh.bind(this), $scope);
$rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope);
+ this.dashboard.on('refresh', this.onRefresh.bind(this), $scope);
+
// init options
this.panel = this.dashboard.timepicker;
_.defaults(this.panel, TimePickerCtrl.defaults);
diff --git a/public/app/features/dashboard/upload.ts b/public/app/features/dashboard/upload.ts
index 974a0c35cd2..ec4ad9a03cb 100644
--- a/public/app/features/dashboard/upload.ts
+++ b/public/app/features/dashboard/upload.ts
@@ -1,22 +1,27 @@
import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import angular from 'angular';
const template = `
-
+
- Upload .json File
+ {{btnText}}
`;
/** @ngInject */
-function uploadDashboardDirective(timer, alertSrv, $location) {
+function uploadDashboardDirective(timer, $location) {
return {
restrict: 'E',
template: template,
scope: {
onUpload: '&',
+ btnText: '@?',
},
- link: scope => {
+ link: (scope, elem) => {
+ scope.btnText = angular.isDefined(scope.btnText) ? scope.btnText : 'Upload .json File';
+
function file_selected(evt) {
const files = evt.target.files; // FileList object
const readerOnload = () => {
@@ -26,7 +31,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
dash = JSON.parse(e.target.result);
} catch (err) {
console.log(err);
- scope.appEvent('alert-error', ['Import failed', 'JSON -> JS Serialization failed: ' + err.message]);
+ appEvents.emit('alert-error', ['Import failed', 'JSON -> JS Serialization failed: ' + err.message]);
return;
}
@@ -52,9 +57,9 @@ function uploadDashboardDirective(timer, alertSrv, $location) {
// Check for the various File API support.
if (wnd.File && wnd.FileReader && wnd.FileList && wnd.Blob) {
// Something
- document.getElementById('dashupload').addEventListener('change', file_selected, false);
+ elem[0].addEventListener('change', file_selected, false);
} else {
- alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error');
+ appEvents.emit('alert-error', ['Oops', 'The HTML5 File APIs are not fully supported in this browser']);
}
},
};
diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/view_state_srv.ts
index d9ad6827567..8805050831e 100644
--- a/public/app/features/dashboard/view_state_srv.ts
+++ b/public/app/features/dashboard/view_state_srv.ts
@@ -1,6 +1,7 @@
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';
// represents the transient view state
@@ -10,12 +11,11 @@ export class DashboardViewState {
panelScopes: any;
$scope: any;
dashboard: DashboardModel;
- editStateChanged: any;
fullscreenPanel: any;
oldTimeRange: any;
/** @ngInject */
- constructor($scope, private $location, private $timeout, private $rootScope) {
+ constructor($scope, private $location, private $timeout) {
const self = this;
self.state = {};
self.panelScopes = [];
@@ -33,10 +33,6 @@ export class DashboardViewState {
self.update(payload);
});
- $scope.onAppEvent('panel-initialized', (evt, payload) => {
- self.registerPanel(payload.scope);
- });
-
// this marks changes to location during this digest cycle as not to add history item
// don't want url changes like adding orgId to add browser history
$location.replace();
@@ -75,9 +71,6 @@ export class DashboardViewState {
}
}
- // remember if editStateChanged
- this.editStateChanged = (state.edit || false) !== (this.state.edit || false);
-
_.extend(this.state, state);
this.dashboard.meta.fullscreen = this.state.fullscreen;
@@ -124,110 +117,59 @@ export class DashboardViewState {
}
syncState() {
- if (this.panelScopes.length === 0) {
- return;
- }
-
if (this.dashboard.meta.fullscreen) {
- const panelScope = this.getPanelScope(this.state.panelId);
- if (!panelScope) {
+ const panel = this.dashboard.getPanelById(this.state.panelId);
+
+ if (!panel) {
return;
}
- if (this.fullscreenPanel) {
- // if already fullscreen
- if (this.fullscreenPanel === panelScope && this.editStateChanged === false) {
- return;
- } else {
- this.leaveFullscreen(false);
- }
- }
-
- if (!panelScope.ctrl.editModeInitiated) {
- panelScope.ctrl.initEditMode();
- }
-
- if (!panelScope.ctrl.fullscreen) {
- this.enterFullscreen(panelScope);
+ if (!panel.fullscreen) {
+ this.enterFullscreen(panel);
+ } else {
+ // already in fullscreen view just update the view mode
+ this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
}
} else if (this.fullscreenPanel) {
- this.leaveFullscreen(true);
+ this.leaveFullscreen();
}
}
- getPanelScope(id) {
- return _.find(this.panelScopes, panelScope => {
- return panelScope.ctrl.panel.id === id;
- });
- }
+ leaveFullscreen() {
+ const panel = this.fullscreenPanel;
- leaveFullscreen(render) {
- const self = this;
- const ctrl = self.fullscreenPanel.ctrl;
+ this.dashboard.setViewMode(panel, false, false);
- ctrl.editMode = false;
- ctrl.fullscreen = false;
-
- this.dashboard.setViewMode(ctrl.panel, false, false);
- this.$scope.appEvent('panel-fullscreen-exit', { panelId: ctrl.panel.id });
- this.$scope.appEvent('dash-scroll', { restore: true });
-
- if (!render) {
- return false;
- }
+ delete this.fullscreenPanel;
this.$timeout(() => {
- if (self.oldTimeRange !== ctrl.range) {
- self.$rootScope.$broadcast('refresh');
+ appEvents.emit('dash-scroll', { restore: true });
+
+ if (this.oldTimeRange !== this.dashboard.time) {
+ this.dashboard.startRefresh();
} else {
- self.$rootScope.$broadcast('render');
+ this.dashboard.render();
}
- delete self.fullscreenPanel;
});
- return true;
}
- enterFullscreen(panelScope) {
- const ctrl = panelScope.ctrl;
+ enterFullscreen(panel) {
+ const isEditing = this.state.edit && this.dashboard.meta.canEdit;
- ctrl.editMode = this.state.edit && this.dashboard.meta.canEdit;
- ctrl.fullscreen = true;
-
- this.oldTimeRange = ctrl.range;
- this.fullscreenPanel = panelScope;
+ this.oldTimeRange = this.dashboard.time;
+ this.fullscreenPanel = panel;
// Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
- this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode);
- this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id });
- }
-
- registerPanel(panelScope) {
- const self = this;
- self.panelScopes.push(panelScope);
-
- if (!self.dashboard.meta.soloMode) {
- if (self.state.panelId === panelScope.ctrl.panel.id) {
- if (self.state.edit) {
- panelScope.ctrl.editPanel();
- } else {
- panelScope.ctrl.viewPanel();
- }
- }
- }
-
- const unbind = panelScope.$on('$destroy', () => {
- self.panelScopes = _.without(self.panelScopes, panelScope);
- unbind();
- });
+ this.dashboard.setViewMode(panel, true, isEditing);
}
}
/** @ngInject */
-export function dashboardViewStateSrv($location, $timeout, $rootScope) {
+export function dashboardViewStateSrv($location, $timeout) {
return {
create: $scope => {
- return new DashboardViewState($scope, $location, $timeout, $rootScope);
+ return new DashboardViewState($scope, $location, $timeout);
},
};
}
diff --git a/public/app/features/datasources/DashboardsTable.test.tsx b/public/app/features/datasources/DashboardsTable.test.tsx
new file mode 100644
index 00000000000..10a14f464e4
--- /dev/null
+++ b/public/app/features/datasources/DashboardsTable.test.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import DashboardsTable, { Props } from './DashboardsTable';
+import { PluginDashboard } from '../../types';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ dashboards: [] as PluginDashboard[],
+ onImport: jest.fn(),
+ onRemove: jest.fn(),
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow(
);
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render table', () => {
+ const wrapper = setup({
+ dashboards: [
+ {
+ dashboardId: 0,
+ description: '',
+ folderId: 0,
+ imported: false,
+ importedRevision: 0,
+ importedUri: '',
+ importedUrl: '',
+ path: 'dashboards/carbon_metrics.json',
+ pluginId: 'graphite',
+ removed: false,
+ revision: 1,
+ slug: '',
+ title: 'Graphite Carbon Metrics',
+ },
+ {
+ dashboardId: 0,
+ description: '',
+ folderId: 0,
+ imported: true,
+ importedRevision: 0,
+ importedUri: '',
+ importedUrl: '',
+ path: 'dashboards/carbon_metrics.json',
+ pluginId: 'graphite',
+ removed: false,
+ revision: 1,
+ slug: '',
+ title: 'Graphite Carbon Metrics',
+ },
+ ],
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/DashboardsTable.tsx
new file mode 100644
index 00000000000..b732782c23b
--- /dev/null
+++ b/public/app/features/datasources/DashboardsTable.tsx
@@ -0,0 +1,55 @@
+import React, { SFC } from 'react';
+import { PluginDashboard } from '../../types';
+
+export interface Props {
+ dashboards: PluginDashboard[];
+ onImport: (dashboard, overwrite) => void;
+ onRemove: (dashboard) => void;
+}
+
+const DashboardsTable: SFC
= ({ dashboards, onImport, onRemove }) => {
+ function buttonText(dashboard: PluginDashboard) {
+ return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
+ }
+
+ return (
+
+
+ {dashboards.map((dashboard, index) => {
+ return (
+
+
+
+
+
+ {dashboard.imported ? (
+ {dashboard.title}
+ ) : (
+ {dashboard.title}
+ )}
+
+
+ {!dashboard.imported ? (
+ onImport(dashboard, false)}>
+ Import
+
+ ) : (
+ onImport(dashboard, true)}>
+ {buttonText(dashboard)}
+
+ )}
+ {dashboard.imported && (
+ onRemove(dashboard)}>
+
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default DashboardsTable;
diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx
new file mode 100644
index 00000000000..7409572b9cd
--- /dev/null
+++ b/public/app/features/datasources/DataSourceDashboards.test.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { DataSourceDashboards, Props } from './DataSourceDashboards';
+import { DataSource, NavModel, PluginDashboard } from 'app/types';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ navModel: {} as NavModel,
+ dashboards: [] as PluginDashboard[],
+ dataSource: {} as DataSource,
+ pageId: 1,
+ importDashboard: jest.fn(),
+ loadDataSource: jest.fn(),
+ loadPluginDashboards: jest.fn(),
+ removeDashboard: jest.fn(),
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx
new file mode 100644
index 00000000000..51f9c9ca945
--- /dev/null
+++ b/public/app/features/datasources/DataSourceDashboards.tsx
@@ -0,0 +1,93 @@
+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 DashboardTable from './DashboardsTable';
+import { DataSource, NavModel, PluginDashboard } from 'app/types';
+import { getNavModel } from 'app/core/selectors/navModel';
+import { getRouteParamsId } from 'app/core/selectors/location';
+import { loadDataSource } from './state/actions';
+import { loadPluginDashboards } from '../plugins/state/actions';
+import { importDashboard, removeDashboard } from '../dashboard/state/actions';
+import { getDataSource } from './state/selectors';
+
+export interface Props {
+ navModel: NavModel;
+ dashboards: PluginDashboard[];
+ dataSource: DataSource;
+ pageId: number;
+ importDashboard: typeof importDashboard;
+ loadDataSource: typeof loadDataSource;
+ loadPluginDashboards: typeof loadPluginDashboards;
+ removeDashboard: typeof removeDashboard;
+}
+
+export class DataSourceDashboards extends PureComponent {
+ async componentDidMount() {
+ const { loadDataSource, pageId } = this.props;
+
+ await loadDataSource(pageId);
+ this.props.loadPluginDashboards();
+ }
+
+ onImport = (dashboard: PluginDashboard, overwrite: boolean) => {
+ const { dataSource, importDashboard } = this.props;
+ const data = {
+ pluginId: dashboard.pluginId,
+ path: dashboard.path,
+ overwrite: overwrite,
+ inputs: [],
+ };
+
+ if (dataSource) {
+ data.inputs.push({
+ name: '*',
+ type: 'datasource',
+ pluginId: dataSource.type,
+ value: dataSource.name,
+ });
+ }
+
+ importDashboard(data, dashboard.title);
+ };
+
+ onRemove = (dashboard: PluginDashboard) => {
+ this.props.removeDashboard(dashboard.importedUri);
+ };
+
+ render() {
+ const { dashboards, navModel } = this.props;
+ return (
+
+
+
+ this.onImport(dashboard, overwrite)}
+ onRemove={dashboard => this.onRemove(dashboard)}
+ />
+
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ const pageId = getRouteParamsId(state.location);
+
+ return {
+ navModel: getNavModel(state.navIndex, `datasource-dashboards-${pageId}`),
+ pageId: pageId,
+ dashboards: state.plugins.dashboards,
+ dataSource: getDataSource(state.dataSources, pageId),
+ };
+}
+
+const mapDispatchToProps = {
+ importDashboard,
+ loadDataSource,
+ loadPluginDashboards,
+ removeDashboard,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceDashboards));
diff --git a/public/app/features/datasources/DataSourceList.test.tsx b/public/app/features/datasources/DataSourceList.test.tsx
new file mode 100644
index 00000000000..6e097da2c53
--- /dev/null
+++ b/public/app/features/datasources/DataSourceList.test.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import DataSourcesList from './DataSourcesList';
+import { getMockDataSources } from './__mocks__/dataSourcesMocks';
+import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+const setup = () => {
+ const props = {
+ dataSources: getMockDataSources(3),
+ layoutMode: LayoutModes.Grid,
+ };
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/datasources/DataSourceSettings.tsx b/public/app/features/datasources/DataSourceSettings.tsx
new file mode 100644
index 00000000000..f7d641d34b0
--- /dev/null
+++ b/public/app/features/datasources/DataSourceSettings.tsx
@@ -0,0 +1,125 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { DataSource, Plugin } from 'app/types';
+
+export interface Props {
+ dataSource: DataSource;
+ dataSourceMeta: Plugin;
+}
+interface State {
+ name: string;
+}
+
+enum DataSourceStates {
+ Alpha = 'alpha',
+ Beta = 'beta',
+}
+
+export class DataSourceSettings extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ name: props.dataSource.name,
+ };
+ }
+
+ onNameChange = event => {
+ this.setState({
+ name: event.target.value,
+ });
+ };
+
+ onSubmit = event => {
+ event.preventDefault();
+ console.log(event);
+ };
+
+ onDelete = event => {
+ console.log(event);
+ };
+
+ isReadyOnly() {
+ return this.props.dataSource.readOnly === true;
+ }
+
+ shouldRenderInfoBox() {
+ const { state } = this.props.dataSourceMeta;
+
+ return state === DataSourceStates.Alpha || state === DataSourceStates.Beta;
+ }
+
+ getInfoText() {
+ const { dataSourceMeta } = this.props;
+
+ switch (dataSourceMeta.state) {
+ case DataSourceStates.Alpha:
+ return (
+ 'This plugin is marked as being in alpha state, which means it is in early development phase and updates' +
+ ' will include breaking changes.'
+ );
+
+ case DataSourceStates.Beta:
+ return (
+ 'This plugin is marked as being in a beta development state. This means it is in currently in active' +
+ ' development and could be missing important features.'
+ );
+ }
+
+ return null;
+ }
+
+ render() {
+ const { name } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ dataSource: state.dataSources.dataSource,
+ dataSourceMeta: state.dataSources.dataSourceMeta,
+ };
+}
+
+export default connect(mapStateToProps)(DataSourceSettings);
diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx
new file mode 100644
index 00000000000..904ed0cf679
--- /dev/null
+++ b/public/app/features/datasources/DataSourcesList.tsx
@@ -0,0 +1,34 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames/bind';
+import DataSourcesListItem from './DataSourcesListItem';
+import { DataSource } from 'app/types';
+import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+export interface Props {
+ dataSources: DataSource[];
+ layoutMode: LayoutMode;
+}
+
+export class DataSourcesList extends PureComponent {
+ render() {
+ const { dataSources, layoutMode } = this.props;
+
+ const listStyle = classNames({
+ 'card-section': true,
+ 'card-list-layout-grid': layoutMode === LayoutModes.Grid,
+ 'card-list-layout-list': layoutMode === LayoutModes.List,
+ });
+
+ return (
+
+
+ {dataSources.map((dataSource, index) => {
+ return ;
+ })}
+
+
+ );
+ }
+}
+
+export default DataSourcesList;
diff --git a/public/app/features/datasources/DataSourcesListItem.test.tsx b/public/app/features/datasources/DataSourcesListItem.test.tsx
new file mode 100644
index 00000000000..138c71cb46a
--- /dev/null
+++ b/public/app/features/datasources/DataSourcesListItem.test.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import DataSourcesListItem from './DataSourcesListItem';
+import { getMockDataSource } from './__mocks__/dataSourcesMocks';
+
+const setup = () => {
+ const props = {
+ dataSource: getMockDataSource(),
+ };
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/datasources/DataSourcesListItem.tsx b/public/app/features/datasources/DataSourcesListItem.tsx
new file mode 100644
index 00000000000..a4fedb893fb
--- /dev/null
+++ b/public/app/features/datasources/DataSourcesListItem.tsx
@@ -0,0 +1,35 @@
+import React, { PureComponent } from 'react';
+import { DataSource } from 'app/types';
+
+export interface Props {
+ dataSource: DataSource;
+}
+
+export class DataSourcesListItem extends PureComponent {
+ render() {
+ const { dataSource } = this.props;
+ return (
+
+
+
+
+
+
+
+
+
+ {dataSource.name}
+ {dataSource.isDefault && default }
+
+
{dataSource.url}
+
+
+
+
+ );
+ }
+}
+
+export default DataSourcesListItem;
diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx
new file mode 100644
index 00000000000..0ea716d62c9
--- /dev/null
+++ b/public/app/features/datasources/DataSourcesListPage.test.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { DataSourcesListPage, Props } from './DataSourcesListPage';
+import { DataSource, NavModel } from 'app/types';
+import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+import { getMockDataSources } from './__mocks__/dataSourcesMocks';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ dataSources: [] as DataSource[],
+ layoutMode: LayoutModes.Grid,
+ loadDataSources: jest.fn(),
+ navModel: {} as NavModel,
+ dataSourcesCount: 0,
+ searchQuery: '',
+ setDataSourcesSearchQuery: jest.fn(),
+ setDataSourcesLayoutMode: jest.fn(),
+ hasFetched: false,
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render action bar and datasources', () => {
+ const wrapper = setup({
+ dataSources: getMockDataSources(5),
+ dataSourcesCount: 5,
+ hasFetched: true,
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx
new file mode 100644
index 00000000000..6a292d63e53
--- /dev/null
+++ b/public/app/features/datasources/DataSourcesListPage.tsx
@@ -0,0 +1,110 @@
+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';
+import DataSourcesList from './DataSourcesList';
+import { DataSource, NavModel } from 'app/types';
+import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import {
+ getDataSources,
+ getDataSourcesCount,
+ getDataSourcesLayoutMode,
+ getDataSourcesSearchQuery,
+} from './state/selectors';
+
+export interface Props {
+ navModel: NavModel;
+ dataSources: DataSource[];
+ dataSourcesCount: number;
+ layoutMode: LayoutMode;
+ searchQuery: string;
+ hasFetched: boolean;
+ loadDataSources: typeof loadDataSources;
+ setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
+ setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
+}
+
+const emptyListModel = {
+ title: 'There are no data sources defined yet',
+ buttonIcon: 'gicon gicon-add-datasources',
+ buttonLink: 'datasources/new',
+ buttonTitle: 'Add data source',
+ proTip: 'You can also define data sources through configuration files.',
+ proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
+ proTipLinkTitle: 'Learn more',
+ proTipTarget: '_blank',
+};
+
+export class DataSourcesListPage extends PureComponent {
+ componentDidMount() {
+ this.fetchDataSources();
+ }
+
+ async fetchDataSources() {
+ return await this.props.loadDataSources();
+ }
+
+ render() {
+ const {
+ dataSources,
+ dataSourcesCount,
+ navModel,
+ layoutMode,
+ searchQuery,
+ setDataSourcesSearchQuery,
+ setDataSourcesLayoutMode,
+ hasFetched,
+ } = this.props;
+
+ const linkButton = {
+ href: 'datasources/new',
+ title: 'Add data source',
+ };
+
+ return (
+
+
+
+ {!hasFetched &&
}
+ {hasFetched && dataSourcesCount === 0 &&
}
+ {hasFetched &&
+ dataSourcesCount > 0 && [
+
setDataSourcesLayoutMode(mode)}
+ setSearchQuery={query => setDataSourcesSearchQuery(query)}
+ linkButton={linkButton}
+ key="action-bar"
+ />,
+ ,
+ ]}
+
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ navModel: getNavModel(state.navIndex, 'datasources'),
+ dataSources: getDataSources(state.dataSources),
+ layoutMode: getDataSourcesLayoutMode(state.dataSources),
+ dataSourcesCount: getDataSourcesCount(state.dataSources),
+ searchQuery: getDataSourcesSearchQuery(state.dataSources),
+ hasFetched: state.dataSources.hasFetched,
+ };
+}
+
+const mapDispatchToProps = {
+ loadDataSources,
+ setDataSourcesSearchQuery,
+ setDataSourcesLayoutMode,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));
diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx
new file mode 100644
index 00000000000..c6eaa893d97
--- /dev/null
+++ b/public/app/features/datasources/NewDataSourcePage.tsx
@@ -0,0 +1,85 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { hot } from 'react-hot-loader';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import { NavModel, Plugin } from 'app/types';
+import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions';
+import { getNavModel } from 'app/core/selectors/navModel';
+import { getDataSourceTypes } from './state/selectors';
+
+export interface Props {
+ navModel: NavModel;
+ dataSourceTypes: Plugin[];
+ addDataSource: typeof addDataSource;
+ loadDataSourceTypes: typeof loadDataSourceTypes;
+ dataSourceTypeSearchQuery: string;
+ setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery;
+}
+
+class NewDataSourcePage extends PureComponent {
+ componentDidMount() {
+ this.props.loadDataSourceTypes();
+ }
+
+ onDataSourceTypeClicked = type => {
+ this.props.addDataSource(type);
+ };
+
+ onSearchQueryChange = event => {
+ this.props.setDataSourceTypeSearchQuery(event.target.value);
+ };
+
+ render() {
+ const { navModel, dataSourceTypes, dataSourceTypeSearchQuery } = this.props;
+
+ return (
+
+
+
+
Choose data source type
+
+
+
+
+
+
+
+ {dataSourceTypes.map((type, index) => {
+ return (
+
this.onDataSourceTypeClicked(type)}
+ className="add-data-source-grid-item"
+ key={`${type.id}-${index}`}
+ >
+
+
{type.name}
+
+ );
+ })}
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ navModel: getNavModel(state.navIndex, 'datasources'),
+ dataSourceTypes: getDataSourceTypes(state.dataSources),
+ };
+}
+
+const mapDispatchToProps = {
+ addDataSource,
+ loadDataSourceTypes,
+ setDataSourceTypeSearchQuery,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(NewDataSourcePage));
diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
new file mode 100644
index 00000000000..97819a18c82
--- /dev/null
+++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts
@@ -0,0 +1,45 @@
+import { DataSource } from 'app/types';
+
+export const getMockDataSources = (amount: number): DataSource[] => {
+ const dataSources = [];
+
+ for (let i = 0; i <= amount; i++) {
+ dataSources.push({
+ access: '',
+ basicAuth: false,
+ database: `database-${i}`,
+ id: i,
+ isDefault: false,
+ jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
+ name: `dataSource-${i}`,
+ orgId: 1,
+ password: '',
+ readOnly: false,
+ type: 'cloudwatch',
+ typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
+ url: '',
+ user: '',
+ });
+ }
+
+ return dataSources;
+};
+
+export const getMockDataSource = (): DataSource => {
+ return {
+ access: '',
+ basicAuth: false,
+ database: '',
+ id: 13,
+ isDefault: false,
+ jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
+ name: 'gdev-cloudwatch',
+ orgId: 1,
+ password: '',
+ readOnly: false,
+ type: 'cloudwatch',
+ typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
+ url: '',
+ user: '',
+ };
+};
diff --git a/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap
new file mode 100644
index 00000000000..a85cbd4ad9b
--- /dev/null
+++ b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap
@@ -0,0 +1,88 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
+
+exports[`Render should render table 1`] = `
+
+`;
diff --git a/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap
new file mode 100644
index 00000000000..7a4f05a227f
--- /dev/null
+++ b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
diff --git a/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap
new file mode 100644
index 00000000000..7167f59b048
--- /dev/null
+++ b/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap
@@ -0,0 +1,108 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap
new file mode 100644
index 00000000000..a424276cf32
--- /dev/null
+++ b/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap
@@ -0,0 +1,45 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+
+
+
+
+
+
+
+ gdev-cloudwatch
+
+
+
+
+
+
+`;
diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
new file mode 100644
index 00000000000..c26ac50fed8
--- /dev/null
+++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
@@ -0,0 +1,163 @@
+// 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/datasources/state/actions.test.ts b/public/app/features/datasources/state/actions.test.ts
new file mode 100644
index 00000000000..d0a8caad9a7
--- /dev/null
+++ b/public/app/features/datasources/state/actions.test.ts
@@ -0,0 +1,44 @@
+import { findNewName, nameExits } from './actions';
+import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks';
+
+describe('Name exists', () => {
+ const plugins = getMockPlugins(5);
+
+ it('should be true', () => {
+ const name = 'pretty cool plugin-1';
+
+ expect(nameExits(plugins, name)).toEqual(true);
+ });
+
+ it('should be false', () => {
+ const name = 'pretty cool plugin-6';
+
+ expect(nameExits(plugins, name));
+ });
+});
+
+describe('Find new name', () => {
+ it('should create a new name', () => {
+ const plugins = getMockPlugins(5);
+ const name = 'pretty cool plugin-1';
+
+ expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6');
+ });
+
+ it('should create new name without suffix', () => {
+ const plugin = getMockPlugin();
+ plugin.name = 'prometheus';
+ const plugins = [plugin];
+ const name = 'prometheus';
+
+ expect(findNewName(plugins, name)).toEqual('prometheus-1');
+ });
+
+ it('should handle names that end with -', () => {
+ const plugin = getMockPlugin();
+ const plugins = [plugin];
+ const name = 'pretty cool plugin-';
+
+ expect(findNewName(plugins, name)).toEqual('pretty cool plugin-');
+ });
+});
diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts
new file mode 100644
index 00000000000..bb8fce8424a
--- /dev/null
+++ b/public/app/features/datasources/state/actions.ts
@@ -0,0 +1,190 @@
+import { ThunkAction } from 'redux-thunk';
+import { DataSource, Plugin, StoreState } from 'app/types';
+import { getBackendSrv } from '../../../core/services/backend_srv';
+import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
+import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions';
+import { UpdateLocationAction } from '../../../core/actions/location';
+import { buildNavModel } from './navModel';
+
+export enum ActionTypes {
+ LoadDataSources = 'LOAD_DATA_SOURCES',
+ LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
+ LoadDataSource = 'LOAD_DATA_SOURCE',
+ LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
+ SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
+ SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
+ SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
+}
+
+export interface LoadDataSourcesAction {
+ type: ActionTypes.LoadDataSources;
+ payload: DataSource[];
+}
+
+export interface SetDataSourcesSearchQueryAction {
+ type: ActionTypes.SetDataSourcesSearchQuery;
+ payload: string;
+}
+
+export interface SetDataSourcesLayoutModeAction {
+ type: ActionTypes.SetDataSourcesLayoutMode;
+ payload: LayoutMode;
+}
+
+export interface LoadDataSourceTypesAction {
+ type: ActionTypes.LoadDataSourceTypes;
+ payload: Plugin[];
+}
+
+export interface SetDataSourceTypeSearchQueryAction {
+ type: ActionTypes.SetDataSourceTypeSearchQuery;
+ payload: string;
+}
+
+export interface LoadDataSourceAction {
+ type: ActionTypes.LoadDataSource;
+ payload: DataSource;
+}
+
+export interface LoadDataSourceMetaAction {
+ type: ActionTypes.LoadDataSourceMeta;
+ payload: Plugin;
+}
+
+const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
+ type: ActionTypes.LoadDataSources,
+ payload: dataSources,
+});
+
+const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({
+ type: ActionTypes.LoadDataSource,
+ payload: dataSource,
+});
+
+const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
+ type: ActionTypes.LoadDataSourceMeta,
+ payload: dataSourceMeta,
+});
+
+const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({
+ type: ActionTypes.LoadDataSourceTypes,
+ payload: dataSourceTypes,
+});
+
+export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({
+ type: ActionTypes.SetDataSourcesSearchQuery,
+ payload: searchQuery,
+});
+
+export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSourcesLayoutModeAction => ({
+ type: ActionTypes.SetDataSourcesLayoutMode,
+ payload: layoutMode,
+});
+
+export const setDataSourceTypeSearchQuery = (query: string): SetDataSourceTypeSearchQueryAction => ({
+ type: ActionTypes.SetDataSourceTypeSearchQuery,
+ payload: query,
+});
+
+export type Action =
+ | LoadDataSourcesAction
+ | SetDataSourcesSearchQueryAction
+ | SetDataSourcesLayoutModeAction
+ | UpdateLocationAction
+ | LoadDataSourceTypesAction
+ | SetDataSourceTypeSearchQueryAction
+ | LoadDataSourceAction
+ | UpdateNavIndexAction
+ | LoadDataSourceMetaAction;
+
+type ThunkResult = ThunkAction;
+
+export function loadDataSources(): ThunkResult {
+ return async dispatch => {
+ const response = await getBackendSrv().get('/api/datasources');
+ dispatch(dataSourcesLoaded(response));
+ };
+}
+
+export function loadDataSource(id: number): ThunkResult {
+ return async dispatch => {
+ const dataSource = await getBackendSrv().get(`/api/datasources/${id}`);
+ const pluginInfo = await getBackendSrv().get(`/api/plugins/${dataSource.type}/settings`);
+ dispatch(dataSourceLoaded(dataSource));
+ dispatch(dataSourceMetaLoaded(pluginInfo));
+ dispatch(updateNavIndex(buildNavModel(dataSource, pluginInfo)));
+ };
+}
+
+export function addDataSource(plugin: Plugin): ThunkResult {
+ return async (dispatch, getStore) => {
+ await dispatch(loadDataSources());
+
+ const dataSources = getStore().dataSources.dataSources;
+
+ const newInstance = {
+ name: plugin.name,
+ type: plugin.id,
+ access: 'proxy',
+ isDefault: dataSources.length === 0,
+ };
+
+ if (nameExits(dataSources, newInstance.name)) {
+ newInstance.name = findNewName(dataSources, newInstance.name);
+ }
+
+ const result = await getBackendSrv().post('/api/datasources', newInstance);
+ dispatch(updateLocation({ path: `/datasources/edit/${result.id}` }));
+ };
+}
+
+export function loadDataSourceTypes(): ThunkResult {
+ return async dispatch => {
+ const result = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
+ dispatch(dataSourceTypesLoaded(result));
+ };
+}
+
+export function nameExits(dataSources, name) {
+ return (
+ dataSources.filter(dataSource => {
+ return dataSource.name === name;
+ }).length > 0
+ );
+}
+
+export function findNewName(dataSources, name) {
+ // Need to loop through current data sources to make sure
+ // the name doesn't exist
+ while (nameExits(dataSources, name)) {
+ // If there's a duplicate name that doesn't end with '-x'
+ // we can add -1 to the name and be done.
+ if (!nameHasSuffix(name)) {
+ name = `${name}-1`;
+ } else {
+ // if there's a duplicate name that ends with '-x'
+ // we can try to increment the last digit until the name is unique
+
+ // remove the 'x' part and replace it with the new number
+ name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`;
+ }
+ }
+
+ return name;
+}
+
+function nameHasSuffix(name) {
+ return name.endsWith('-', name.length - 1);
+}
+
+function getLastDigit(name) {
+ return parseInt(name.slice(-1), 10);
+}
+
+function incrementLastDigit(digit) {
+ return isNaN(digit) ? 1 : digit + 1;
+}
+
+function getNewName(name) {
+ return name.slice(0, name.length - 1);
+}
diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts
new file mode 100644
index 00000000000..e0b6b39588e
--- /dev/null
+++ b/public/app/features/datasources/state/navModel.ts
@@ -0,0 +1,109 @@
+import { DataSource, NavModel, NavModelItem, PluginMeta } from 'app/types';
+import config from 'app/core/config';
+
+export function buildNavModel(dataSource: DataSource, pluginMeta: PluginMeta): NavModelItem {
+ const navModel = {
+ img: pluginMeta.info.logos.large,
+ id: 'datasource-' + dataSource.id,
+ subTitle: `Type: ${pluginMeta.name}`,
+ url: '',
+ text: dataSource.name,
+ breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }],
+ children: [
+ {
+ active: false,
+ icon: 'fa fa-fw fa-sliders',
+ id: `datasource-settings-${dataSource.id}`,
+ text: 'Settings',
+ url: `datasources/edit/${dataSource.id}`,
+ },
+ ],
+ };
+
+ if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) {
+ navModel.children.push({
+ active: false,
+ icon: 'fa fa-fw fa-th-large',
+ id: `datasource-dashboards-${dataSource.id}`,
+ text: 'Dashboards',
+ url: `datasources/edit/${dataSource.id}/dashboards`,
+ });
+ }
+
+ if (config.buildInfo.isEnterprise) {
+ navModel.children.push({
+ active: false,
+ icon: 'fa fa-fw fa-lock',
+ id: `datasource-permissions-${dataSource.id}`,
+ text: 'Permissions',
+ url: `datasources/edit/${dataSource.id}/permissions`,
+ });
+ }
+
+ return navModel;
+}
+
+export function getDataSourceLoadingNav(pageName: string): NavModel {
+ const main = buildNavModel(
+ {
+ access: '',
+ basicAuth: false,
+ database: '',
+ id: 1,
+ isDefault: false,
+ jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
+ name: 'Loading',
+ orgId: 1,
+ password: '',
+ readOnly: false,
+ type: 'Loading',
+ typeLogoUrl: 'public/img/icn-datasource.svg',
+ url: '',
+ user: '',
+ },
+ {
+ id: '1',
+ name: '',
+ info: {
+ author: {
+ name: '',
+ url: '',
+ },
+ description: '',
+ links: [''],
+ logos: {
+ large: '',
+ small: '',
+ },
+ screenshots: '',
+ updated: '',
+ version: '',
+ },
+ includes: [{ type: '', name: '', path: '' }],
+ }
+ );
+
+ let node: NavModelItem;
+
+ // find active page
+ for (const child of main.children) {
+ if (child.id.indexOf(pageName) > 0) {
+ child.active = true;
+ node = child;
+ break;
+ }
+ }
+
+ return {
+ main: main,
+ node: node,
+ };
+}
+
+function hasDashboards(includes) {
+ return (
+ includes.filter(include => {
+ return include.type === 'dashboard';
+ }).length > 0
+ );
+}
diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts
new file mode 100644
index 00000000000..7e235f5ea0a
--- /dev/null
+++ b/public/app/features/datasources/state/reducers.ts
@@ -0,0 +1,46 @@
+import { DataSource, DataSourcesState, Plugin } from 'app/types';
+import { Action, ActionTypes } from './actions';
+import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
+
+const initialState: DataSourcesState = {
+ dataSources: [] as DataSource[],
+ dataSource: {} as DataSource,
+ layoutMode: LayoutModes.Grid,
+ searchQuery: '',
+ dataSourcesCount: 0,
+ dataSourceTypes: [] as Plugin[],
+ dataSourceTypeSearchQuery: '',
+ dataSourceMeta: {} as Plugin,
+ hasFetched: false,
+};
+
+export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
+ switch (action.type) {
+ case ActionTypes.LoadDataSources:
+ return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
+
+ case ActionTypes.LoadDataSource:
+ return { ...state, dataSource: action.payload };
+
+ case ActionTypes.SetDataSourcesSearchQuery:
+ return { ...state, searchQuery: action.payload };
+
+ case ActionTypes.SetDataSourcesLayoutMode:
+ return { ...state, layoutMode: action.payload };
+
+ case ActionTypes.LoadDataSourceTypes:
+ return { ...state, dataSourceTypes: action.payload };
+
+ case ActionTypes.SetDataSourceTypeSearchQuery:
+ return { ...state, dataSourceTypeSearchQuery: action.payload };
+
+ case ActionTypes.LoadDataSourceMeta:
+ return { ...state, dataSourceMeta: action.payload };
+ }
+
+ return state;
+};
+
+export default {
+ dataSources: dataSourcesReducer,
+};
diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts
new file mode 100644
index 00000000000..eef176eb49a
--- /dev/null
+++ b/public/app/features/datasources/state/selectors.ts
@@ -0,0 +1,28 @@
+import { DataSource } from '../../../types';
+
+export const getDataSources = state => {
+ const regex = new RegExp(state.searchQuery, 'i');
+
+ return state.dataSources.filter(dataSource => {
+ return regex.test(dataSource.name) || regex.test(dataSource.database);
+ });
+};
+
+export const getDataSourceTypes = state => {
+ const regex = new RegExp(state.dataSourceTypeSearchQuery, 'i');
+
+ return state.dataSourceTypes.filter(type => {
+ return regex.test(type.name);
+ });
+};
+
+export const getDataSource = (state, dataSourceId): DataSource | null => {
+ if (state.dataSource.id === parseInt(dataSourceId, 10)) {
+ return state.dataSource;
+ }
+ return null;
+};
+
+export const getDataSourcesSearchQuery = state => state.searchQuery;
+export const getDataSourcesLayoutMode = state => state.layoutMode;
+export const getDataSourcesCount = state => state.dataSourcesCount;
diff --git a/public/app/features/explore/ErrorBoundary.tsx b/public/app/features/explore/ErrorBoundary.tsx
new file mode 100644
index 00000000000..959d15eea80
--- /dev/null
+++ b/public/app/features/explore/ErrorBoundary.tsx
@@ -0,0 +1,34 @@
+import React, { Component } from 'react';
+
+export default class ErrorBoundary extends Component<{}, any> {
+ constructor(props) {
+ super(props);
+ this.state = { error: null, errorInfo: null };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ // Catch errors in any components below and re-render with error message
+ this.setState({
+ error: error,
+ errorInfo: errorInfo,
+ });
+ }
+
+ render() {
+ if (this.state.errorInfo) {
+ // Error path
+ return (
+
+
An unexpected error happened.
+
+ {this.state.error && this.state.error.toString()}
+
+ {this.state.errorInfo.componentStack}
+
+
+ );
+ }
+ // Normally, just render children
+ return this.props.children;
+ }
+}
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index 187d68133cd..af771bad5dd 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -1,34 +1,34 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import Select from 'react-select';
+import _ from 'lodash';
+import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore';
+import { RawTimeRange } from 'app/types/series';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import store from 'app/core/store';
import TimeSeries from 'app/core/time_series2';
-import { decodePathComponent } from 'app/core/utils/location_util';
import { parse as parseDate } from 'app/core/utils/datemath';
+import { DEFAULT_RANGE } from 'app/core/utils/explore';
+import ResetStyles from 'app/core/components/Picker/ResetStyles';
+import PickerOption from 'app/core/components/Picker/PickerOption';
+import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
+import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
+import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
-import ElapsedTime from './ElapsedTime';
import QueryRows from './QueryRows';
import Graph from './Graph';
import Logs from './Logs';
import Table from './Table';
-import TimePicker, { DEFAULT_RANGE } from './TimePicker';
+import ErrorBoundary from './ErrorBoundary';
+import TimePicker from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
+import { DataSource } from 'app/types/datasources';
+import { mergeStreams } from 'app/core/logs_model';
const MAX_HISTORY_ITEMS = 100;
-function makeHints(hints) {
- const hintsByIndex = [];
- hints.forEach(hint => {
- if (hint) {
- hintsByIndex[hint.index] = hint;
- }
- });
- return hintsByIndex;
-}
-
function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => {
const datapoints = seriesData.datapoints || [];
@@ -47,112 +47,110 @@ function makeTimeSeriesList(dataList, options) {
});
}
-function parseUrlState(initial: string | undefined) {
- if (initial) {
- try {
- const parsed = JSON.parse(decodePathComponent(initial));
- return {
- datasource: parsed.datasource,
- queries: parsed.queries.map(q => q.query),
- range: parsed.range,
- };
- } catch (e) {
- console.error(e);
- }
+/**
+ * Update the query history. Side-effect: store history in local storage
+ */
+function updateHistory(history: HistoryItem[], datasourceId: string, queries: string[]): HistoryItem[] {
+ const ts = Date.now();
+ queries.forEach(query => {
+ history = [{ query, ts }, ...history];
+ });
+
+ if (history.length > MAX_HISTORY_ITEMS) {
+ history = history.slice(0, MAX_HISTORY_ITEMS);
}
- return { datasource: null, queries: [], range: DEFAULT_RANGE };
+
+ // Combine all queries of a datasource type into one history
+ const historyKey = `grafana.explore.history.${datasourceId}`;
+ store.setObject(historyKey, history);
+ return history;
}
-interface ExploreState {
- datasource: any;
- datasourceError: any;
- datasourceLoading: boolean | null;
- datasourceMissing: boolean;
- graphResult: any;
- history: any[];
- initialDatasource?: string;
- latency: number;
- loading: any;
- logsResult: any;
- queries: any[];
- queryErrors: any[];
- queryHints: any[];
- range: any;
- requestOptions: any;
- showingGraph: boolean;
- showingLogs: boolean;
- showingTable: boolean;
- supportsGraph: boolean | null;
- supportsLogs: boolean | null;
- supportsTable: boolean | null;
- tableResult: any;
+interface ExploreProps {
+ datasourceSrv: any;
+ onChangeSplit: (split: boolean, state?: ExploreState) => void;
+ onSaveState: (key: string, state: ExploreState) => void;
+ position: string;
+ split: boolean;
+ splitState?: ExploreState;
+ stateKey: string;
+ urlState: ExploreUrlState;
}
-export class Explore extends React.Component {
+export class Explore extends React.PureComponent {
el: any;
+ /**
+ * Current query expressions of the rows including their modifications, used for running queries.
+ * Not kept in component state to prevent edit-render roundtrips.
+ */
+ queryExpressions: string[];
constructor(props) {
super(props);
- const initialState: ExploreState = props.initialState;
- const { datasource, queries, range } = parseUrlState(props.routeParams.state);
- this.state = {
- datasource: null,
- datasourceError: null,
- datasourceLoading: null,
- datasourceMissing: false,
- graphResult: null,
- initialDatasource: datasource,
- history: [],
- latency: 0,
- loading: false,
- logsResult: null,
- queries: ensureQueries(queries),
- queryErrors: [],
- queryHints: [],
- range: range || { ...DEFAULT_RANGE },
- requestOptions: null,
- showingGraph: true,
- showingLogs: true,
- showingTable: true,
- supportsGraph: null,
- supportsLogs: null,
- supportsTable: null,
- tableResult: null,
- ...initialState,
- };
+ const splitState: ExploreState = props.splitState;
+ let initialQueries: Query[];
+ if (splitState) {
+ // Split state overrides everything
+ this.state = splitState;
+ initialQueries = splitState.queries;
+ } else {
+ const { datasource, queries, range } = props.urlState as ExploreUrlState;
+ initialQueries = ensureQueries(queries);
+ const initialRange = range || { ...DEFAULT_RANGE };
+ this.state = {
+ datasource: null,
+ datasourceError: null,
+ datasourceLoading: null,
+ datasourceMissing: false,
+ datasourceName: datasource,
+ exploreDatasources: [],
+ graphRange: initialRange,
+ history: [],
+ queries: initialQueries,
+ queryTransactions: [],
+ range: initialRange,
+ showingGraph: true,
+ showingLogs: true,
+ showingTable: true,
+ supportsGraph: null,
+ supportsLogs: null,
+ supportsTable: null,
+ };
+ }
+ this.queryExpressions = initialQueries.map(q => q.query);
}
async componentDidMount() {
const { datasourceSrv } = this.props;
- const { initialDatasource } = this.state;
+ const { datasourceName } = this.state;
if (!datasourceSrv) {
throw new Error('No datasource service passed as props.');
}
const datasources = datasourceSrv.getExploreSources();
+ const exploreDatasources = datasources.map(ds => ({
+ value: ds.name,
+ label: ds.name,
+ }));
+
if (datasources.length > 0) {
- this.setState({ datasourceLoading: true });
+ this.setState({ datasourceLoading: true, exploreDatasources });
// Priority: datasource in url, default datasource, first explore datasource
let datasource;
- if (initialDatasource) {
- datasource = await datasourceSrv.get(initialDatasource);
+ if (datasourceName) {
+ datasource = await datasourceSrv.get(datasourceName);
} else {
datasource = await datasourceSrv.get();
}
if (!datasource.meta.explore) {
datasource = await datasourceSrv.get(datasources[0].name);
}
- this.setDatasource(datasource);
+ await this.setDatasource(datasource);
} else {
this.setState({ datasourceMissing: true });
}
}
- componentDidCatch(error) {
- this.setState({ datasourceError: error });
- console.error(error);
- }
-
- async setDatasource(datasource) {
+ async setDatasource(datasource: DataSource) {
const supportsGraph = datasource.meta.metrics;
const supportsLogs = datasource.meta.logs;
const supportsTable = datasource.meta.metrics;
@@ -163,7 +161,7 @@ export class Explore extends React.Component {
const testResult = await datasource.testDatasource();
datasourceError = testResult.status === 'success' ? null : testResult.message;
} catch (error) {
- datasourceError = (error && error.statusText) || error;
+ datasourceError = (error && error.statusText) || 'Network error';
}
const historyKey = `grafana.explore.history.${datasourceId}`;
@@ -174,13 +172,18 @@ export class Explore extends React.Component {
}
// Keep queries but reset edit state
- const nextQueries = this.state.queries.map(q => ({
+ const nextQueries = this.state.queries.map((q, i) => ({
...q,
- edited: false,
+ key: generateQueryKey(i),
+ query: this.queryExpressions[i],
}));
+ // Custom components
+ const StartPage = datasource.pluginExports.ExploreStartPage;
+
this.setState(
{
+ StartPage,
datasource,
datasourceError,
history,
@@ -188,9 +191,14 @@ export class Explore extends React.Component {
supportsLogs,
supportsTable,
datasourceLoading: false,
+ datasourceName: datasource.name,
queries: nextQueries,
},
- () => datasourceError === null && this.onSubmit()
+ () => {
+ if (datasourceError === null) {
+ this.onSubmit();
+ }
+ }
);
}
@@ -199,13 +207,32 @@ export class Explore extends React.Component {
};
onAddQueryRow = index => {
- const { queries } = this.state;
- const nextQueries = [
- ...queries.slice(0, index + 1),
- { query: '', key: generateQueryKey() },
- ...queries.slice(index + 1),
- ];
- this.setState({ queries: nextQueries });
+ // Local cache
+ this.queryExpressions[index + 1] = '';
+
+ this.setState(state => {
+ const { queries, queryTransactions } = state;
+
+ // Add row by generating new react key
+ const nextQueries = [
+ ...queries.slice(0, index + 1),
+ { query: '', key: generateQueryKey() },
+ ...queries.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 { queries: nextQueries, queryTransactions: nextQueryTransactions };
+ });
};
onChangeDatasource = async option => {
@@ -213,61 +240,55 @@ export class Explore extends React.Component {
datasource: null,
datasourceError: null,
datasourceLoading: true,
- graphResult: null,
- latency: 0,
- logsResult: null,
- queryErrors: [],
- queryHints: [],
- tableResult: null,
+ queryTransactions: [],
});
- const datasource = await this.props.datasourceSrv.get(option.value);
+ const datasourceName = option.value;
+ const datasource = await this.props.datasourceSrv.get(datasourceName);
this.setDatasource(datasource);
};
onChangeQuery = (value: string, index: number, override?: boolean) => {
- const { queries } = this.state;
- let { queryErrors, queryHints } = this.state;
- const prevQuery = queries[index];
- const edited = override ? false : prevQuery.query !== value;
- const nextQuery = {
- ...queries[index],
- edited,
- query: value,
- };
- const nextQueries = [...queries];
- nextQueries[index] = nextQuery;
+ // Keep current value in local cache
+ this.queryExpressions[index] = value;
+
if (override) {
- queryErrors = [];
- queryHints = [];
+ this.setState(state => {
+ // Replace query row
+ const { queries, queryTransactions } = state;
+ const nextQuery: Query = {
+ key: generateQueryKey(index),
+ query: value,
+ };
+ const nextQueries = [...queries];
+ nextQueries[index] = nextQuery;
+
+ // Discard ongoing transaction related to row query
+ const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+ return {
+ queries: nextQueries,
+ queryTransactions: nextQueryTransactions,
+ };
+ }, this.onSubmit);
}
- this.setState(
- {
- queryErrors,
- queryHints,
- queries: nextQueries,
- },
- override ? () => this.onSubmit() : undefined
- );
};
- onChangeTime = nextRange => {
- const range = {
- from: nextRange.from,
- to: nextRange.to,
+ onChangeTime = (nextRange: RawTimeRange) => {
+ const range: RawTimeRange = {
+ ...nextRange,
};
this.setState({ range }, () => this.onSubmit());
};
onClickClear = () => {
- this.setState({
- graphResult: null,
- logsResult: null,
- latency: 0,
- queries: ensureQueries(),
- queryErrors: [],
- queryHints: [],
- tableResult: null,
- });
+ this.queryExpressions = [''];
+ this.setState(
+ {
+ queries: ensureQueries(),
+ queryTransactions: [],
+ },
+ this.saveState
+ );
};
onClickCloseSplit = () => {
@@ -278,64 +299,156 @@ export class Explore extends React.Component {
};
onClickGraphButton = () => {
- this.setState(state => ({ showingGraph: !state.showingGraph }));
+ 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 => ({ showingLogs: !state.showingLogs }));
+ 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();
+ }
+ }
+ );
+ };
+
+ // Use this in help pages to set page to a single query
+ onClickQuery = query => {
+ const nextQueries = [{ query, key: generateQueryKey() }];
+ this.queryExpressions = nextQueries.map(q => q.query);
+ this.setState({ queries: nextQueries }, this.onSubmit);
};
onClickSplit = () => {
const { onChangeSplit } = this.props;
- const state = { ...this.state };
- state.queries = state.queries.map(({ edited, ...rest }) => rest);
if (onChangeSplit) {
+ const state = this.cloneState();
onChangeSplit(true, state);
}
};
onClickTableButton = () => {
- this.setState(state => ({ showingTable: !state.showingTable }));
+ this.setState(
+ state => {
+ const showingTable = !state.showingTable;
+ let nextQueryTransactions = state.queryTransactions;
+ if (!showingTable) {
+ // Discard transactions related to Table query
+ nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table');
+ }
+ return { queryTransactions: nextQueryTransactions, showingTable };
+ },
+ () => {
+ if (this.state.showingTable) {
+ this.onSubmit();
+ }
+ }
+ );
};
onClickTableCell = (columnKey: string, rowValue: string) => {
this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
};
- onModifyQueries = (action: object, index?: number) => {
- const { datasource, queries } = this.state;
+ onModifyQueries = (action, index?: number) => {
+ const { datasource } = this.state;
if (datasource && datasource.modifyQuery) {
- let nextQueries;
- if (index === undefined) {
- // Modify all queries
- nextQueries = queries.map(q => ({
- ...q,
- edited: false,
- query: datasource.modifyQuery(q.query, action),
- }));
- } else {
- // Modify query only at index
- nextQueries = [
- ...queries.slice(0, index),
- {
- ...queries[index],
- edited: false,
- query: datasource.modifyQuery(queries[index].query, action),
- },
- ...queries.slice(index + 1),
- ];
- }
- this.setState({ queries: nextQueries }, () => this.onSubmit());
+ const preventSubmit = action.preventSubmit;
+ this.setState(
+ state => {
+ const { queries, queryTransactions } = state;
+ let nextQueries;
+ let nextQueryTransactions;
+ if (index === undefined) {
+ // Modify all queries
+ nextQueries = queries.map((q, i) => ({
+ key: generateQueryKey(i),
+ query: datasource.modifyQuery(this.queryExpressions[i], action),
+ }));
+ // Discard all ongoing transactions
+ nextQueryTransactions = [];
+ } else {
+ // Modify query only at index
+ nextQueries = queries.map((q, i) => {
+ // Synchronise all queries with local query cache to ensure consistency
+ q.query = this.queryExpressions[i];
+ return i === index
+ ? {
+ key: generateQueryKey(index),
+ query: datasource.modifyQuery(q.query, action),
+ }
+ : q;
+ });
+ 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.queryExpressions = nextQueries.map(q => q.query);
+ return {
+ queries: nextQueries,
+ queryTransactions: nextQueryTransactions,
+ };
+ },
+ // Accepting certain fixes do not result in a well-formed query which should not be submitted
+ !preventSubmit ? () => this.onSubmit() : null
+ );
}
};
onRemoveQueryRow = index => {
- const { queries } = this.state;
- if (queries.length <= 1) {
- return;
- }
- const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
- this.setState({ queries: nextQueries }, () => this.onSubmit());
+ // Remove from local cache
+ this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)];
+
+ this.setState(
+ state => {
+ const { queries, queryTransactions } = state;
+ if (queries.length <= 1) {
+ return null;
+ }
+ // Remove row from react state
+ const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
+
+ // Discard transactions related to row query
+ const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index);
+
+ return {
+ queries: nextQueries,
+ queryTransactions: nextQueryTransactions,
+ };
+ },
+ () => this.onSubmit()
+ );
};
onSubmit = () => {
@@ -344,157 +457,300 @@ export class Explore extends React.Component {
this.runTableQuery();
}
if (showingGraph && supportsGraph) {
- this.runGraphQuery();
+ this.runGraphQueries();
}
if (showingLogs && supportsLogs) {
this.runLogsQuery();
}
+ this.saveState();
};
- onQuerySuccess(datasourceId: string, queries: any[]): void {
- // save queries to history
- let { history } = this.state;
- const { datasource } = this.state;
+ buildQueryOptions(
+ query: string,
+ rowIndex: number,
+ targetOptions: { format: string; hinting?: boolean; instant?: boolean }
+ ) {
+ const { datasource, range } = this.state;
+ const resolution = this.el.offsetWidth;
+ const absoluteRange: RawTimeRange = {
+ from: parseDate(range.from, false),
+ to: parseDate(range.to, true),
+ };
+ const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
+ const targets = [
+ {
+ ...targetOptions,
+ // Target identifier is needed for table transformations
+ refId: rowIndex + 1,
+ expr: query,
+ },
+ ];
+ // Clone range for query request
+ const queryRange: RawTimeRange = { ...range };
+
+ return {
+ interval,
+ targets,
+ range: queryRange,
+ };
+ }
+
+ startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction {
+ const queryOptions = this.buildQueryOptions(query, rowIndex, options);
+ const transaction: QueryTransaction = {
+ query,
+ resultType,
+ rowIndex,
+ id: generateQueryKey(),
+ done: false,
+ latency: 0,
+ options: queryOptions,
+ };
+
+ // 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];
+
+ return {
+ queryTransactions: nextQueryTransactions,
+ };
+ });
+
+ return transaction;
+ }
+
+ completeQueryTransaction(
+ transactionId: string,
+ result: any,
+ latency: number,
+ queries: string[],
+ datasourceId: string
+ ) {
+ const { datasource } = this.state;
if (datasource.meta.id !== datasourceId) {
// Navigated away, queries did not matter
return;
}
- const ts = Date.now();
- queries.forEach(q => {
- const { query } = q;
- history = [{ query, ts }, ...history];
- });
+ this.setState(state => {
+ const { history, queryTransactions } = state;
- if (history.length > MAX_HISTORY_ITEMS) {
- history = history.slice(0, MAX_HISTORY_ITEMS);
+ // Transaction might have been discarded
+ const transaction = queryTransactions.find(qt => qt.id === transactionId);
+ if (!transaction) {
+ return null;
+ }
+
+ // Get query hints
+ let hints;
+ if (datasource.getQueryHints) {
+ 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 nextHistory = updateHistory(history, datasourceId, queries);
+
+ return {
+ history: nextHistory,
+ queryTransactions: nextQueryTransactions,
+ };
+ });
+ }
+
+ discardTransactions(rowIndex: number) {
+ this.setState(state => {
+ const remainingTransactions = state.queryTransactions.filter(qt => qt.rowIndex !== rowIndex);
+ return { queryTransactions: remainingTransactions };
+ });
+ }
+
+ failQueryTransaction(transactionId: string, response: any, datasourceId: string) {
+ const { datasource } = this.state;
+ if (datasource.meta.id !== datasourceId) {
+ // Navigated away, queries did not matter
+ return;
}
- // Combine all queries of a datasource type into one history
- const historyKey = `grafana.explore.history.${datasourceId}`;
- store.setObject(historyKey, history);
- this.setState({ history });
+ console.error(response);
+
+ let error: string | JSX.Element = response;
+ if (response.data) {
+ error = response.data.error;
+ if (response.data.response) {
+ error = (
+ <>
+ {response.data.error}
+ {response.data.response}
+ >
+ );
+ }
+ }
+
+ 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,
+ };
+ });
}
- buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) {
- const { datasource, queries, range } = this.state;
- const resolution = this.el.offsetWidth;
- const absoluteRange = {
- from: parseDate(range.from, false),
- to: parseDate(range.to, true),
- };
- const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
- const targets = queries.map(q => ({
- ...targetOptions,
- expr: q.query,
- }));
- return {
- interval,
- range,
- targets,
- };
- }
-
- async runGraphQuery() {
- const { datasource, queries } = this.state;
+ async runGraphQueries() {
+ const queries = [...this.queryExpressions];
if (!hasQuery(queries)) {
return;
}
- this.setState({ latency: 0, loading: true, graphResult: null, queryErrors: [], queryHints: [] });
- const now = Date.now();
- const options = this.buildQueryOptions({ format: 'time_series', instant: false, hinting: true });
- try {
- const res = await datasource.query(options);
- const result = makeTimeSeriesList(res.data, options);
- const queryHints = res.hints ? makeHints(res.hints) : [];
- const latency = Date.now() - now;
- this.setState({ latency, loading: false, graphResult: result, queryHints, requestOptions: options });
- this.onQuerySuccess(datasource.meta.id, queries);
- } catch (response) {
- console.error(response);
- const queryError = response.data ? response.data.error : response;
- this.setState({ loading: false, queryErrors: [queryError] });
- }
+ const { datasource } = this.state;
+ const datasourceId = datasource.meta.id;
+ // Run all queries concurrently
+ queries.forEach(async (query, rowIndex) => {
+ if (query) {
+ const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', {
+ format: 'time_series',
+ instant: false,
+ });
+ try {
+ const now = Date.now();
+ const res = await datasource.query(transaction.options);
+ const latency = Date.now() - now;
+ const results = makeTimeSeriesList(res.data, transaction.options);
+ this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
+ this.setState({ graphRange: transaction.options.range });
+ } catch (response) {
+ this.failQueryTransaction(transaction.id, response, datasourceId);
+ }
+ } else {
+ this.discardTransactions(rowIndex);
+ }
+ });
}
async runTableQuery() {
- const { datasource, queries } = this.state;
+ const queries = [...this.queryExpressions];
if (!hasQuery(queries)) {
return;
}
- this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], tableResult: null });
- const now = Date.now();
- const options = this.buildQueryOptions({
- format: 'table',
- instant: true,
+ const { datasource } = this.state;
+ const datasourceId = datasource.meta.id;
+ // Run all queries concurrently
+ queries.forEach(async (query, rowIndex) => {
+ if (query) {
+ const transaction = this.startQueryTransaction(query, rowIndex, 'Table', {
+ format: 'table',
+ instant: true,
+ valueWithRefId: true,
+ });
+ try {
+ const now = Date.now();
+ const res = await datasource.query(transaction.options);
+ const latency = Date.now() - now;
+ const results = res.data[0];
+ this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
+ } catch (response) {
+ this.failQueryTransaction(transaction.id, response, datasourceId);
+ }
+ } else {
+ this.discardTransactions(rowIndex);
+ }
});
- try {
- const res = await datasource.query(options);
- const tableModel = res.data[0];
- const latency = Date.now() - now;
- this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
- this.onQuerySuccess(datasource.meta.id, queries);
- } catch (response) {
- console.error(response);
- const queryError = response.data ? response.data.error : response;
- this.setState({ loading: false, queryErrors: [queryError] });
- }
}
async runLogsQuery() {
- const { datasource, queries } = this.state;
+ const queries = [...this.queryExpressions];
if (!hasQuery(queries)) {
return;
}
- this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], logsResult: null });
- const now = Date.now();
- const options = this.buildQueryOptions({
- format: 'logs',
+ const { datasource } = this.state;
+ const datasourceId = datasource.meta.id;
+ // Run all queries concurrently
+ queries.forEach(async (query, rowIndex) => {
+ if (query) {
+ const transaction = this.startQueryTransaction(query, rowIndex, 'Logs', { format: 'logs' });
+ try {
+ const now = Date.now();
+ const res = await datasource.query(transaction.options);
+ const latency = Date.now() - now;
+ const results = res.data;
+ this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);
+ } catch (response) {
+ this.failQueryTransaction(transaction.id, response, datasourceId);
+ }
+ } else {
+ this.discardTransactions(rowIndex);
+ }
});
-
- try {
- const res = await datasource.query(options);
- const logsData = res.data;
- const latency = Date.now() - now;
- this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
- this.onQuerySuccess(datasource.meta.id, queries);
- } catch (response) {
- console.error(response);
- const queryError = response.data ? response.data.error : response;
- this.setState({ loading: false, queryErrors: [queryError] });
- }
}
- request = url => {
- const { datasource } = this.state;
- return datasource.metadataRequest(url);
+ cloneState(): ExploreState {
+ // Copy state, but copy queries including modifications
+ return {
+ ...this.state,
+ queryTransactions: [],
+ queries: ensureQueries(this.queryExpressions.map(query => ({ query }))),
+ };
+ }
+
+ saveState = () => {
+ const { stateKey, onSaveState } = this.props;
+ onSaveState(stateKey, this.cloneState());
};
render() {
- const { datasourceSrv, position, split } = this.props;
+ const { position, split } = this.props;
const {
+ StartPage,
datasource,
datasourceError,
datasourceLoading,
datasourceMissing,
- graphResult,
+ exploreDatasources,
+ graphRange,
history,
- latency,
- loading,
- logsResult,
queries,
- queryErrors,
- queryHints,
+ queryTransactions,
range,
- requestOptions,
showingGraph,
showingLogs,
showingTable,
supportsGraph,
supportsLogs,
supportsTable,
- tableResult,
} = this.state;
const showingBoth = showingGraph && showingTable;
const graphHeight = showingBoth ? '200px' : '400px';
@@ -502,11 +758,24 @@ export class Explore extends React.Component {
const logsButtonActive = showingLogs ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore';
- const datasources = datasourceSrv.getExploreSources().map(ds => ({
- value: ds.name,
- label: ds.name,
- }));
- const selectedDatasource = datasource ? datasource.name : undefined;
+ const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === 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);
+ // TODO don't recreate those on each re-render
+ const graphResult = _.flatten(
+ queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result)
+ );
+ const tableResult = mergeTablesIntoModel(
+ new TableModel(),
+ ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result)
+ );
+ const logsResult = mergeStreams(
+ queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result)
+ );
+ const loading = queryTransactions.some(qt => !qt.done);
+ const showStartPages = StartPage && queryTransactions.length === 0;
+ const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length;
return (
@@ -528,12 +797,23 @@ export class Explore extends React.Component
{
{!datasourceMissing ? (
'Loading datasources...'}
+ noOptionsMessage={() => 'No datasources found'}
value={selectedDatasource}
+ components={{
+ Option: PickerOption,
+ IndicatorsContainer,
+ NoOptionsMessage,
+ }}
/>
) : null}
@@ -553,9 +833,9 @@ export class Explore extends React.Component {
- Run Query
+ Run Query{' '}
+ {loading ? : }
- {loading || latency ? : null}
@@ -572,51 +852,61 @@ export class Explore extends React.Component {
{datasource && !datasourceError ? (
-
- {supportsGraph ? (
-
- Graph
-
- ) : null}
- {supportsTable ? (
-
- Table
-
- ) : null}
- {supportsLogs ? (
-
- Logs
-
- ) : null}
-
-
- {supportsGraph && showingGraph ? (
-
- ) : null}
- {supportsTable && showingTable ? (
-
- ) : null}
- {supportsLogs && showingLogs ? : null}
+
+ {showStartPages && }
+ {!showStartPages && (
+ <>
+ {viewModeCount > 1 && (
+
+ {supportsGraph ? (
+
+ Graph
+
+ ) : null}
+ {supportsTable ? (
+
+ Table
+
+ ) : null}
+ {supportsLogs ? (
+
+ Logs
+
+ ) : null}
+
+ )}
+
+ {supportsGraph &&
+ showingGraph && (
+
+ )}
+ {supportsTable && showingTable ? (
+
+ ) : null}
+ {supportsLogs && showingLogs ? : null}
+ >
+ )}
+
) : null}
diff --git a/public/app/features/explore/Graph.test.tsx b/public/app/features/explore/Graph.test.tsx
new file mode 100644
index 00000000000..fe4deaf17aa
--- /dev/null
+++ b/public/app/features/explore/Graph.test.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { Graph } from './Graph';
+import { mockData } from './__mocks__/mockData';
+
+const setup = (propOverrides?: object) => {
+ const props = {
+ data: mockData().slice(0, 19),
+ range: { from: 'now-6h', to: 'now' },
+ ...propOverrides,
+ };
+
+ // Enzyme.shallow did not work well with jquery.flop. Mocking the draw function.
+ Graph.prototype.draw = jest.fn();
+
+ const wrapper = shallow( );
+ const instance = wrapper.instance() as Graph;
+
+ return {
+ wrapper,
+ instance,
+ };
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const { wrapper } = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render component with disclaimer', () => {
+ const { wrapper } = setup({
+ data: mockData(),
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should show query return no time series', () => {
+ const { wrapper } = setup({
+ data: [],
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx
index 9243f612466..bbb055067a2 100644
--- a/public/app/features/explore/Graph.tsx
+++ b/public/app/features/explore/Graph.tsx
@@ -1,14 +1,19 @@
import $ from 'jquery';
-import React, { Component } from 'react';
+import React, { PureComponent } from 'react';
import moment from 'moment';
+import { withSize } from 'react-sizeme';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
+
+import { RawTimeRange } from 'app/types/series';
import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2';
import Legend from './Legend';
+const MAX_NUMBER_OF_TIME_SERIES = 20;
+
// Copied from graph.ts
function time_format(ticks, min, max) {
if (min && max && ticks) {
@@ -66,37 +71,73 @@ const FLOT_OPTIONS = {
// },
};
-class Graph extends Component {
+interface GraphProps {
+ data: any[];
+ height?: string; // e.g., '200px'
+ id?: string;
+ loading?: boolean;
+ range: RawTimeRange;
+ split?: boolean;
+ size?: { width: number; height: number };
+}
+
+interface GraphState {
+ showAllTimeSeries: boolean;
+}
+
+export class Graph extends PureComponent {
+ state = {
+ showAllTimeSeries: false,
+ };
+
+ getGraphData() {
+ const { data } = this.props;
+
+ return this.state.showAllTimeSeries ? data : data.slice(0, MAX_NUMBER_OF_TIME_SERIES);
+ }
+
componentDidMount() {
this.draw();
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps: GraphProps) {
if (
prevProps.data !== this.props.data ||
- prevProps.options !== this.props.options ||
+ prevProps.range !== this.props.range ||
prevProps.split !== this.props.split ||
- prevProps.height !== this.props.height
+ prevProps.height !== this.props.height ||
+ (prevProps.size && prevProps.size.width !== this.props.size.width)
) {
this.draw();
}
}
- draw() {
- const { data, options: userOptions } = this.props;
- const $el = $(`#${this.props.id}`);
- if (!data) {
- $el.empty();
- return;
- }
- const series = data.map((ts: TimeSeries) => ({
- color: ts.color,
- label: ts.label,
- data: ts.getFlotPairs('null'),
- }));
+ onShowAllTimeSeries = () => {
+ this.setState(
+ {
+ showAllTimeSeries: true,
+ },
+ this.draw
+ );
+ };
- const ticks = $el.width() / 100;
- let { from, to } = userOptions.range;
+ draw() {
+ const { range, size } = this.props;
+ const data = this.getGraphData();
+
+ const $el = $(`#${this.props.id}`);
+ let series = [{ data: [[0, 0]] }];
+
+ if (data && data.length > 0) {
+ series = data.map((ts: TimeSeries) => ({
+ color: ts.color,
+ label: ts.label,
+ data: ts.getFlotPairs('null'),
+ }));
+ }
+
+ const ticks = (size.width || 0) / 100;
+ let { from, to } = range;
if (!moment.isMoment(from)) {
from = dateMath.parse(from, false);
}
@@ -118,27 +159,33 @@ class Graph extends Component {
const options = {
...FLOT_OPTIONS,
...dynamicOptions,
- ...userOptions,
};
$.plot($el, series, options);
}
render() {
- const { data, height, loading } = this.props;
- if (!loading && data && data.length === 0) {
- return (
-
-
The queries returned no time series to graph.
-
- );
- }
+ const { height = '100px', id = 'graph', loading = false } = this.props;
+ const data = this.getGraphData();
+
return (
-
+ {loading &&
}
+ {this.props.data &&
+ this.props.data.length > MAX_NUMBER_OF_TIME_SERIES &&
+ !this.state.showAllTimeSeries && (
+
+
+ {`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
+ {`Show all ${
+ this.props.data.length
+ }`}
+
+ )}
+
);
}
}
-export default Graph;
+export default withSize()(Graph);
diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx
index ae2d5e2daa6..278c5ee016d 100644
--- a/public/app/features/explore/Logs.tsx
+++ b/public/app/features/explore/Logs.tsx
@@ -1,6 +1,8 @@
import React, { Fragment, PureComponent } from 'react';
+import Highlighter from 'react-highlight-words';
-import { LogsModel, LogRow } from 'app/core/logs_model';
+import { LogsModel } from 'app/core/logs_model';
+import { findHighlightChunksInText } from 'app/core/utils/text';
interface LogsProps {
className?: string;
@@ -8,59 +10,33 @@ interface LogsProps {
loading: boolean;
}
-const EXAMPLE_QUERY = '{job="default/prometheus"}';
-
-const Entry: React.SFC = props => {
- const { entry, searchMatches } = props;
- if (searchMatches && searchMatches.length > 0) {
- let lastMatchEnd = 0;
- const spans = searchMatches.reduce((acc, match, i) => {
- // Insert non-match
- if (match.start !== lastMatchEnd) {
- acc.push(<>{entry.slice(lastMatchEnd, match.start)}>);
- }
- // Match
- acc.push(
-
- {entry.substr(match.start, match.length)}
-
- );
- lastMatchEnd = match.start + match.length;
- // Non-matching end
- if (i === searchMatches.length - 1) {
- acc.push(<>{entry.slice(lastMatchEnd)}>);
- }
- return acc;
- }, []);
- return <>{spans}>;
- }
- return <>{props.entry}>;
-};
-
-export default class Logs extends PureComponent {
+export default class Logs extends PureComponent {
render() {
- const { className = '', data } = this.props;
+ const { className = '', data, loading = false } = this.props;
const hasData = data && data.rows && data.rows.length > 0;
return (
- {hasData ? (
-
- {data.rows.map(row => (
-
-
- {row.timeLocal}
-
-
-
-
- ))}
+
+ {loading &&
}
+
+ {hasData &&
+ data.rows.map(row => (
+
+
+ {row.timeLocal}
+
+
+
+
+ ))}
- ) : null}
- {!hasData ? (
-
- Enter a query like {EXAMPLE_QUERY}
-
- ) : null}
+ {!loading && !hasData && 'No data was returned.'}
+
);
}
diff --git a/public/app/features/explore/PlaceholdersBuffer.test.ts b/public/app/features/explore/PlaceholdersBuffer.test.ts
new file mode 100644
index 00000000000..2ce31e79b05
--- /dev/null
+++ b/public/app/features/explore/PlaceholdersBuffer.test.ts
@@ -0,0 +1,72 @@
+import PlaceholdersBuffer from './PlaceholdersBuffer';
+
+describe('PlaceholdersBuffer', () => {
+ it('does nothing if no placeholders are defined', () => {
+ const text = 'metric';
+ const buffer = new PlaceholdersBuffer(text);
+
+ expect(buffer.hasPlaceholders()).toBe(false);
+ expect(buffer.toString()).toBe(text);
+ expect(buffer.getNextMoveOffset()).toBe(0);
+ });
+
+ it('respects the traversal order of placeholders', () => {
+ const text = 'sum($2 offset $1) by ($3)';
+ const buffer = new PlaceholdersBuffer(text);
+
+ expect(buffer.hasPlaceholders()).toBe(true);
+ expect(buffer.toString()).toBe('sum( offset ) by ()');
+ expect(buffer.getNextMoveOffset()).toBe(12);
+
+ buffer.setNextPlaceholderValue('1h');
+
+ expect(buffer.hasPlaceholders()).toBe(true);
+ expect(buffer.toString()).toBe('sum( offset 1h) by ()');
+ expect(buffer.getNextMoveOffset()).toBe(-10);
+
+ buffer.setNextPlaceholderValue('metric');
+
+ expect(buffer.hasPlaceholders()).toBe(true);
+ expect(buffer.toString()).toBe('sum(metric offset 1h) by ()');
+ expect(buffer.getNextMoveOffset()).toBe(16);
+
+ buffer.setNextPlaceholderValue('label');
+
+ expect(buffer.hasPlaceholders()).toBe(false);
+ expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)');
+ expect(buffer.getNextMoveOffset()).toBe(0);
+ });
+
+ it('respects the traversal order of adjacent placeholders', () => {
+ const text = '$1$3$2$4';
+ const buffer = new PlaceholdersBuffer(text);
+
+ expect(buffer.hasPlaceholders()).toBe(true);
+ expect(buffer.toString()).toBe('');
+ expect(buffer.getNextMoveOffset()).toBe(0);
+
+ buffer.setNextPlaceholderValue('1');
+
+ expect(buffer.hasPlaceholders()).toBe(true);
+ expect(buffer.toString()).toBe('1');
+ expect(buffer.getNextMoveOffset()).toBe(0);
+
+ buffer.setNextPlaceholderValue('2');
+
+ expect(buffer.hasPlaceholders()).toBe(true);
+ expect(buffer.toString()).toBe('12');
+ expect(buffer.getNextMoveOffset()).toBe(-1);
+
+ buffer.setNextPlaceholderValue('3');
+
+ expect(buffer.hasPlaceholders()).toBe(true);
+ expect(buffer.toString()).toBe('132');
+ expect(buffer.getNextMoveOffset()).toBe(1);
+
+ buffer.setNextPlaceholderValue('4');
+
+ expect(buffer.hasPlaceholders()).toBe(false);
+ expect(buffer.toString()).toBe('1324');
+ expect(buffer.getNextMoveOffset()).toBe(0);
+ });
+});
diff --git a/public/app/features/explore/PlaceholdersBuffer.ts b/public/app/features/explore/PlaceholdersBuffer.ts
new file mode 100644
index 00000000000..9a0db18ef04
--- /dev/null
+++ b/public/app/features/explore/PlaceholdersBuffer.ts
@@ -0,0 +1,112 @@
+/**
+ * Provides a stateful means of managing placeholders in text.
+ *
+ * Placeholders are numbers prefixed with the `$` character (e.g. `$1`).
+ * Each number value represents the order in which a placeholder should
+ * receive focus if multiple placeholders exist.
+ *
+ * Example scenario given `sum($3 offset $1) by($2)`:
+ * 1. `sum( offset |) by()`
+ * 2. `sum( offset 1h) by(|)`
+ * 3. `sum(| offset 1h) by (label)`
+ */
+export default class PlaceholdersBuffer {
+ private nextMoveOffset: number;
+ private orders: number[];
+ private parts: string[];
+
+ constructor(text: string) {
+ const result = this.parse(text);
+ const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0;
+ this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex);
+ this.orders = result.orders;
+ this.parts = result.parts;
+ }
+
+ clearPlaceholders() {
+ this.nextMoveOffset = 0;
+ this.orders = [];
+ }
+
+ getNextMoveOffset(): number {
+ return this.nextMoveOffset;
+ }
+
+ hasPlaceholders(): boolean {
+ return this.orders.length > 0;
+ }
+
+ setNextPlaceholderValue(value: string) {
+ if (this.orders.length === 0) {
+ return;
+ }
+ const currentPlaceholderIndex = this.orders[0];
+ this.parts[currentPlaceholderIndex] = value;
+ this.orders = this.orders.slice(1);
+ if (this.orders.length === 0) {
+ this.nextMoveOffset = 0;
+ return;
+ }
+ const nextPlaceholderIndex = this.orders[0];
+ // Case should never happen but handle it gracefully in case
+ if (currentPlaceholderIndex === nextPlaceholderIndex) {
+ this.nextMoveOffset = 0;
+ return;
+ }
+ const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex;
+ const indices = backwardMove
+ ? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 }
+ : { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex };
+ this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end);
+ }
+
+ toString(): string {
+ return this.parts.join('');
+ }
+
+ private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) {
+ return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0);
+ }
+
+ private parse(text: string): ParseResult {
+ const placeholderRegExp = /\$(\d+)/g;
+ const parts = [];
+ const orders = [];
+ let textOffset = 0;
+ while (true) {
+ const match = placeholderRegExp.exec(text);
+ if (!match) {
+ break;
+ }
+ const part = text.slice(textOffset, match.index);
+ parts.push(part);
+ // Accounts for placeholders at text boundaries
+ if (part !== '') {
+ parts.push('');
+ }
+ const order = parseInt(match[1], 10);
+ orders.push({ index: parts.length - 1, order });
+ textOffset += part.length + match.length;
+ }
+ // Ensures string serialisation still works if no placeholders were parsed
+ // and also accounts for the remainder of text with placeholders
+ parts.push(text.slice(textOffset));
+ return {
+ // Placeholder values do not necessarily appear sequentially so sort the
+ // indices to traverse in priority order
+ orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index),
+ parts,
+ };
+ }
+}
+
+type ParseResult = {
+ /**
+ * Indices to placeholder items in `parts` in traversal order.
+ */
+ orders: number[];
+ /**
+ * Parts comprising the original text with placeholders occupying distinct items.
+ */
+ parts: string[];
+};
diff --git a/public/app/features/explore/PromQueryField.test.tsx b/public/app/features/explore/PromQueryField.test.tsx
deleted file mode 100644
index c82a1cd448f..00000000000
--- a/public/app/features/explore/PromQueryField.test.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-import React from 'react';
-import Enzyme, { shallow } from 'enzyme';
-import Adapter from 'enzyme-adapter-react-16';
-import Plain from 'slate-plain-serializer';
-
-import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
-
-Enzyme.configure({ adapter: new Adapter() });
-
-describe('PromQueryField typeahead handling', () => {
- const defaultProps = {
- request: () => ({ data: { data: [] } }),
- };
-
- it('returns default suggestions on emtpty context', () => {
- const instance = shallow(
).instance() as PromQueryField;
- const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] });
- expect(result.context).toBeUndefined();
- expect(result.refresher).toBeUndefined();
- expect(result.suggestions.length).toEqual(2);
- });
-
- describe('range suggestions', () => {
- it('returns range suggestions in range context', () => {
- const instance = shallow(
).instance() as PromQueryField;
- const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
- expect(result.context).toBe('context-range');
- expect(result.refresher).toBeUndefined();
- expect(result.suggestions).toEqual([
- {
- items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
- label: 'Range vector',
- },
- ]);
- });
- });
-
- describe('metric suggestions', () => {
- it('returns metrics suggestions by default', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] });
- expect(result.context).toBeUndefined();
- expect(result.refresher).toBeUndefined();
- expect(result.suggestions.length).toEqual(2);
- });
-
- it('returns default suggestions after a binary operator', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] });
- expect(result.context).toBeUndefined();
- expect(result.refresher).toBeUndefined();
- expect(result.suggestions.length).toEqual(2);
- });
- });
-
- describe('label suggestions', () => {
- it('returns default label suggestions on label context and no metric', () => {
- const instance = shallow(
).instance() as PromQueryField;
- const value = Plain.deserialize('{}');
- const range = value.selection.merge({
- anchorOffset: 1,
- });
- const valueWithSelection = value.change().select(range).value;
- const result = instance.getTypeahead({
- text: '',
- prefix: '',
- wrapperClasses: ['context-labels'],
- value: valueWithSelection,
- });
- expect(result.context).toBe('context-labels');
- expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
- });
-
- it('returns label suggestions on label context and metric', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const value = Plain.deserialize('metric{}');
- const range = value.selection.merge({
- anchorOffset: 7,
- });
- const valueWithSelection = value.change().select(range).value;
- const result = instance.getTypeahead({
- text: '',
- prefix: '',
- wrapperClasses: ['context-labels'],
- value: valueWithSelection,
- });
- expect(result.context).toBe('context-labels');
- expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
- });
-
- it('returns label suggestions on label context but leaves out labels that already exist', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const value = Plain.deserialize('{job="foo",}');
- const range = value.selection.merge({
- anchorOffset: 11,
- });
- const valueWithSelection = value.change().select(range).value;
- const result = instance.getTypeahead({
- text: '',
- prefix: '',
- wrapperClasses: ['context-labels'],
- value: valueWithSelection,
- });
- expect(result.context).toBe('context-labels');
- expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
- });
-
- it('returns a refresher on label context and unavailable metric', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const value = Plain.deserialize('metric{}');
- const range = value.selection.merge({
- anchorOffset: 7,
- });
- const valueWithSelection = value.change().select(range).value;
- const result = instance.getTypeahead({
- text: '',
- prefix: '',
- wrapperClasses: ['context-labels'],
- value: valueWithSelection,
- });
- expect(result.context).toBeUndefined();
- expect(result.refresher).toBeInstanceOf(Promise);
- expect(result.suggestions).toEqual([]);
- });
-
- it('returns label values on label context when given a metric and a label key', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const value = Plain.deserialize('metric{bar=ba}');
- const range = value.selection.merge({
- anchorOffset: 13,
- });
- const valueWithSelection = value.change().select(range).value;
- const result = instance.getTypeahead({
- text: '=ba',
- prefix: 'ba',
- wrapperClasses: ['context-labels'],
- labelKey: 'bar',
- value: valueWithSelection,
- });
- expect(result.context).toBe('context-label-values');
- expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
- });
-
- it('returns label suggestions on aggregation context and metric w/ selector', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
- const range = value.selection.merge({
- anchorOffset: 26,
- });
- const valueWithSelection = value.change().select(range).value;
- const result = instance.getTypeahead({
- text: '',
- prefix: '',
- wrapperClasses: ['context-aggregation'],
- value: valueWithSelection,
- });
- expect(result.context).toBe('context-aggregation');
- expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
- });
-
- it('returns label suggestions on aggregation context and metric w/o selector', () => {
- const instance = shallow(
-
- ).instance() as PromQueryField;
- const value = Plain.deserialize('sum(metric) by ()');
- const range = value.selection.merge({
- anchorOffset: 16,
- });
- const valueWithSelection = value.change().select(range).value;
- const result = instance.getTypeahead({
- text: '',
- prefix: '',
- wrapperClasses: ['context-aggregation'],
- value: valueWithSelection,
- });
- expect(result.context).toBe('context-aggregation');
- expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
- });
- });
-});
-
-describe('groupMetricsByPrefix()', () => {
- it('returns an empty group for no metrics', () => {
- expect(groupMetricsByPrefix([])).toEqual([]);
- });
-
- it('returns options grouped by prefix', () => {
- expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
- {
- value: 'foo',
- children: [
- {
- value: 'foo_metric',
- },
- ],
- },
- ]);
- });
-
- it('returns options without prefix as toplevel option', () => {
- expect(groupMetricsByPrefix(['metric'])).toMatchObject([
- {
- value: 'metric',
- },
- ]);
- });
-
- it('returns recording rules grouped separately', () => {
- expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
- {
- value: RECORDING_RULES_GROUP,
- children: [
- {
- value: ':foo_metric:',
- },
- ],
- },
- ]);
- });
-});
diff --git a/public/app/features/explore/PromQueryField.tsx b/public/app/features/explore/PromQueryField.tsx
deleted file mode 100644
index 293688276dc..00000000000
--- a/public/app/features/explore/PromQueryField.tsx
+++ /dev/null
@@ -1,597 +0,0 @@
-import _ from 'lodash';
-import moment from 'moment';
-import React from 'react';
-import { Value } from 'slate';
-import Cascader from 'rc-cascader';
-import PluginPrism from 'slate-prism';
-import Prism from 'prismjs';
-
-// dom also includes Element polyfills
-import { getNextCharacter, getPreviousCousin } from './utils/dom';
-import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
-import BracesPlugin from './slate-plugins/braces';
-import RunnerPlugin from './slate-plugins/runner';
-import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
-
-import TypeaheadField, {
- Suggestion,
- SuggestionGroup,
- TypeaheadInput,
- TypeaheadFieldState,
- TypeaheadOutput,
-} from './QueryField';
-
-const DEFAULT_KEYS = ['job', 'instance'];
-const EMPTY_SELECTOR = '{}';
-const HISTOGRAM_GROUP = '__histograms__';
-const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
-const HISTORY_ITEM_COUNT = 5;
-const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
-const METRIC_MARK = 'metric';
-const PRISM_SYNTAX = 'promql';
-export const RECORDING_RULES_GROUP = '__recording_rules__';
-
-export const wrapLabel = (label: string) => ({ label });
-export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
- suggestion.move = -1;
- return suggestion;
-};
-
-// Syntax highlighting
-Prism.languages[PRISM_SYNTAX] = PrismPromql;
-function setPrismTokens(language, field, values, alias = 'variable') {
- Prism.languages[language][field] = {
- alias,
- pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
- };
-}
-
-export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
- const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
- const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
- const count = historyForItem.length;
- const recent = historyForItem[0];
- let hint = `Queried ${count} times in the last 24h.`;
- if (recent) {
- const lastQueried = moment(recent.ts).fromNow();
- hint = `${hint} Last queried ${lastQueried}.`;
- }
- return {
- ...item,
- documentation: hint,
- };
-}
-
-export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
- // Filter out recording rules and insert as first option
- const ruleRegex = /:\w+:/;
- const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
- const rulesOption = {
- label: 'Recording rules',
- value: RECORDING_RULES_GROUP,
- children: ruleNames
- .slice()
- .sort()
- .map(name => ({ label: name, value: name })),
- };
-
- const options = ruleNames.length > 0 ? [rulesOption] : [];
-
- const metricsOptions = _.chain(metrics)
- .filter(metric => !ruleRegex.test(metric))
- .groupBy(metric => metric.split(delimiter)[0])
- .map((metricsForPrefix: string[], prefix: string): CascaderOption => {
- const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
- const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m }));
- return {
- children,
- label: prefix,
- value: prefix,
- };
- })
- .sortBy('label')
- .value();
-
- return [...options, ...metricsOptions];
-}
-
-export function willApplySuggestion(
- suggestion: string,
- { typeaheadContext, typeaheadText }: TypeaheadFieldState
-): string {
- // Modify suggestion based on context
- switch (typeaheadContext) {
- case 'context-labels': {
- const nextChar = getNextCharacter();
- if (!nextChar || nextChar === '}' || nextChar === ',') {
- suggestion += '=';
- }
- break;
- }
-
- case 'context-label-values': {
- // Always add quotes and remove existing ones instead
- if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
- suggestion = `"${suggestion}`;
- }
- if (getNextCharacter() !== '"') {
- suggestion = `${suggestion}"`;
- }
- break;
- }
-
- default:
- }
- return suggestion;
-}
-
-interface CascaderOption {
- label: string;
- value: string;
- children?: CascaderOption[];
- disabled?: boolean;
-}
-
-interface PromQueryFieldProps {
- error?: string;
- hint?: any;
- histogramMetrics?: string[];
- history?: any[];
- initialQuery?: string | null;
- labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
- labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
- metrics?: string[];
- metricsByPrefix?: CascaderOption[];
- onClickHintFix?: (action: any) => void;
- onPressEnter?: () => void;
- onQueryChange?: (value: string, override?: boolean) => void;
- portalPrefix?: string;
- request?: (url: string) => any;
- supportsLogs?: boolean; // To be removed after Logging gets its own query field
-}
-
-interface PromQueryFieldState {
- histogramMetrics: string[];
- labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
- labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
- logLabelOptions: any[];
- metrics: string[];
- metricsByPrefix: CascaderOption[];
-}
-
-interface PromTypeaheadInput {
- text: string;
- prefix: string;
- wrapperClasses: string[];
- labelKey?: string;
- value?: Value;
-}
-
-class PromQueryField extends React.Component
{
- plugins: any[];
-
- constructor(props: PromQueryFieldProps, context) {
- super(props, context);
-
- this.plugins = [
- BracesPlugin(),
- RunnerPlugin({ handler: props.onPressEnter }),
- PluginPrism({
- onlyIn: node => node.type === 'code_block',
- getSyntax: node => 'promql',
- }),
- ];
-
- this.state = {
- histogramMetrics: props.histogramMetrics || [],
- labelKeys: props.labelKeys || {},
- labelValues: props.labelValues || {},
- logLabelOptions: [],
- metrics: props.metrics || [],
- metricsByPrefix: props.metricsByPrefix || [],
- };
- }
-
- componentDidMount() {
- // Temporarily reused by logging
- const { supportsLogs } = this.props;
- if (supportsLogs) {
- this.fetchLogLabels();
- } else {
- // Usual actions
- this.fetchMetricNames();
- this.fetchHistogramMetrics();
- }
- }
-
- onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
- let query;
- if (selectedOptions.length === 1) {
- if (selectedOptions[0].children.length === 0) {
- query = selectedOptions[0].value;
- } else {
- // Ignore click on group
- return;
- }
- } else {
- const key = selectedOptions[0].value;
- const value = selectedOptions[1].value;
- query = `{${key}="${value}"}`;
- }
- this.onChangeQuery(query, true);
- };
-
- onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
- let query;
- if (selectedOptions.length === 1) {
- if (selectedOptions[0].children.length === 0) {
- query = selectedOptions[0].value;
- } else {
- // Ignore click on group
- return;
- }
- } else {
- const prefix = selectedOptions[0].value;
- const metric = selectedOptions[1].value;
- if (prefix === HISTOGRAM_GROUP) {
- query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
- } else {
- query = metric;
- }
- }
- this.onChangeQuery(query, true);
- };
-
- onChangeQuery = (value: string, override?: boolean) => {
- // Send text change to parent
- const { onQueryChange } = this.props;
- if (onQueryChange) {
- onQueryChange(value, override);
- }
- };
-
- onClickHintFix = () => {
- const { hint, onClickHintFix } = this.props;
- if (onClickHintFix && hint && hint.fix) {
- onClickHintFix(hint.fix.action);
- }
- };
-
- onReceiveMetrics = () => {
- if (!this.state.metrics) {
- return;
- }
- setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
- };
-
- onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
- const { prefix, text, value, wrapperNode } = typeahead;
-
- // Get DOM-dependent context
- const wrapperClasses = Array.from(wrapperNode.classList);
- const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
- const labelKey = labelKeyNode && labelKeyNode.textContent;
- const nextChar = getNextCharacter();
-
- const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
-
- console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
-
- return result;
- };
-
- // Keep this DOM-free for testing
- getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
- // Syntax spans have 3 classes by default. More indicate a recognized token
- const tokenRecognized = wrapperClasses.length > 3;
- // Determine candidates by CSS context
- if (_.includes(wrapperClasses, 'context-range')) {
- // Suggestions for metric[|]
- return this.getRangeTypeahead();
- } else if (_.includes(wrapperClasses, 'context-labels')) {
- // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
- return this.getLabelTypeahead.apply(this, arguments);
- } else if (_.includes(wrapperClasses, 'context-aggregation')) {
- return this.getAggregationTypeahead.apply(this, arguments);
- } else if (
- // Non-empty but not inside known token
- (prefix && !tokenRecognized) ||
- (prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
- text.match(/[+\-*/^%]/) // After binary operator
- ) {
- return this.getEmptyTypeahead();
- }
-
- return {
- suggestions: [],
- };
- }
-
- getEmptyTypeahead(): TypeaheadOutput {
- const { history } = this.props;
- const { metrics } = this.state;
- const suggestions: SuggestionGroup[] = [];
-
- if (history && history.length > 0) {
- const historyItems = _.chain(history)
- .uniqBy('query')
- .take(HISTORY_ITEM_COUNT)
- .map(h => h.query)
- .map(wrapLabel)
- .map(item => addHistoryMetadata(item, history))
- .value();
-
- suggestions.push({
- prefixMatch: true,
- skipSort: true,
- label: 'History',
- items: historyItems,
- });
- }
-
- suggestions.push({
- prefixMatch: true,
- label: 'Functions',
- items: FUNCTIONS.map(setFunctionMove),
- });
-
- if (metrics) {
- suggestions.push({
- label: 'Metrics',
- items: metrics.map(wrapLabel),
- });
- }
- return { suggestions };
- }
-
- getRangeTypeahead(): TypeaheadOutput {
- return {
- context: 'context-range',
- suggestions: [
- {
- label: 'Range vector',
- items: [...RATE_RANGES].map(wrapLabel),
- },
- ],
- };
- }
-
- getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
- let refresher: Promise = null;
- const suggestions: SuggestionGroup[] = [];
-
- // sum(foo{bar="1"}) by (|)
- const line = value.anchorBlock.getText();
- const cursorOffset: number = value.anchorOffset;
- // sum(foo{bar="1"}) by (
- const leftSide = line.slice(0, cursorOffset);
- const openParensAggregationIndex = leftSide.lastIndexOf('(');
- const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
- const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
- // foo{bar="1"}
- const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
- const selector = parseSelector(selectorString, selectorString.length - 2).selector;
-
- const labelKeys = this.state.labelKeys[selector];
- if (labelKeys) {
- suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
- } else {
- refresher = this.fetchSeriesLabels(selector);
- }
-
- return {
- refresher,
- suggestions,
- context: 'context-aggregation',
- };
- }
-
- getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
- let context: string;
- let refresher: Promise = null;
- const suggestions: SuggestionGroup[] = [];
- const line = value.anchorBlock.getText();
- const cursorOffset: number = value.anchorOffset;
-
- // Get normalized selector
- let selector;
- let parsedSelector;
- try {
- parsedSelector = parseSelector(line, cursorOffset);
- selector = parsedSelector.selector;
- } catch {
- selector = EMPTY_SELECTOR;
- }
- const containsMetric = selector.indexOf('__name__=') > -1;
- const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
-
- if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
- // Label values
- if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
- const labelValues = this.state.labelValues[selector][labelKey];
- context = 'context-label-values';
- suggestions.push({
- label: `Label values for "${labelKey}"`,
- items: labelValues.map(wrapLabel),
- });
- }
- } else {
- // Label keys
- const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
- if (labelKeys) {
- const possibleKeys = _.difference(labelKeys, existingKeys);
- if (possibleKeys.length > 0) {
- context = 'context-labels';
- suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
- }
- }
- }
-
- // Query labels for selector
- // Temporarily add skip for logging
- if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
- if (selector === EMPTY_SELECTOR) {
- // Query label values for default labels
- refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
- } else {
- refresher = this.fetchSeriesLabels(selector, !containsMetric);
- }
- }
-
- return { context, refresher, suggestions };
- }
-
- request = url => {
- if (this.props.request) {
- return this.props.request(url);
- }
- return fetch(url);
- };
-
- fetchHistogramMetrics() {
- this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
- const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
- if (histogramSeries && histogramSeries['__name__']) {
- const histogramMetrics = histogramSeries['__name__'].slice().sort();
- this.setState({ histogramMetrics });
- }
- });
- }
-
- // Temporarily here while reusing this field for logging
- async fetchLogLabels() {
- const url = '/api/prom/label';
- try {
- const res = await this.request(url);
- const body = await (res.data || res.json());
- const labelKeys = body.data.slice().sort();
- const labelKeysBySelector = {
- ...this.state.labelKeys,
- [EMPTY_SELECTOR]: labelKeys,
- };
- const labelValuesByKey = {};
- const logLabelOptions = [];
- for (const key of labelKeys) {
- const valuesUrl = `/api/prom/label/${key}/values`;
- const res = await this.request(valuesUrl);
- const body = await (res.data || res.json());
- const values = body.data.slice().sort();
- labelValuesByKey[key] = values;
- logLabelOptions.push({
- label: key,
- value: key,
- children: values.map(value => ({ label: value, value })),
- });
- }
- const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
- this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
- } catch (e) {
- console.error(e);
- }
- }
-
- async fetchLabelValues(key: string) {
- const url = `/api/v1/label/${key}/values`;
- try {
- const res = await this.request(url);
- const body = await (res.data || res.json());
- const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
- const values = {
- ...exisingValues,
- [key]: body.data,
- };
- const labelValues = {
- ...this.state.labelValues,
- [EMPTY_SELECTOR]: values,
- };
- this.setState({ labelValues });
- } catch (e) {
- console.error(e);
- }
- }
-
- async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
- const url = `/api/v1/series?match[]=${name}`;
- try {
- const res = await this.request(url);
- const body = await (res.data || res.json());
- const { keys, values } = processLabels(body.data, withName);
- const labelKeys = {
- ...this.state.labelKeys,
- [name]: keys,
- };
- const labelValues = {
- ...this.state.labelValues,
- [name]: values,
- };
- this.setState({ labelKeys, labelValues }, callback);
- } catch (e) {
- console.error(e);
- }
- }
-
- async fetchMetricNames() {
- const url = '/api/v1/label/__name__/values';
- try {
- const res = await this.request(url);
- const body = await (res.data || res.json());
- const metrics = body.data;
- const metricsByPrefix = groupMetricsByPrefix(metrics);
- this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
- } catch (error) {
- console.error(error);
- }
- }
-
- render() {
- const { error, hint, supportsLogs } = this.props;
- const { histogramMetrics, logLabelOptions, metricsByPrefix } = this.state;
- const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
- const metricsOptions = [
- { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
- ...metricsByPrefix,
- ];
-
- return (
-
-
- {supportsLogs ? (
-
- Log labels
-
- ) : (
-
- Metrics
-
- )}
-
-
-
-
-
- {error ?
{error}
: null}
- {hint ? (
-
- ) : null}
-
-
- );
- }
-}
-
-export default PromQueryField;
diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx
index 13364729e7e..73d743fe8e6 100644
--- a/public/app/features/explore/QueryField.tsx
+++ b/public/app/features/explore/QueryField.tsx
@@ -5,89 +5,29 @@ import { Change, Value } from 'slate';
import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer';
+import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
+
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead';
import { makeFragment, makeValue } from './Value';
+import PlaceholdersBuffer from './PlaceholdersBuffer';
-export const TYPEAHEAD_DEBOUNCE = 300;
+export const TYPEAHEAD_DEBOUNCE = 100;
-function flattenSuggestions(s: any[]): any[] {
- return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
+function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
+ // Flatten suggestion groups
+ const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
+ const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
+ return flattenedSuggestions[correctedIndex];
}
-export interface Suggestion {
- /**
- * The label of this completion item. By default
- * this is also the text that is inserted when selecting
- * this completion.
- */
- label: string;
- /**
- * The kind of this completion item. Based on the kind
- * an icon is chosen by the editor.
- */
- kind?: string;
- /**
- * A human-readable string with additional information
- * about this item, like type or symbol information.
- */
- detail?: string;
- /**
- * A human-readable string, can be Markdown, that represents a doc-comment.
- */
- documentation?: string;
- /**
- * A string that should be used when comparing this item
- * with other items. When `falsy` the `label` is used.
- */
- sortText?: string;
- /**
- * A string that should be used when filtering a set of
- * completion items. When `falsy` the `label` is used.
- */
- filterText?: string;
- /**
- * A string or snippet that should be inserted in a document when selecting
- * this completion. When `falsy` the `label` is used.
- */
- insertText?: string;
- /**
- * Delete number of characters before the caret position,
- * by default the letters from the beginning of the word.
- */
- deleteBackwards?: number;
- /**
- * Number of steps to move after the insertion, can be negative.
- */
- move?: number;
+function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
+ return suggestions && suggestions.length > 0;
}
-export interface SuggestionGroup {
- /**
- * Label that will be displayed for all entries of this group.
- */
- label: string;
- /**
- * List of suggestions of this group.
- */
- items: Suggestion[];
- /**
- * If true, match only by prefix (and not mid-word).
- */
- prefixMatch?: boolean;
- /**
- * If true, do not filter items in this group based on the search.
- */
- skipFilter?: boolean;
- /**
- * If true, do not sort items.
- */
- skipSort?: boolean;
-}
-
-interface TypeaheadFieldProps {
+interface QueryFieldProps {
additionalPlugins?: any[];
cleanText?: (text: string) => string;
initialValue: string | null;
@@ -95,14 +35,15 @@ interface TypeaheadFieldProps {
onFocus?: () => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
onValueChanged?: (value: Value) => void;
- onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
+ onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
placeholder?: string;
- portalPrefix?: string;
+ portalOrigin?: string;
syntax?: string;
+ syntaxLoaded?: boolean;
}
-export interface TypeaheadFieldState {
- suggestions: SuggestionGroup[];
+export interface QueryFieldState {
+ suggestions: CompletionItemGroup[];
typeaheadContext: string | null;
typeaheadIndex: number;
typeaheadPrefix: string;
@@ -119,22 +60,19 @@ export interface TypeaheadInput {
wrapperNode: Element;
}
-export interface TypeaheadOutput {
- context?: string;
- refresher?: Promise<{}>;
- suggestions: SuggestionGroup[];
-}
-
-class QueryField extends React.Component {
+export class QueryField extends React.PureComponent {
menuEl: HTMLElement | null;
+ placeholdersBuffer: PlaceholdersBuffer;
plugins: any[];
resetTimer: any;
constructor(props, context) {
super(props, context);
+ this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || '');
+
// Base plugins
- this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
+ this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins].filter(p => p);
this.state = {
suggestions: [],
@@ -142,7 +80,7 @@ class QueryField extends React.Component {
- const changed = value.document !== this.state.value.document;
+ const textChanged = value.document !== this.state.value.document;
+
+ // Control editor loop, then pass text change up to parent
this.setState({ value }, () => {
- if (changed) {
+ if (textChanged) {
this.handleChangeValue();
}
});
- if (changed) {
+ // Show suggest menu on text input
+ if (textChanged && value.selection.isCollapsed) {
+ // Need one paint to allow DOM-based typeahead rules to work
window.requestAnimationFrame(this.handleTypeahead);
+ } else if (!this.resetTimer) {
+ this.resetTypeahead();
}
};
@@ -203,7 +160,13 @@ class QueryField extends React.Component {
if (group.items) {
if (prefix) {
@@ -241,6 +204,11 @@ class QueryField extends React.Component group.items && group.items.length > 0); // Filter out empty groups
+ // Keep same object for equality checking later
+ if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
+ filteredSuggestions = this.state.suggestions;
+ }
+
this.setState(
{
suggestions: filteredSuggestions,
@@ -257,7 +225,7 @@ class QueryField extends React.Component operation.type === 'insert_text');
+ if (insertTextOperation) {
+ const suggestionText = insertTextOperation.text;
+ this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
+ if (this.placeholdersBuffer.hasPlaceholders()) {
+ nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
+ }
+ }
- this.applyTypeahead(change, suggestion);
return true;
}
break;
@@ -371,6 +344,7 @@ class QueryField extends React.Component {
@@ -378,6 +352,8 @@ class QueryField extends React.Component {
+ onClickMenu = (item: CompletionItem) => {
// Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item);
this.onChange(change);
@@ -408,8 +384,7 @@ class QueryField extends React.Component 0;
- if (!hasSuggesstions) {
+ if (!hasSuggestions(suggestions)) {
menu.removeAttribute('style');
return;
}
@@ -435,27 +410,22 @@ class QueryField extends React.Component {
- const { portalPrefix } = this.props;
- const { suggestions } = this.state;
- const hasSuggesstions = suggestions && suggestions.length > 0;
- if (!hasSuggesstions) {
+ const { portalOrigin } = this.props;
+ const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
+ if (!hasSuggestions(suggestions)) {
return null;
}
- // Guard selectedIndex to be within the length of the suggestions
- let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
- const flattenedSuggestions = flattenSuggestions(suggestions);
- selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
- const selectedItem: Suggestion | null =
- flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
+ const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
// Create typeahead in DOM root so we can later position it absolutely
return (
-
+
@@ -464,32 +434,34 @@ class QueryField extends React.Component
- {this.renderMenu()}
-
+
+
+ {this.renderMenu()}
+
+
);
}
}
-class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
+class Portal extends React.PureComponent<{ index?: number; origin: string }, {}> {
node: HTMLElement;
constructor(props) {
super(props);
- const { index = 0, prefix = 'query' } = props;
+ const { index = 0, origin = 'query' } = props;
this.node = document.createElement('div');
- this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
+ this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
document.body.appendChild(this.node);
}
diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx
index 51adfa81c68..4aacdb22599 100644
--- a/public/app/features/explore/QueryRows.tsx
+++ b/public/app/features/explore/QueryRows.tsx
@@ -1,9 +1,41 @@
import React, { PureComponent } from 'react';
-// TODO make this datasource-plugin-dependent
-import QueryField from './PromQueryField';
+import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore';
-class QueryRow extends PureComponent {
+import DefaultQueryField from './QueryField';
+import QueryTransactionStatus from './QueryTransactionStatus';
+import { DataSource } from 'app/types';
+
+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: string, index: number, override?: boolean) => void;
+ onClickHintFix: (action: object, index?: number) => void;
+ onExecuteQuery: () => void;
+ onRemoveQueryRow: (index: number) => void;
+}
+
+interface QueryRowCommonProps {
+ className?: string;
+ datasource: DataSource;
+ history: HistoryItem[];
+ transactions: QueryTransaction[];
+}
+
+type QueryRowProps = QueryRowCommonProps &
+ QueryRowEventHandlers & {
+ index: number;
+ query: string;
+ };
+
+class QueryRow extends PureComponent {
onChangeQuery = (value, override?: boolean) => {
const { index, onChangeQuery } = this.props;
if (onChangeQuery) {
@@ -44,21 +76,26 @@ class QueryRow extends PureComponent {
};
render() {
- const { edited, history, query, queryError, queryHint, request, supportsLogs } = this.props;
+ const { datasource, history, query, transactions } = 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 || DefaultQueryField;
return (
+
+
+
@@ -77,9 +114,14 @@ class QueryRow extends PureComponent
{
}
}
-export default class QueryRows extends PureComponent {
+type QueryRowsProps = QueryRowCommonProps &
+ QueryRowEventHandlers & {
+ queries: Query[];
+ };
+
+export default class QueryRows extends PureComponent {
render() {
- const { className = '', queries, queryErrors = [], queryHints = [], ...handlers } = this.props;
+ const { className = '', queries, transactions, ...handlers } = this.props;
return (
{queries.map((q, index) => (
@@ -87,9 +129,7 @@ export default class QueryRows extends PureComponent
{
key={q.key}
index={index}
query={q.query}
- queryError={queryErrors[index]}
- queryHint={queryHints[index]}
- edited={q.edited}
+ transactions={transactions.filter(t => t.rowIndex === index)}
{...handlers}
/>
))}
diff --git a/public/app/features/explore/QueryTransactionStatus.tsx b/public/app/features/explore/QueryTransactionStatus.tsx
new file mode 100644
index 00000000000..77a50b7d2ca
--- /dev/null
+++ b/public/app/features/explore/QueryTransactionStatus.tsx
@@ -0,0 +1,42 @@
+import React, { PureComponent } from 'react';
+
+import { QueryTransaction } from 'app/types/explore';
+import ElapsedTime from './ElapsedTime';
+
+function formatLatency(value) {
+ return `${(value / 1000).toFixed(1)}s`;
+}
+
+interface QueryTransactionStatusItemProps {
+ transaction: QueryTransaction;
+}
+
+class QueryTransactionStatusItem extends PureComponent {
+ render() {
+ const { transaction } = this.props;
+ const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading';
+ return (
+
+
{transaction.resultType}:
+
+ {transaction.done ? formatLatency(transaction.latency) : }
+
+
+ );
+ }
+}
+
+interface QueryTransactionStatusProps {
+ transactions: QueryTransaction[];
+}
+
+export default class QueryTransactionStatus extends PureComponent {
+ render() {
+ const { transactions } = this.props;
+ return (
+
+ {transactions.map((t, i) => )}
+
+ );
+ }
+}
diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx
index cbb3ab11f4e..4946a6a505d 100644
--- a/public/app/features/explore/Table.tsx
+++ b/public/app/features/explore/Table.tsx
@@ -1,84 +1,64 @@
+import _ from 'lodash';
import React, { PureComponent } from 'react';
+import ReactTable from 'react-table';
+
import TableModel from 'app/core/table_model';
const EMPTY_TABLE = new TableModel();
+// Identify columns that contain values
+const VALUE_REGEX = /^[Vv]alue #\d+/;
interface TableProps {
- className?: string;
data: TableModel;
loading: boolean;
onClickCell?: (columnKey: string, rowValue: string) => void;
}
-interface SFCCellProps {
- columnIndex: number;
- onClickCell?: (columnKey: string, rowValue: string, columnIndex: number, rowIndex: number, table: TableModel) => void;
- rowIndex: number;
- table: TableModel;
- value: string;
+function prepareRows(rows, columnNames) {
+ return rows.map(cells => _.zipObject(columnNames, cells));
}
-function Cell(props: SFCCellProps) {
- const { columnIndex, rowIndex, table, value, onClickCell } = props;
- const column = table.columns[columnIndex];
- if (column && column.filterable && onClickCell) {
- const onClick = event => {
- event.preventDefault();
- onClickCell(column.text, value, columnIndex, rowIndex, table);
+export default class Table extends PureComponent {
+ getCellProps = (state, rowInfo, column) => {
+ return {
+ onClick: (e: React.SyntheticEvent) => {
+ // Only handle click on link, not the cell
+ if (e.target) {
+ const link = e.target as HTMLElement;
+ if (link.className === 'link') {
+ const columnKey = column.Header;
+ const rowValue = rowInfo.row[columnKey];
+ this.props.onClickCell(columnKey, rowValue);
+ }
+ }
+ },
};
- return (
-
-
- {value}
-
-
- );
- }
- return {value} ;
-}
+ };
-export default class Table extends PureComponent {
render() {
- const { className = '', data, loading, onClickCell } = this.props;
+ const { data, loading } = this.props;
const tableModel = data || EMPTY_TABLE;
- if (!loading && data && data.rows.length === 0) {
- return (
-
-
-
- Table
-
-
-
-
- The queries returned no data for a table.
-
-
-
- );
- }
+ const columnNames = tableModel.columns.map(({ text }) => text);
+ const columns = tableModel.columns.map(({ filterable, text }) => ({
+ Header: text,
+ accessor: text,
+ className: VALUE_REGEX.test(text) ? 'text-right' : '',
+ show: text !== 'Time',
+ Cell: row => {row.value} ,
+ }));
+ const noDataText = data ? 'The queries returned no data for a table.' : '';
+
return (
-
-
- {tableModel.columns.map(col => {col.text} )}
-
-
- {tableModel.rows.map((row, i) => (
-
- {row.map((value, j) => (
- |
- ))}
-
- ))}
-
-
+ prepareRows(data, columnNames)}
+ showPagination={Boolean(data)}
+ />
);
}
}
diff --git a/public/app/features/explore/TimePicker.test.tsx b/public/app/features/explore/TimePicker.test.tsx
index afe6b092901..cadcfbb668c 100644
--- a/public/app/features/explore/TimePicker.test.tsx
+++ b/public/app/features/explore/TimePicker.test.tsx
@@ -33,8 +33,8 @@ describe(' ', () => {
to: '1000',
};
const rangeString = rangeUtil.describeTimeRange({
- from: parseTime(range.from),
- to: parseTime(range.to),
+ from: parseTime(range.from, true),
+ to: parseTime(range.to, true),
});
const wrapper = shallow( );
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
@@ -50,8 +50,8 @@ describe(' ', () => {
to: '4000',
};
const rangeString = rangeUtil.describeTimeRange({
- from: parseTime(range.from),
- to: parseTime(range.to),
+ from: parseTime(range.from, true),
+ to: parseTime(range.to, true),
});
const onChangeTime = sinon.spy();
diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx
index 08867f8d0fc..8955fb4aa9b 100644
--- a/public/app/features/explore/TimePicker.tsx
+++ b/public/app/features/explore/TimePicker.tsx
@@ -3,85 +3,112 @@ import moment from 'moment';
import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil';
+import { RawTimeRange } from 'app/types/series';
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
-
export const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
-export function parseTime(value, isUtc = false, asString = false) {
+/**
+ * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
+ * @param value Epoch or relative time
+ */
+export function parseTime(value: string, isUtc = false): string {
if (value.indexOf('now') !== -1) {
return value;
}
- if (!isNaN(value)) {
- const epoch = parseInt(value, 10);
- const m = isUtc ? moment.utc(epoch) : moment(epoch);
- return asString ? m.format(DATE_FORMAT) : m;
+ let time: any = value;
+ // Possible epoch
+ if (!isNaN(time)) {
+ time = parseInt(time, 10);
}
- return undefined;
+ time = isUtc ? moment.utc(time) : moment(time);
+ return time.format(DATE_FORMAT);
}
-export default class TimePicker extends PureComponent {
+interface TimePickerProps {
+ isOpen?: boolean;
+ isUtc?: boolean;
+ range?: RawTimeRange;
+ onChangeTime?: (Range) => void;
+}
+
+interface TimePickerState {
+ isOpen: boolean;
+ isUtc: boolean;
+ rangeString: string;
+ refreshInterval: string;
+
+ // Input-controlled text, keep these in a shape that is human-editable
+ fromRaw: string;
+ toRaw: string;
+}
+
+export default class TimePicker extends PureComponent {
dropdownEl: any;
+
constructor(props) {
super(props);
- const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from;
- const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to;
+ const from = props.range ? props.range.from : DEFAULT_RANGE.from;
+ const to = props.range ? props.range.to : DEFAULT_RANGE.to;
+
+ // Ensure internal format
+ const fromRaw = parseTime(from, props.isUtc);
+ const toRaw = parseTime(to, props.isUtc);
const range = {
- from: parseTime(fromRaw),
- to: parseTime(toRaw),
+ from: fromRaw,
+ to: toRaw,
};
+
this.state = {
- fromRaw: parseTime(fromRaw, props.isUtc, true),
+ fromRaw,
+ toRaw,
isOpen: props.isOpen,
isUtc: props.isUtc,
rangeString: rangeUtil.describeTimeRange(range),
refreshInterval: '',
- toRaw: parseTime(toRaw, props.isUtc, true),
};
}
- move(direction) {
+ move(direction: number) {
const { onChangeTime } = this.props;
const { fromRaw, toRaw } = this.state;
- const range = {
- from: dateMath.parse(fromRaw, false),
- to: dateMath.parse(toRaw, true),
- };
+ const from = dateMath.parse(fromRaw, false);
+ const to = dateMath.parse(toRaw, true);
+ const timespan = (to.valueOf() - from.valueOf()) / 2;
- const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
- let to, from;
+ let nextTo, nextFrom;
if (direction === -1) {
- to = range.to.valueOf() - timespan;
- from = range.from.valueOf() - timespan;
+ nextTo = to.valueOf() - timespan;
+ nextFrom = from.valueOf() - timespan;
} else if (direction === 1) {
- to = range.to.valueOf() + timespan;
- from = range.from.valueOf() + timespan;
- if (to > Date.now() && range.to < Date.now()) {
- to = Date.now();
- from = range.from.valueOf();
+ nextTo = to.valueOf() + timespan;
+ nextFrom = from.valueOf() + timespan;
+ if (nextTo > Date.now() && to < Date.now()) {
+ nextTo = Date.now();
+ nextFrom = from.valueOf();
}
} else {
- to = range.to.valueOf();
- from = range.from.valueOf();
+ nextTo = to.valueOf();
+ nextFrom = from.valueOf();
}
- const rangeString = rangeUtil.describeTimeRange(range);
- // No need to convert to UTC again
- to = moment(to);
- from = moment(from);
+ const nextRange = {
+ from: moment(nextFrom),
+ to: moment(nextTo),
+ };
this.setState(
{
- rangeString,
- fromRaw: from.format(DATE_FORMAT),
- toRaw: to.format(DATE_FORMAT),
+ rangeString: rangeUtil.describeTimeRange(nextRange),
+ fromRaw: nextRange.from.format(DATE_FORMAT),
+ toRaw: nextRange.to.format(DATE_FORMAT),
},
() => {
- onChangeTime({ to, from });
+ onChangeTime(nextRange);
}
);
}
@@ -100,16 +127,19 @@ export default class TimePicker extends PureComponent {
handleClickApply = () => {
const { onChangeTime } = this.props;
- const { toRaw, fromRaw } = this.state;
- const range = {
- from: dateMath.parse(fromRaw, false),
- to: dateMath.parse(toRaw, true),
- };
- const rangeString = rangeUtil.describeTimeRange(range);
+ let range;
this.setState(
- {
- isOpen: false,
- rangeString,
+ state => {
+ const { toRaw, fromRaw } = this.state;
+ range = {
+ from: dateMath.parse(fromRaw, false),
+ to: dateMath.parse(toRaw, true),
+ };
+ const rangeString = rangeUtil.describeTimeRange(range);
+ return {
+ isOpen: false,
+ rangeString,
+ };
},
() => {
if (onChangeTime) {
diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx
index 9924488035c..13882e030f6 100644
--- a/public/app/features/explore/Typeahead.tsx
+++ b/public/app/features/explore/Typeahead.tsx
@@ -1,6 +1,7 @@
import React from 'react';
+import Highlighter from 'react-highlight-words';
-import { Suggestion, SuggestionGroup } from './QueryField';
+import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) {
@@ -14,16 +15,19 @@ function scrollIntoView(el: HTMLElement) {
interface TypeaheadItemProps {
isSelected: boolean;
- item: Suggestion;
+ item: CompletionItem;
onClickItem: (Suggestion) => void;
+ prefix?: string;
}
-class TypeaheadItem extends React.PureComponent {
+class TypeaheadItem extends React.PureComponent {
el: HTMLElement;
componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) {
- scrollIntoView(this.el);
+ requestAnimationFrame(() => {
+ scrollIntoView(this.el);
+ });
}
}
@@ -36,11 +40,12 @@ class TypeaheadItem extends React.PureComponent {
};
render() {
- const { isSelected, item } = this.props;
+ const { isSelected, item, prefix } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
+ const { label } = item;
return (
- {item.detail || item.label}
+
{item.documentation && isSelected ? {item.documentation}
: null}
);
@@ -48,22 +53,29 @@ class TypeaheadItem extends React.PureComponent {
}
interface TypeaheadGroupProps {
- items: Suggestion[];
+ items: CompletionItem[];
label: string;
- onClickItem: (Suggestion) => void;
- selected: Suggestion;
+ onClickItem: (CompletionItem) => void;
+ selected: CompletionItem;
+ prefix?: string;
}
-class TypeaheadGroup extends React.PureComponent {
+class TypeaheadGroup extends React.PureComponent {
render() {
- const { items, label, selected, onClickItem } = this.props;
+ const { items, label, selected, onClickItem, prefix } = this.props;
return (
{label}
{items.map(item => {
return (
-
+
);
})}
@@ -73,18 +85,19 @@ class TypeaheadGroup extends React.PureComponent {
}
interface TypeaheadProps {
- groupedItems: SuggestionGroup[];
+ groupedItems: CompletionItemGroup[];
menuRef: any;
- selectedItem: Suggestion | null;
+ selectedItem: CompletionItem | null;
onClickItem: (Suggestion) => void;
+ prefix?: string;
}
-class Typeahead extends React.PureComponent {
+class Typeahead extends React.PureComponent {
render() {
- const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
+ const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
return (
{groupedItems.map(g => (
-
+
))}
);
diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx
index 6bdbd7cc42f..de1eee4c662 100644
--- a/public/app/features/explore/Wrapper.tsx
+++ b/public/app/features/explore/Wrapper.tsx
@@ -1,33 +1,106 @@
-import React, { PureComponent } from 'react';
+import React, { Component } from 'react';
+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 ErrorBoundary from './ErrorBoundary';
import Explore from './Explore';
-export default class Wrapper extends PureComponent {
- state = {
- initialState: null,
- split: false,
+interface WrapperProps {
+ backendSrv?: any;
+ datasourceSrv?: any;
+ updateLocation: typeof updateLocation;
+ urlStates: { [key: string]: string };
+}
+
+interface WrapperState {
+ split: boolean;
+ splitState: ExploreState;
+}
+
+const STATE_KEY_LEFT = 'state';
+const STATE_KEY_RIGHT = 'stateRight';
+
+export class Wrapper extends Component {
+ urlStates: { [key: string]: string };
+
+ constructor(props: WrapperProps) {
+ super(props);
+ this.urlStates = props.urlStates;
+ this.state = {
+ split: Boolean(props.urlStates[STATE_KEY_RIGHT]),
+ splitState: undefined,
+ };
+ }
+
+ 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,
+ });
+ }
};
- handleChangeSplit = (split, initialState) => {
- this.setState({ split, initialState });
+ onSaveState = (key: string, state: ExploreState) => {
+ const urlState = serializeStateToUrlParam(state, true);
+ this.urlStates[key] = urlState;
+ this.props.updateLocation({
+ query: this.urlStates,
+ });
};
render() {
+ const { datasourceSrv } = this.props;
// State overrides for props from first Explore
- const { initialState, split } = this.state;
+ const { split, splitState } = this.state;
+ const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]);
+ const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]);
+
return (
-
- {split ? (
+
- ) : null}
+
+ {split && (
+
+
+
+ )}
);
}
}
+
+const mapStateToProps = (state: StoreState) => ({
+ urlStates: state.location.query,
+});
+
+const mapDispatchToProps = {
+ updateLocation,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Wrapper));
diff --git a/public/app/features/explore/__mocks__/mockData.ts b/public/app/features/explore/__mocks__/mockData.ts
new file mode 100644
index 00000000000..2e89ded29cf
--- /dev/null
+++ b/public/app/features/explore/__mocks__/mockData.ts
@@ -0,0 +1,274 @@
+export const mockData = () => {
+ return [
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '+Inf',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.1',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.2',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.4',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '1',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '120',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '20',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '3',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '60',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/label/:name/values',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '8',
+ },
+ values: [[1537858100, '16'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '+Inf',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.1',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.4',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '1',
+ },
+ values: [[1537847900, '953'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '120',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '20',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '3',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '60',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/metrics',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '8',
+ },
+ values: [[1537858060, '1195'], [1537858080, '1195'], [1537858100, '1195']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '+Inf',
+ },
+ values: [[1537858100, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.1',
+ },
+ values: [[1537858100, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.2',
+ },
+ values: [[1537858100, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '0.4',
+ },
+ values: [[1537858100, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '1',
+ },
+ values: [[1537858100, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '120',
+ },
+ values: [[1537858100, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '20',
+ },
+ values: [[1537858100, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ {
+ metric: {
+ __name__: 'prometheus_http_request_duration_seconds_bucket',
+ handler: '/query',
+ instance: 'localhost:9090',
+ job: 'prometheus',
+ le: '3',
+ },
+ values: [[1537857260, '55'], [1537861960, '1'], [1537861980, '1']],
+ },
+ ];
+};
diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap
new file mode 100644
index 00000000000..fd2010a76d3
--- /dev/null
+++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap
@@ -0,0 +1,975 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
+
+exports[`Render should render component with disclaimer 1`] = `
+
+
+
+ Showing only 20 time series.
+
+ Show all 27
+
+
+
+
+
+`;
+
+exports[`Render should show query return no time series 1`] = `
+
+`;
diff --git a/public/app/features/explore/utils/query.ts b/public/app/features/explore/utils/query.ts
index d774f619a30..193ee2dbc52 100644
--- a/public/app/features/explore/utils/query.ts
+++ b/public/app/features/explore/utils/query.ts
@@ -1,14 +1,16 @@
-export function generateQueryKey(index = 0) {
+import { Query } from 'app/types/explore';
+
+export function generateQueryKey(index = 0): string {
return `Q-${Date.now()}-${Math.random()}-${index}`;
}
-export function ensureQueries(queries?) {
- if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0] === 'string') {
- return queries.map((query, i) => ({ key: generateQueryKey(i), query }));
+export function ensureQueries(queries?: Query[]): Query[] {
+ if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0].query === 'string') {
+ return queries.map(({ query }, i) => ({ key: generateQueryKey(i), query }));
}
return [{ key: generateQueryKey(), query: '' }];
}
-export function hasQuery(queries) {
- return queries.some(q => q.query);
+export function hasQuery(queries: string[]): boolean {
+ return queries.some(q => Boolean(q));
}
diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts
index df81acb91eb..cd02915e586 100644
--- a/public/app/features/folders/state/actions.ts
+++ b/public/app/features/folders/state/actions.ts
@@ -110,7 +110,7 @@ export function updateFolderPermission(itemToUpdate: DashboardAcl, level: Permis
continue;
}
- const updated = toUpdateItem(itemToUpdate);
+ const updated = toUpdateItem(item);
// if this is the item we want to update, update it's permisssion
if (itemToUpdate === item) {
diff --git a/public/app/features/org/new_org_ctrl.ts b/public/app/features/org/NewOrgCtrl.ts
similarity index 100%
rename from public/app/features/org/new_org_ctrl.ts
rename to public/app/features/org/NewOrgCtrl.ts
diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx
new file mode 100644
index 00000000000..2eb45fa368a
--- /dev/null
+++ b/public/app/features/org/OrgDetailsPage.test.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { OrgDetailsPage, Props } from './OrgDetailsPage';
+import { NavModel, Organization, OrganizationPreferences } from '../../types';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ preferences: {} as OrganizationPreferences,
+ organization: {} as Organization,
+ navModel: {} as NavModel,
+ loadOrganization: jest.fn(),
+ loadOrganizationPreferences: jest.fn(),
+ loadStarredDashboards: jest.fn(),
+ setOrganizationName: jest.fn(),
+ updateOrganization: jest.fn(),
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render organization and preferences', () => {
+ const wrapper = setup({
+ organization: {
+ name: 'Cool org',
+ id: 1,
+ },
+ preferences: {
+ homeDashboardId: 1,
+ theme: 'Default',
+ timezone: 'Default',
+ },
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx
new file mode 100644
index 00000000000..86a3fab3268
--- /dev/null
+++ b/public/app/features/org/OrgDetailsPage.tsx
@@ -0,0 +1,85 @@
+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 OrgProfile from './OrgProfile';
+import OrgPreferences from './OrgPreferences';
+import {
+ loadOrganization,
+ loadOrganizationPreferences,
+ setOrganizationName,
+ updateOrganization,
+} from './state/actions';
+import { loadStarredDashboards } from '../../core/actions/user';
+import { NavModel, Organization, OrganizationPreferences, StoreState } from 'app/types';
+import { getNavModel } from '../../core/selectors/navModel';
+
+export interface Props {
+ navModel: NavModel;
+ organization: Organization;
+ preferences: OrganizationPreferences;
+ loadOrganization: typeof loadOrganization;
+ loadOrganizationPreferences: typeof loadOrganizationPreferences;
+ loadStarredDashboards: typeof loadStarredDashboards;
+ setOrganizationName: typeof setOrganizationName;
+ updateOrganization: typeof updateOrganization;
+}
+
+export class OrgDetailsPage extends PureComponent {
+ async componentDidMount() {
+ await this.props.loadStarredDashboards();
+ await this.props.loadOrganization();
+ await this.props.loadOrganizationPreferences();
+ }
+
+ onOrgNameChange = name => {
+ this.props.setOrganizationName(name);
+ };
+
+ onUpdateOrganization = () => {
+ this.props.updateOrganization();
+ };
+
+ render() {
+ const { navModel, organization, preferences } = this.props;
+
+ return (
+
+
+
+ {Object.keys(organization).length === 0 || Object.keys(preferences).length === 0 ? (
+
+ ) : (
+
+ this.onOrgNameChange(name)}
+ onSubmit={this.onUpdateOrganization}
+ orgName={organization.name}
+ />
+
+
+ )}
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: StoreState) {
+ return {
+ navModel: getNavModel(state.navIndex, 'org-settings'),
+ organization: state.organization.organization,
+ preferences: state.organization.preferences,
+ };
+}
+
+const mapDispatchToProps = {
+ loadOrganization,
+ loadOrganizationPreferences,
+ loadStarredDashboards,
+ setOrganizationName,
+ updateOrganization,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(OrgDetailsPage));
diff --git a/public/app/features/org/OrgPreferences.test.tsx b/public/app/features/org/OrgPreferences.test.tsx
new file mode 100644
index 00000000000..e79e43d04f2
--- /dev/null
+++ b/public/app/features/org/OrgPreferences.test.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { OrgPreferences, Props } from './OrgPreferences';
+
+const setup = () => {
+ const props: Props = {
+ preferences: {
+ homeDashboardId: 1,
+ timezone: 'UTC',
+ theme: 'Default',
+ },
+ starredDashboards: [{ id: 1, title: 'Standard dashboard', url: '', uri: '', uid: '', type: '', tags: [] }],
+ setOrganizationTimezone: jest.fn(),
+ setOrganizationTheme: jest.fn(),
+ setOrganizationHomeDashboard: jest.fn(),
+ updateOrganizationPreferences: jest.fn(),
+ };
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/org/OrgPreferences.tsx b/public/app/features/org/OrgPreferences.tsx
new file mode 100644
index 00000000000..a8133ba4a1c
--- /dev/null
+++ b/public/app/features/org/OrgPreferences.tsx
@@ -0,0 +1,113 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { Label } from '../../core/components/Label/Label';
+import SimplePicker from '../../core/components/Picker/SimplePicker';
+import { DashboardSearchHit, OrganizationPreferences } from 'app/types';
+import {
+ setOrganizationHomeDashboard,
+ setOrganizationTheme,
+ setOrganizationTimezone,
+ updateOrganizationPreferences,
+} from './state/actions';
+
+export interface Props {
+ preferences: OrganizationPreferences;
+ starredDashboards: DashboardSearchHit[];
+ setOrganizationHomeDashboard: typeof setOrganizationHomeDashboard;
+ setOrganizationTheme: typeof setOrganizationTheme;
+ setOrganizationTimezone: typeof setOrganizationTimezone;
+ updateOrganizationPreferences: typeof updateOrganizationPreferences;
+}
+
+const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
+
+const timezones = [
+ { value: '', text: 'Default' },
+ { value: 'browser', text: 'Local browser time' },
+ { value: 'utc', text: 'UTC' },
+];
+
+export class OrgPreferences extends PureComponent {
+ onSubmitForm = event => {
+ event.preventDefault();
+ this.props.updateOrganizationPreferences();
+ };
+
+ render() {
+ const {
+ preferences,
+ starredDashboards,
+ setOrganizationHomeDashboard,
+ setOrganizationTimezone,
+ setOrganizationTheme,
+ } = this.props;
+
+ starredDashboards.unshift({ id: 0, title: 'Default', tags: [], type: '', uid: '', uri: '', url: '' });
+
+ return (
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ preferences: state.organization.preferences,
+ starredDashboards: state.user.starredDashboards,
+ };
+}
+
+const mapDispatchToProps = {
+ setOrganizationHomeDashboard,
+ setOrganizationTimezone,
+ setOrganizationTheme,
+ updateOrganizationPreferences,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(OrgPreferences);
diff --git a/public/app/features/org/OrgProfile.test.tsx b/public/app/features/org/OrgProfile.test.tsx
new file mode 100644
index 00000000000..d101eded13e
--- /dev/null
+++ b/public/app/features/org/OrgProfile.test.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import OrgProfile, { Props } from './OrgProfile';
+
+const setup = () => {
+ const props: Props = {
+ orgName: 'Main org',
+ onSubmit: jest.fn(),
+ onOrgNameChange: jest.fn(),
+ };
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/org/OrgProfile.tsx b/public/app/features/org/OrgProfile.tsx
new file mode 100644
index 00000000000..22dfa7bb1ce
--- /dev/null
+++ b/public/app/features/org/OrgProfile.tsx
@@ -0,0 +1,44 @@
+import React, { SFC } from 'react';
+
+export interface Props {
+ orgName: string;
+ onSubmit: () => void;
+ onOrgNameChange: (orgName: string) => void;
+}
+
+const OrgProfile: SFC = ({ onSubmit, onOrgNameChange, orgName }) => {
+ return (
+
+
Organization profile
+
+
+ );
+};
+
+export default OrgProfile;
diff --git a/public/app/features/org/select_org_ctrl.ts b/public/app/features/org/SelectOrgCtrl.ts
similarity index 100%
rename from public/app/features/org/select_org_ctrl.ts
rename to public/app/features/org/SelectOrgCtrl.ts
diff --git a/public/app/features/org/user_invite_ctrl.ts b/public/app/features/org/UserInviteCtrl.ts
similarity index 100%
rename from public/app/features/org/user_invite_ctrl.ts
rename to public/app/features/org/UserInviteCtrl.ts
diff --git a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
new file mode 100644
index 00000000000..28806d2bf1d
--- /dev/null
+++ b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
@@ -0,0 +1,36 @@
+// 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/org/__snapshots__/OrgPreferences.test.tsx.snap b/public/app/features/org/__snapshots__/OrgPreferences.test.tsx.snap
new file mode 100644
index 00000000000..06bf464a4a0
--- /dev/null
+++ b/public/app/features/org/__snapshots__/OrgPreferences.test.tsx.snap
@@ -0,0 +1,136 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
diff --git a/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap b/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap
new file mode 100644
index 00000000000..b49b63e4532
--- /dev/null
+++ b/public/app/features/org/__snapshots__/OrgProfile.test.tsx.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+ Organization profile
+
+
+
+`;
diff --git a/public/app/features/org/all.ts b/public/app/features/org/all.ts
index 8872450e3ab..c905853cf49 100644
--- a/public/app/features/org/all.ts
+++ b/public/app/features/org/all.ts
@@ -1,11 +1,3 @@
-import './org_users_ctrl';
-import './profile_ctrl';
-import './org_users_ctrl';
-import './select_org_ctrl';
-import './change_password_ctrl';
-import './new_org_ctrl';
-import './user_invite_ctrl';
-import './create_team_ctrl';
-import './org_api_keys_ctrl';
-import './org_details_ctrl';
-import './prefs_control';
+import './SelectOrgCtrl';
+import './NewOrgCtrl';
+import './UserInviteCtrl';
diff --git a/public/app/features/org/org_api_keys_ctrl.ts b/public/app/features/org/org_api_keys_ctrl.ts
deleted file mode 100644
index 1ead0a350b9..00000000000
--- a/public/app/features/org/org_api_keys_ctrl.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import angular from 'angular';
-
-export class OrgApiKeysCtrl {
- /** @ngInject */
- constructor($scope, $http, backendSrv, navModelSrv) {
- $scope.navModel = navModelSrv.getNav('cfg', 'apikeys', 0);
-
- $scope.roleTypes = ['Viewer', 'Editor', 'Admin'];
- $scope.token = { role: 'Viewer' };
-
- $scope.init = () => {
- $scope.getTokens();
- };
-
- $scope.getTokens = () => {
- backendSrv.get('/api/auth/keys').then(tokens => {
- $scope.tokens = tokens;
- });
- };
-
- $scope.removeToken = id => {
- backendSrv.delete('/api/auth/keys/' + id).then($scope.getTokens);
- };
-
- $scope.addToken = () => {
- backendSrv.post('/api/auth/keys', $scope.token).then(result => {
- const modalScope = $scope.$new(true);
- modalScope.key = result.key;
- modalScope.rootPath = window.location.origin + $scope.$root.appSubUrl;
-
- $scope.appEvent('show-modal', {
- src: 'public/app/features/org/partials/apikeyModal.html',
- scope: modalScope,
- });
-
- $scope.getTokens();
- });
- };
-
- $scope.init();
- }
-}
-
-angular.module('grafana.controllers').controller('OrgApiKeysCtrl', OrgApiKeysCtrl);
diff --git a/public/app/features/org/org_details_ctrl.ts b/public/app/features/org/org_details_ctrl.ts
deleted file mode 100644
index 1d4a92c6e8b..00000000000
--- a/public/app/features/org/org_details_ctrl.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import angular from 'angular';
-
-export class OrgDetailsCtrl {
- /** @ngInject */
- constructor($scope, $http, backendSrv, contextSrv, navModelSrv) {
- $scope.init = () => {
- $scope.getOrgInfo();
- $scope.navModel = navModelSrv.getNav('cfg', 'org-settings', 0);
- };
-
- $scope.getOrgInfo = () => {
- backendSrv.get('/api/org').then(org => {
- $scope.org = org;
- $scope.address = org.address;
- contextSrv.user.orgName = org.name;
- });
- };
-
- $scope.update = () => {
- if (!$scope.orgForm.$valid) {
- return;
- }
- const data = { name: $scope.org.name };
- backendSrv.put('/api/org', data).then($scope.getOrgInfo);
- };
-
- $scope.updateAddress = () => {
- if (!$scope.addressForm.$valid) {
- return;
- }
- backendSrv.put('/api/org/address', $scope.address).then($scope.getOrgInfo);
- };
-
- $scope.init();
- }
-}
-
-angular.module('grafana.controllers').controller('OrgDetailsCtrl', OrgDetailsCtrl);
diff --git a/public/app/features/org/org_users_ctrl.ts b/public/app/features/org/org_users_ctrl.ts
deleted file mode 100644
index 625e2749399..00000000000
--- a/public/app/features/org/org_users_ctrl.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import config from 'app/core/config';
-import coreModule from 'app/core/core_module';
-import Remarkable from 'remarkable';
-import _ from 'lodash';
-
-export class OrgUsersCtrl {
- unfiltered: any;
- users: any;
- pendingInvites: any;
- editor: any;
- navModel: any;
- externalUserMngLinkUrl: string;
- externalUserMngLinkName: string;
- externalUserMngInfo: string;
- canInvite: boolean;
- searchQuery: string;
- showInvites: boolean;
-
- /** @ngInject */
- constructor(private $scope, private backendSrv, navModelSrv, $sce) {
- this.navModel = navModelSrv.getNav('cfg', 'users', 0);
-
- this.get();
- this.externalUserMngLinkUrl = config.externalUserMngLinkUrl;
- this.externalUserMngLinkName = config.externalUserMngLinkName;
- this.canInvite = !config.disableLoginForm && !config.externalUserMngLinkName;
-
- // render external user management info markdown
- if (config.externalUserMngInfo) {
- this.externalUserMngInfo = new Remarkable({
- linkTarget: '__blank',
- }).render(config.externalUserMngInfo);
- }
- }
-
- get() {
- this.backendSrv.get('/api/org/users').then(users => {
- this.users = users;
- this.unfiltered = users;
- });
- this.backendSrv.get('/api/org/invites').then(pendingInvites => {
- this.pendingInvites = pendingInvites;
- });
- }
-
- onQueryUpdated() {
- const regex = new RegExp(this.searchQuery, 'ig');
- this.users = _.filter(this.unfiltered, item => {
- return regex.test(item.email) || regex.test(item.login);
- });
- }
-
- updateOrgUser(user) {
- this.backendSrv.patch('/api/org/users/' + user.userId, user);
- }
-
- removeUser(user) {
- this.$scope.appEvent('confirm-modal', {
- title: 'Delete',
- text: 'Are you sure you want to delete user ' + user.login + '?',
- yesText: 'Delete',
- icon: 'fa-warning',
- onConfirm: () => {
- this.removeUserConfirmed(user);
- },
- });
- }
-
- removeUserConfirmed(user) {
- this.backendSrv.delete('/api/org/users/' + user.userId).then(this.get.bind(this));
- }
-
- revokeInvite(invite, evt) {
- evt.stopPropagation();
- this.backendSrv.patch('/api/org/invites/' + invite.code + '/revoke').then(this.get.bind(this));
- }
-
- copyInviteToClipboard(evt) {
- evt.stopPropagation();
- }
-
- getInviteUrl(invite) {
- return invite.url;
- }
-}
-
-coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);
diff --git a/public/app/features/org/partials/apikeyModal.html b/public/app/features/org/partials/apikeyModal.html
deleted file mode 100644
index eeefcafc634..00000000000
--- a/public/app/features/org/partials/apikeyModal.html
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
- You will only be able to view this key here once! It is not stored in this form. So be sure to copy it now.
-
-
- You can authenticate request using the Authorization HTTP header, example:
-
-
-
-curl -H "Authorization: Bearer {{key}}" {{rootPath}}/api/dashboards/home
-
-
-
-
-
-
-
diff --git a/public/app/features/org/partials/orgApiKeys.html b/public/app/features/org/partials/orgApiKeys.html
deleted file mode 100644
index a2b4ceb6670..00000000000
--- a/public/app/features/org/partials/orgApiKeys.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
Add new
-
-
-
-
Existing Keys
-
-
-
- Name
- Role
-
-
-
-
-
- {{t.name}}
- {{t.role}}
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/app/features/org/partials/orgDetails.html b/public/app/features/org/partials/orgDetails.html
deleted file mode 100644
index f5a49e3d3ee..00000000000
--- a/public/app/features/org/partials/orgDetails.html
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
Organization profile
-
-
-
-
-
-
diff --git a/public/app/features/org/partials/orgUsers.html b/public/app/features/org/partials/orgUsers.html
deleted file mode 100644
index 697879c6ac2..00000000000
--- a/public/app/features/org/partials/orgUsers.html
+++ /dev/null
@@ -1,96 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Pending Invites ({{ctrl.pendingInvites.length}})
-
-
-
-
- Invite
-
-
-
-
- {{ctrl.externalUserMngLinkName}}
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/app/features/org/prefs_control.ts b/public/app/features/org/prefs_control.ts
deleted file mode 100644
index 74dde250eec..00000000000
--- a/public/app/features/org/prefs_control.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import config from 'app/core/config';
-import coreModule from 'app/core/core_module';
-
-export class PrefsControlCtrl {
- prefs: any;
- oldTheme: any;
- prefsForm: any;
- mode: string;
-
- timezones: any = [
- { value: '', text: 'Default' },
- { value: 'browser', text: 'Local browser time' },
- { value: 'utc', text: 'UTC' },
- ];
- themes: any = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
-
- /** @ngInject */
- constructor(private backendSrv, private $location) {}
-
- $onInit() {
- return this.backendSrv.get(`/api/${this.mode}/preferences`).then(prefs => {
- this.prefs = prefs;
- this.oldTheme = prefs.theme;
- });
- }
-
- updatePrefs() {
- if (!this.prefsForm.$valid) {
- return;
- }
-
- const cmd = {
- theme: this.prefs.theme,
- timezone: this.prefs.timezone,
- homeDashboardId: this.prefs.homeDashboardId,
- };
-
- this.backendSrv.put(`/api/${this.mode}/preferences`, cmd).then(() => {
- window.location.href = config.appSubUrl + this.$location.path();
- });
- }
-}
-
-const template = `
-
-`;
-
-export function prefsControlDirective() {
- return {
- restrict: 'E',
- controller: PrefsControlCtrl,
- bindToController: true,
- controllerAs: 'ctrl',
- template: template,
- scope: {
- mode: '@',
- },
- };
-}
-
-coreModule.directive('prefsControl', prefsControlDirective);
diff --git a/public/app/features/org/state/actions.ts b/public/app/features/org/state/actions.ts
new file mode 100644
index 00000000000..4df9083c323
--- /dev/null
+++ b/public/app/features/org/state/actions.ts
@@ -0,0 +1,118 @@
+import { ThunkAction } from 'redux-thunk';
+import { Organization, OrganizationPreferences, StoreState } from 'app/types';
+import { getBackendSrv } from '../../../core/services/backend_srv';
+
+type ThunkResult = ThunkAction;
+
+export enum ActionTypes {
+ LoadOrganization = 'LOAD_ORGANISATION',
+ LoadPreferences = 'LOAD_PREFERENCES',
+ SetOrganizationName = 'SET_ORGANIZATION_NAME',
+ SetOrganizationTheme = 'SET_ORGANIZATION_THEME',
+ SetOrganizationHomeDashboard = 'SET_ORGANIZATION_HOME_DASHBOARD',
+ SetOrganizationTimezone = 'SET_ORGANIZATION_TIMEZONE',
+}
+
+interface LoadOrganizationAction {
+ type: ActionTypes.LoadOrganization;
+ payload: Organization;
+}
+
+interface LoadPreferencesAction {
+ type: ActionTypes.LoadPreferences;
+ payload: OrganizationPreferences;
+}
+
+interface SetOrganizationNameAction {
+ type: ActionTypes.SetOrganizationName;
+ payload: string;
+}
+
+interface SetOrganizationThemeAction {
+ type: ActionTypes.SetOrganizationTheme;
+ payload: string;
+}
+
+interface SetOrganizationHomeDashboardAction {
+ type: ActionTypes.SetOrganizationHomeDashboard;
+ payload: number;
+}
+
+interface SetOrganizationTimezoneAction {
+ type: ActionTypes.SetOrganizationTimezone;
+ payload: string;
+}
+
+const organisationLoaded = (organisation: Organization) => ({
+ type: ActionTypes.LoadOrganization,
+ payload: organisation,
+});
+
+const preferencesLoaded = (preferences: OrganizationPreferences) => ({
+ type: ActionTypes.LoadPreferences,
+ payload: preferences,
+});
+
+export const setOrganizationName = (orgName: string) => ({
+ type: ActionTypes.SetOrganizationName,
+ payload: orgName,
+});
+
+export const setOrganizationTheme = (theme: string) => ({
+ type: ActionTypes.SetOrganizationTheme,
+ payload: theme,
+});
+
+export const setOrganizationHomeDashboard = (id: number) => ({
+ type: ActionTypes.SetOrganizationHomeDashboard,
+ payload: id,
+});
+
+export const setOrganizationTimezone = (timezone: string) => ({
+ type: ActionTypes.SetOrganizationTimezone,
+ payload: timezone,
+});
+
+export type Action =
+ | LoadOrganizationAction
+ | LoadPreferencesAction
+ | SetOrganizationNameAction
+ | SetOrganizationThemeAction
+ | SetOrganizationHomeDashboardAction
+ | SetOrganizationTimezoneAction;
+
+export function loadOrganization(): ThunkResult {
+ return async dispatch => {
+ const organisationResponse = await getBackendSrv().get('/api/org');
+ dispatch(organisationLoaded(organisationResponse));
+
+ return organisationResponse;
+ };
+}
+
+export function loadOrganizationPreferences(): ThunkResult {
+ return async dispatch => {
+ const preferencesResponse = await getBackendSrv().get('/api/org/preferences');
+ dispatch(preferencesLoaded(preferencesResponse));
+ };
+}
+
+export function updateOrganization() {
+ return async (dispatch, getStore) => {
+ const organization = getStore().organization.organization;
+
+ await getBackendSrv().put('/api/org', { name: organization.name });
+
+ dispatch(loadOrganization());
+ };
+}
+
+export function updateOrganizationPreferences() {
+ return async (dispatch, getStore) => {
+ const preferences = getStore().organization.preferences;
+
+ await getBackendSrv().put('/api/org/preferences', preferences);
+
+ window.location.reload();
+ };
+}
diff --git a/public/app/features/org/state/reducers.ts b/public/app/features/org/state/reducers.ts
new file mode 100644
index 00000000000..b79f915a731
--- /dev/null
+++ b/public/app/features/org/state/reducers.ts
@@ -0,0 +1,35 @@
+import { Organization, OrganizationPreferences, OrganizationState } from 'app/types';
+import { Action, ActionTypes } from './actions';
+
+const initialState: OrganizationState = {
+ organization: {} as Organization,
+ preferences: {} as OrganizationPreferences,
+};
+
+const organizationReducer = (state = initialState, action: Action): OrganizationState => {
+ switch (action.type) {
+ case ActionTypes.LoadOrganization:
+ return { ...state, organization: action.payload };
+
+ case ActionTypes.LoadPreferences:
+ return { ...state, preferences: action.payload };
+
+ case ActionTypes.SetOrganizationName:
+ return { ...state, organization: { ...state.organization, name: action.payload } };
+
+ case ActionTypes.SetOrganizationTheme:
+ return { ...state, preferences: { ...state.preferences, theme: action.payload } };
+
+ case ActionTypes.SetOrganizationHomeDashboard:
+ return { ...state, preferences: { ...state.preferences, homeDashboardId: action.payload } };
+
+ case ActionTypes.SetOrganizationTimezone:
+ return { ...state, preferences: { ...state.preferences, timezone: action.payload } };
+ }
+
+ return state;
+};
+
+export default {
+ organization: organizationReducer,
+};
diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts
index 5eecf6036d8..e517c48bb59 100644
--- a/public/app/features/panel/metrics_panel_ctrl.ts
+++ b/public/app/features/panel/metrics_panel_ctrl.ts
@@ -6,14 +6,12 @@ import kbn from 'app/core/utils/kbn';
import { PanelCtrl } from 'app/features/panel/panel_ctrl';
import * as rangeUtil from 'app/core/utils/rangeutil';
import * as dateMath from 'app/core/utils/datemath';
-import { encodePathComponent } from 'app/core/utils/location_util';
-
+import { getExploreUrl } from 'app/core/utils/explore';
import { metricsTabDirective } from './metrics_tab';
class MetricsPanelCtrl extends PanelCtrl {
scope: any;
datasource: any;
- datasourceName: any;
$q: any;
$timeout: any;
contextSrv: any;
@@ -45,10 +43,6 @@ class MetricsPanelCtrl extends PanelCtrl {
this.scope = $scope;
this.panel.datasource = this.panel.datasource || null;
- if (!this.panel.targets) {
- this.panel.targets = [{}];
- }
-
this.events.on('refresh', this.onMetricsPanelRefresh.bind(this));
this.events.on('init-edit-mode', this.onInitMetricsPanelEditMode.bind(this));
this.events.on('panel-teardown', this.onPanelTearDown.bind(this));
@@ -62,7 +56,7 @@ class MetricsPanelCtrl extends PanelCtrl {
}
private onInitMetricsPanelEditMode() {
- this.addEditorTab('Metrics', metricsTabDirective);
+ this.addEditorTab('Metrics', metricsTabDirective, 1, 'fa fa-database');
this.addEditorTab('Time range', 'public/app/features/panel/partials/panelTime.html');
}
@@ -291,30 +285,14 @@ class MetricsPanelCtrl extends PanelCtrl {
});
}
- setDatasource(datasource) {
- // switching to mixed
- if (datasource.meta.mixed) {
- _.each(this.panel.targets, target => {
- target.datasource = this.panel.datasource;
- if (!target.datasource) {
- target.datasource = config.defaultDatasource;
- }
- });
- } else if (this.datasource && this.datasource.meta.mixed) {
- _.each(this.panel.targets, target => {
- delete target.datasource;
- });
- }
-
- this.panel.datasource = datasource.value;
- this.datasourceName = datasource.name;
- this.datasource = null;
- this.refresh();
- }
-
getAdditionalMenuItems() {
const items = [];
- if (config.exploreEnabled && this.contextSrv.isEditor && this.datasource && this.datasource.supportsExplore) {
+ if (
+ config.exploreEnabled &&
+ this.contextSrv.isEditor &&
+ this.datasource &&
+ (this.datasource.meta.explore || this.datasource.meta.id === 'mixed')
+ ) {
items.push({
text: 'Explore',
click: 'ctrl.explore();',
@@ -325,14 +303,11 @@ class MetricsPanelCtrl extends PanelCtrl {
return items;
}
- explore() {
- const range = this.timeSrv.timeRangeForUrl();
- const state = {
- ...this.datasource.getExploreState(this.panel),
- range,
- };
- const exploreState = encodePathComponent(JSON.stringify(state));
- this.$location.url(`/explore?state=${exploreState}`);
+ async explore() {
+ const url = await getExploreUrl(this.panel, this.panel.targets, this.datasource, this.datasourceSrv, this.timeSrv);
+ if (url) {
+ this.$timeout(() => this.$location.url(url));
+ }
}
addQuery(target) {
diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts
index 3a1d0abe1c2..f520b5eefc0 100644
--- a/public/app/features/panel/metrics_tab.ts
+++ b/public/app/features/panel/metrics_tab.ts
@@ -1,6 +1,14 @@
-import { DashboardModel } from '../dashboard/dashboard_model';
+// Libraries
+import _ from 'lodash';
import Remarkable from 'remarkable';
+// Services & utils
+import coreModule from 'app/core/core_module';
+import config from 'app/core/config';
+
+// Types
+import { DashboardModel } from '../dashboard/dashboard_model';
+
export class MetricsTabCtrl {
dsName: string;
panel: any;
@@ -24,6 +32,9 @@ export class MetricsTabCtrl {
$scope.ctrl = this;
this.panel = this.panelCtrl.panel;
+ this.panel.datasource = this.panel.datasource || null;
+ this.panel.targets = this.panel.targets || [{}];
+
this.dashboard = this.panelCtrl.dashboard;
this.datasources = datasourceSrv.getMetricSources();
this.panelDsValue = this.panelCtrl.panel.datasource;
@@ -66,10 +77,29 @@ export class MetricsTabCtrl {
}
this.datasourceInstance = option.datasource;
- this.panelCtrl.setDatasource(option.datasource);
+ this.setDatasource(option.datasource);
this.updateDatasourceOptions();
}
+ setDatasource(datasource) {
+ // switching to mixed
+ if (datasource.meta.mixed) {
+ _.each(this.panel.targets, target => {
+ target.datasource = this.panel.datasource;
+ if (!target.datasource) {
+ target.datasource = config.defaultDatasource;
+ }
+ });
+ } else if (this.datasourceInstance && this.datasourceInstance.meta.mixed) {
+ _.each(this.panel.targets, target => {
+ delete target.datasource;
+ });
+ }
+
+ this.panel.datasource = datasource.value;
+ this.panel.refresh();
+ }
+
addMixedQuery(option) {
if (!option) {
return;
@@ -120,3 +150,5 @@ export function metricsTabDirective() {
controller: MetricsTabCtrl,
};
}
+
+coreModule.directive('metricsTab', metricsTabDirective);
diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts
index e2ae5cc78a9..08605132e82 100644
--- a/public/app/features/panel/panel_ctrl.ts
+++ b/public/app/features/panel/panel_ctrl.ts
@@ -24,10 +24,8 @@ export class PanelCtrl {
$injector: any;
$location: any;
$timeout: any;
- fullscreen: boolean;
inspector: any;
editModeInitiated: boolean;
- editMode: any;
height: any;
containerHeight: any;
events: Emitter;
@@ -49,22 +47,12 @@ export class PanelCtrl {
this.pluginName = plugin.name;
}
- $scope.$on('refresh', () => this.refresh());
$scope.$on('component-did-mount', () => this.panelDidMount());
-
- $scope.$on('$destroy', () => {
- this.events.emit('panel-teardown');
- this.events.removeAllListeners();
- });
- }
-
- init() {
- this.events.emit('panel-initialized');
- this.publishAppEvent('panel-initialized', { scope: this.$scope });
}
panelDidMount() {
this.events.emit('component-did-mount');
+ this.dashboard.panelInitialized(this.panel);
}
renderingCompleted() {
@@ -72,7 +60,7 @@ export class PanelCtrl {
}
refresh() {
- this.events.emit('refresh', null);
+ this.panel.refresh();
}
publishAppEvent(evtName, evt) {
@@ -102,6 +90,7 @@ export class PanelCtrl {
initEditMode() {
this.editorTabs = [];
this.addEditorTab('General', 'public/app/partials/panelgeneral.html');
+
this.editModeInitiated = true;
this.events.emit('init-edit-mode', null);
@@ -122,14 +111,15 @@ export class PanelCtrl {
route.updateParams();
}
- addEditorTab(title, directiveFn, index?) {
- const editorTab = { title, directiveFn };
+ addEditorTab(title, directiveFn, index?, icon?) {
+ const editorTab = { title, directiveFn, icon };
if (_.isString(directiveFn)) {
editorTab.directiveFn = () => {
return { templateUrl: directiveFn };
};
}
+
if (index) {
this.editorTabs.splice(index, 0, editorTab);
} else {
@@ -190,7 +180,7 @@ export class PanelCtrl {
getExtendedMenu() {
const menu = [];
- if (!this.fullscreen && this.dashboard.meta.canEdit) {
+ if (!this.panel.fullscreen && this.dashboard.meta.canEdit) {
menu.push({
text: 'Duplicate',
click: 'ctrl.duplicate()',
@@ -220,15 +210,15 @@ export class PanelCtrl {
}
otherPanelInFullscreenMode() {
- return this.dashboard.meta.fullscreen && !this.fullscreen;
+ return this.dashboard.meta.fullscreen && !this.panel.fullscreen;
}
calculatePanelHeight() {
- if (this.fullscreen) {
- const docHeight = $(window).height();
- const editHeight = Math.floor(docHeight * 0.4);
+ if (this.panel.fullscreen) {
+ const docHeight = $('.react-grid-layout').height();
+ const editHeight = Math.floor(docHeight * 0.35);
const fullscreenHeight = Math.floor(docHeight * 0.8);
- this.containerHeight = this.editMode ? editHeight : fullscreenHeight;
+ this.containerHeight = this.panel.isEditing ? editHeight : fullscreenHeight;
} else {
this.containerHeight = this.panel.gridPos.h * GRID_CELL_HEIGHT + (this.panel.gridPos.h - 1) * GRID_CELL_VMARGIN;
}
@@ -237,6 +227,11 @@ export class PanelCtrl {
this.containerHeight = $(window).height();
}
+ // hacky solution
+ if (this.panel.isEditing && !this.editModeInitiated) {
+ this.initEditMode();
+ }
+
this.height = this.containerHeight - (PANEL_BORDER + TITLE_HEIGHT);
}
@@ -247,9 +242,6 @@ export class PanelCtrl {
duplicate() {
this.dashboard.duplicatePanel(this.panel);
- this.$timeout(() => {
- this.$scope.$root.$broadcast('render');
- });
}
removePanel() {
diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts
index 8b742e17952..77ebf754b3a 100644
--- a/public/app/features/panel/panel_directive.ts
+++ b/public/app/features/panel/panel_directive.ts
@@ -6,48 +6,53 @@ import baron from 'baron';
const module = angular.module('grafana.directives');
const panelTemplate = `
-
-
diff --git a/public/app/features/org/partials/create_team.html b/public/app/features/teams/partials/create_team.html
similarity index 100%
rename from public/app/features/org/partials/create_team.html
rename to public/app/features/teams/partials/create_team.html
diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts
index 8b76028b9cb..2e72dce0afb 100644
--- a/public/app/features/teams/state/reducers.ts
+++ b/public/app/features/teams/state/reducers.ts
@@ -1,7 +1,7 @@
import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
import { Action, ActionTypes } from './actions';
-export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' };
+export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', hasFetched: false };
export const initialTeamState: TeamState = {
team: {} as Team,
members: [] as TeamMember[],
@@ -12,7 +12,7 @@ export const initialTeamState: TeamState = {
export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => {
switch (action.type) {
case ActionTypes.LoadTeams:
- return { ...state, teams: action.payload };
+ return { ...state, hasFetched: true, teams: action.payload };
case ActionTypes.SetSearchQuery:
return { ...state, searchQuery: action.payload };
diff --git a/public/app/features/teams/state/selectors.test.ts b/public/app/features/teams/state/selectors.test.ts
index 5f338069bbb..3764a9355c6 100644
--- a/public/app/features/teams/state/selectors.test.ts
+++ b/public/app/features/teams/state/selectors.test.ts
@@ -7,7 +7,7 @@ describe('Teams selectors', () => {
const mockTeams = getMultipleMockTeams(5);
it('should return teams if no search query', () => {
- const mockState: TeamsState = { teams: mockTeams, searchQuery: '' };
+ const mockState: TeamsState = { teams: mockTeams, searchQuery: '', hasFetched: false };
const teams = getTeams(mockState);
@@ -15,7 +15,7 @@ describe('Teams selectors', () => {
});
it('Should filter teams if search query', () => {
- const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' };
+ const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', hasFetched: false };
const teams = getTeams(mockState);
diff --git a/public/app/features/templating/TextBoxVariable.ts b/public/app/features/templating/TextBoxVariable.ts
new file mode 100644
index 00000000000..331ff4f95b8
--- /dev/null
+++ b/public/app/features/templating/TextBoxVariable.ts
@@ -0,0 +1,58 @@
+import { Variable, assignModelProperties, variableTypes } from './variable';
+
+export class TextBoxVariable implements Variable {
+ query: string;
+ current: any;
+ options: any[];
+ skipUrlSync: boolean;
+
+ defaults = {
+ type: 'textbox',
+ name: '',
+ hide: 2,
+ label: '',
+ query: '',
+ current: {},
+ options: [],
+ skipUrlSync: false,
+ };
+
+ /** @ngInject */
+ constructor(private model, private variableSrv) {
+ assignModelProperties(this, model, this.defaults);
+ }
+
+ getSaveModel() {
+ assignModelProperties(this.model, this, this.defaults);
+ return this.model;
+ }
+
+ setValue(option) {
+ this.variableSrv.setOptionAsCurrent(this, option);
+ }
+
+ updateOptions() {
+ this.options = [{ text: this.query.trim(), value: this.query.trim() }];
+ this.current = this.options[0];
+ return Promise.resolve();
+ }
+
+ dependsOn(variable) {
+ return false;
+ }
+
+ setValueFromUrl(urlValue) {
+ this.query = urlValue;
+ return this.variableSrv.setOptionFromUrl(this, urlValue);
+ }
+
+ getValueForUrl() {
+ return this.current.value;
+ }
+}
+
+variableTypes['textbox'] = {
+ name: 'Text box',
+ ctor: TextBoxVariable,
+ description: 'Define a textbox variable, where users can enter any arbitrary string',
+};
diff --git a/public/app/features/templating/all.ts b/public/app/features/templating/all.ts
index 16465740642..b872fa6cd4a 100644
--- a/public/app/features/templating/all.ts
+++ b/public/app/features/templating/all.ts
@@ -9,6 +9,7 @@ import { DatasourceVariable } from './datasource_variable';
import { CustomVariable } from './custom_variable';
import { ConstantVariable } from './constant_variable';
import { AdhocVariable } from './adhoc_variable';
+import { TextBoxVariable } from './TextBoxVariable';
coreModule.factory('templateSrv', () => {
return templateSrv;
@@ -22,4 +23,5 @@ export {
CustomVariable,
ConstantVariable,
AdhocVariable,
+ TextBoxVariable,
};
diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html
index 0d8b0ace327..c4463972177 100644
--- a/public/app/features/templating/partials/editor.html
+++ b/public/app/features/templating/partials/editor.html
@@ -115,7 +115,7 @@
Values
-
+
@@ -155,6 +155,14 @@
+
+
Query Options
diff --git a/public/app/features/templating/specs/template_srv.test.ts b/public/app/features/templating/specs/template_srv.test.ts
index 7f5ff959216..7805341d1a2 100644
--- a/public/app/features/templating/specs/template_srv.test.ts
+++ b/public/app/features/templating/specs/template_srv.test.ts
@@ -286,10 +286,40 @@ describe('templateSrv', () => {
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]);
});
- it('should return true if exists', () => {
+ it('should return true if $test exists', () => {
const result = _templateSrv.variableExists('$test');
expect(result).toBe(true);
});
+
+ it('should return true if $test exists in string', () => {
+ const result = _templateSrv.variableExists('something $test something');
+ expect(result).toBe(true);
+ });
+
+ it('should return true if [[test]] exists in string', () => {
+ const result = _templateSrv.variableExists('something [[test]] something');
+ expect(result).toBe(true);
+ });
+
+ it('should return true if [[test:csv]] exists in string', () => {
+ const result = _templateSrv.variableExists('something [[test:csv]] something');
+ expect(result).toBe(true);
+ });
+
+ it('should return true if ${test} exists in string', () => {
+ const result = _templateSrv.variableExists('something ${test} something');
+ expect(result).toBe(true);
+ });
+
+ it('should return true if ${test:raw} exists in string', () => {
+ const result = _templateSrv.variableExists('something ${test:raw} something');
+ expect(result).toBe(true);
+ });
+
+ it('should return null if there are no variables in string', () => {
+ const result = _templateSrv.variableExists('string without variables');
+ expect(result).toBe(null);
+ });
});
describe('can highlight variables in string', () => {
@@ -429,6 +459,11 @@ describe('templateSrv', () => {
name: 'period',
current: { value: '$__auto_interval_interval', text: 'auto' },
},
+ {
+ type: 'textbox',
+ name: 'empty_on_init',
+ current: { value: '', text: '' },
+ },
]);
_templateSrv.setGrafanaVariable('$__auto_interval_interval', '13m');
_templateSrv.updateTemplateData();
@@ -438,6 +473,11 @@ describe('templateSrv', () => {
const target = _templateSrv.replaceWithText('Server: $server, period: $period');
expect(target).toBe('Server: All, period: 13m');
});
+
+ it('should replace empty string-values with an empty string', () => {
+ const target = _templateSrv.replaceWithText('Hello $empty_on_init');
+ expect(target).toBe('Hello ');
+ });
});
describe('built in interval variables', () => {
diff --git a/public/app/features/templating/specs/variable.test.ts b/public/app/features/templating/specs/variable.test.ts
index 6d5e88fa4bd..83f4af8bca9 100644
--- a/public/app/features/templating/specs/variable.test.ts
+++ b/public/app/features/templating/specs/variable.test.ts
@@ -22,6 +22,11 @@ describe('containsVariable', () => {
expect(contains).toBe(true);
});
+ it('should find it with [[var:option]] syntax', () => {
+ const contains = containsVariable('this.[[test:csv]].filters', 'test');
+ expect(contains).toBe(true);
+ });
+
it('should find it when part of segment', () => {
const contains = containsVariable('metrics.$env.$group-*', 'group');
expect(contains).toBe(true);
@@ -36,6 +41,16 @@ describe('containsVariable', () => {
const contains = containsVariable('asd', 'asd2.$env', 'env');
expect(contains).toBe(true);
});
+
+ it('should find it with ${var} syntax', () => {
+ const contains = containsVariable('this.${test}.filters', 'test');
+ expect(contains).toBe(true);
+ });
+
+ it('should find it with ${var:option} syntax', () => {
+ const contains = containsVariable('this.${test:csv}.filters', 'test');
+ expect(contains).toBe(true);
+ });
});
});
diff --git a/public/app/features/templating/specs/variable_srv.test.ts b/public/app/features/templating/specs/variable_srv.test.ts
index 359d5b79a38..3df6ccb8b5b 100644
--- a/public/app/features/templating/specs/variable_srv.test.ts
+++ b/public/app/features/templating/specs/variable_srv.test.ts
@@ -1,5 +1,6 @@
import '../all';
import { VariableSrv } from '../variable_srv';
+import { DashboardModel } from '../../dashboard/dashboard_model';
import moment from 'moment';
import $q from 'q';
@@ -56,10 +57,12 @@ describe('VariableSrv', function(this: any) {
return getVarMockConstructor(ctr, model, ctx);
};
- ctx.variableSrv.init({
- templating: { list: [] },
- updateSubmenuVisibility: () => {},
- });
+ ctx.variableSrv.init(
+ new DashboardModel({
+ templating: { list: [] },
+ updateSubmenuVisibility: () => {},
+ })
+ );
scenario.variable = ctx.variableSrv.createVariableFromModel(scenario.variableModel);
ctx.variableSrv.addVariable(scenario.variable);
diff --git a/public/app/features/templating/specs/variable_srv_init.test.ts b/public/app/features/templating/specs/variable_srv_init.test.ts
index b5d00a5289e..bda5b6aa577 100644
--- a/public/app/features/templating/specs/variable_srv_init.test.ts
+++ b/public/app/features/templating/specs/variable_srv_init.test.ts
@@ -2,6 +2,7 @@ import '../all';
import _ from 'lodash';
import { VariableSrv } from '../variable_srv';
+import { DashboardModel } from '../../dashboard/dashboard_model';
import $q from 'q';
describe('VariableSrv init', function(this: any) {
@@ -56,9 +57,9 @@ describe('VariableSrv init', function(this: any) {
ctx.variableSrv.datasourceSrv = ctx.datasourceSrv;
ctx.variableSrv.$location.search = () => scenario.urlParams;
- ctx.variableSrv.dashboard = {
+ ctx.variableSrv.dashboard = new DashboardModel({
templating: { list: scenario.variables },
- };
+ });
await ctx.variableSrv.init(ctx.variableSrv.dashboard);
diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts
index 70fd287402f..0db7b8e77e0 100644
--- a/public/app/features/templating/template_srv.ts
+++ b/public/app/features/templating/template_srv.ts
@@ -1,5 +1,6 @@
import kbn from 'app/core/utils/kbn';
import _ from 'lodash';
+import { variableRegex } from 'app/features/templating/variable';
function luceneEscape(value) {
return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1');
@@ -8,13 +9,7 @@ function luceneEscape(value) {
export class TemplateSrv {
variables: any[];
- /*
- * This regex matches 3 types of variable reference with an optional format specifier
- * \$(\w+) $var1
- * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
- * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3}
- */
- private regex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g;
+ private regex = variableRegex;
private index = {};
private grafanaVariables = {};
private builtIns = {};
@@ -30,17 +25,14 @@ export class TemplateSrv {
}
updateTemplateData() {
- this.index = {};
+ const existsOrEmpty = value => value || value === '';
- for (let i = 0; i < this.variables.length; i++) {
- const variable = this.variables[i];
-
- if (!variable.current || (!variable.current.isNone && !variable.current.value)) {
- continue;
+ this.index = this.variables.reduce((acc, currentValue) => {
+ if (currentValue.current && !currentValue.current.isNone && existsOrEmpty(currentValue.current.value)) {
+ acc[currentValue.name] = currentValue;
}
-
- this.index[variable.name] = variable;
- }
+ return acc;
+ }, {});
}
variableInitialized(variable) {
@@ -144,7 +136,8 @@ export class TemplateSrv {
if (!match) {
return null;
}
- return match[1] || match[2];
+ const variableName = match.slice(1).find(match => match !== undefined);
+ return variableName;
}
variableExists(expression) {
diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts
index 412426fb294..1994e86eff0 100644
--- a/public/app/features/templating/variable.ts
+++ b/public/app/features/templating/variable.ts
@@ -1,6 +1,19 @@
-import kbn from 'app/core/utils/kbn';
import { assignModelProperties } from 'app/core/utils/model_utils';
+/*
+ * This regex matches 3 types of variable reference with an optional format specifier
+ * \$(\w+) $var1
+ * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]]
+ * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3}
+ */
+export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g;
+
+// Helper function since lastIndex is not reset
+export const variableRegexExec = (variableString: string) => {
+ variableRegex.lastIndex = 0;
+ return variableRegex.exec(variableString);
+};
+
export interface Variable {
setValue(option);
updateOptions();
@@ -14,15 +27,16 @@ export let variableTypes = {};
export { assignModelProperties };
export function containsVariable(...args: any[]) {
- let variableName = args[args.length - 1];
- let str = args[0] || '';
+ const variableName = args[args.length - 1];
+ const variableString = args.slice(0, -1).join(' ');
+ const matches = variableString.match(variableRegex);
+ const isMatchingVariable =
+ matches !== null
+ ? matches.find(match => {
+ const varMatch = variableRegexExec(match);
+ return varMatch !== null && varMatch.indexOf(variableName) > -1;
+ })
+ : false;
- for (let i = 1; i < args.length - 1; i++) {
- str += ' ' + args[i] || '';
- }
-
- variableName = kbn.regexEscape(variableName);
- const findVarRegex = new RegExp('\\$(' + variableName + ')(?:\\W|$)|\\[\\[(' + variableName + ')\\]\\]', 'g');
- const match = findVarRegex.exec(str);
- return match !== null;
+ return !!isMatchingVariable;
}
diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts
index 8c0f1f11f77..a676f9c2848 100644
--- a/public/app/features/templating/variable_srv.ts
+++ b/public/app/features/templating/variable_srv.ts
@@ -1,5 +1,8 @@
+// Libaries
import angular from 'angular';
import _ from 'lodash';
+
+// Utils & Services
import coreModule from 'app/core/core_module';
import { variableTypes } from './variable';
import { Graph } from 'app/core/utils/dag';
@@ -10,13 +13,12 @@ export class VariableSrv {
/** @ngInject */
constructor(private $rootScope, private $q, private $location, private $injector, private templateSrv) {
- // update time variant variables
- $rootScope.$on('refresh', this.onDashboardRefresh.bind(this), $rootScope);
$rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope);
}
init(dashboard) {
this.dashboard = dashboard;
+ this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this));
// create working class models representing variables
this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this));
@@ -39,11 +41,7 @@ export class VariableSrv {
});
}
- onDashboardRefresh(evt, payload) {
- if (payload && payload.fromVariableValueUpdated) {
- return Promise.resolve({});
- }
-
+ onTimeRangeUpdated() {
const promises = this.variables.filter(variable => variable.refresh === 2).map(variable => {
const previousOptions = variable.options.slice();
@@ -54,7 +52,9 @@ export class VariableSrv {
});
});
- return this.$q.all(promises);
+ return this.$q.all(promises).then(() => {
+ this.dashboard.startRefresh();
+ });
}
processVariable(variable, queryParams) {
@@ -133,7 +133,7 @@ export class VariableSrv {
return this.$q.all(promises).then(() => {
if (emitChangeEvents) {
this.$rootScope.$emit('template-variable-value-updated');
- this.$rootScope.$broadcast('refresh', { fromVariableValueUpdated: true });
+ this.dashboard.startRefresh();
}
});
}
@@ -291,9 +291,11 @@ export class VariableSrv {
createGraph() {
const g = new Graph();
- this.variables.forEach(v1 => {
- g.createNode(v1.name);
+ this.variables.forEach(v => {
+ g.createNode(v.name);
+ });
+ this.variables.forEach(v1 => {
this.variables.forEach(v2 => {
if (v1 === v2) {
return;
diff --git a/public/app/features/users/InviteesTable.test.tsx b/public/app/features/users/InviteesTable.test.tsx
new file mode 100644
index 00000000000..e40ad033c57
--- /dev/null
+++ b/public/app/features/users/InviteesTable.test.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import InviteesTable, { Props } from './InviteesTable';
+import { Invitee } from 'app/types';
+import { getMockInvitees } from './__mocks__/userMocks';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ invitees: [] as Invitee[],
+ onRevokeInvite: jest.fn(),
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow(
);
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render invitees', () => {
+ const wrapper = setup({
+ invitees: getMockInvitees(5),
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/users/InviteesTable.tsx b/public/app/features/users/InviteesTable.tsx
new file mode 100644
index 00000000000..2521fbd09e2
--- /dev/null
+++ b/public/app/features/users/InviteesTable.tsx
@@ -0,0 +1,64 @@
+import React, { createRef, PureComponent } from 'react';
+import { Invitee } from 'app/types';
+
+export interface Props {
+ invitees: Invitee[];
+ onRevokeInvite: (code: string) => void;
+}
+
+export default class InviteesTable extends PureComponent
{
+ private copyUrlRef = createRef();
+
+ copyToClipboard = () => {
+ const node = this.copyUrlRef.current;
+
+ if (node) {
+ node.select();
+ document.execCommand('copy');
+ }
+ };
+
+ render() {
+ const { invitees, onRevokeInvite } = this.props;
+
+ return (
+
+
+
+ Email
+ Name
+
+
+
+
+
+ {invitees.map((invitee, index) => {
+ return (
+
+ {invitee.email}
+ {invitee.name}
+
+
+
+ Copy Invite
+
+
+
+
+ onRevokeInvite(invitee.code)}>
+
+
+
+
+ );
+ })}
+
+
+ );
+ }
+}
diff --git a/public/app/features/users/UsersActionBar.test.tsx b/public/app/features/users/UsersActionBar.test.tsx
new file mode 100644
index 00000000000..7326de4d247
--- /dev/null
+++ b/public/app/features/users/UsersActionBar.test.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { UsersActionBar, Props } from './UsersActionBar';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ searchQuery: '',
+ setUsersSearchQuery: jest.fn(),
+ onShowInvites: jest.fn(),
+ pendingInvitesCount: 0,
+ canInvite: false,
+ externalUserMngLinkUrl: '',
+ externalUserMngLinkName: '',
+ showInvites: false,
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render pending invites button', () => {
+ const wrapper = setup({
+ pendingInvitesCount: 5,
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should show invite button', () => {
+ const wrapper = setup({
+ canInvite: true,
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should show external user management button', () => {
+ const wrapper = setup({
+ externalUserMngLinkUrl: 'some/url',
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx
new file mode 100644
index 00000000000..82fae2ed059
--- /dev/null
+++ b/public/app/features/users/UsersActionBar.tsx
@@ -0,0 +1,97 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames/bind';
+import { setUsersSearchQuery } from './state/actions';
+import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
+
+export interface Props {
+ searchQuery: string;
+ setUsersSearchQuery: typeof setUsersSearchQuery;
+ onShowInvites: () => void;
+ pendingInvitesCount: number;
+ canInvite: boolean;
+ showInvites: boolean;
+ externalUserMngLinkUrl: string;
+ externalUserMngLinkName: string;
+}
+
+export class UsersActionBar extends PureComponent {
+ render() {
+ const {
+ canInvite,
+ externalUserMngLinkName,
+ externalUserMngLinkUrl,
+ searchQuery,
+ pendingInvitesCount,
+ setUsersSearchQuery,
+ onShowInvites,
+ showInvites,
+ } = this.props;
+
+ const pendingInvitesButtonStyle = classNames({
+ btn: true,
+ 'toggle-btn': true,
+ active: showInvites,
+ });
+
+ const usersButtonStyle = classNames({
+ btn: true,
+ 'toggle-btn': true,
+ active: !showInvites,
+ });
+
+ return (
+
+
+
+ setUsersSearchQuery(event.target.value)}
+ placeholder="Filter by name or type"
+ />
+
+
+ {pendingInvitesCount > 0 && (
+
+
+ Users
+
+
+ Pending Invites ({pendingInvitesCount})
+
+
+ )}
+
+ {canInvite && (
+
+ Invite
+
+ )}
+ {externalUserMngLinkUrl && (
+
+ {externalUserMngLinkName}
+
+ )}
+
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ searchQuery: getUsersSearchQuery(state.users),
+ pendingInvitesCount: getInviteesCount(state.users),
+ externalUserMngLinkName: state.users.externalUserMngLinkName,
+ externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
+ canInvite: state.users.canInvite,
+ };
+}
+
+const mapDispatchToProps = {
+ setUsersSearchQuery,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);
diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx
new file mode 100644
index 00000000000..b6cd85182ee
--- /dev/null
+++ b/public/app/features/users/UsersListPage.test.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { UsersListPage, Props } from './UsersListPage';
+import { Invitee, NavModel, OrgUser } from 'app/types';
+import { getMockUser } from './__mocks__/userMocks';
+import appEvents from '../../core/app_events';
+
+jest.mock('../../core/app_events', () => ({
+ emit: jest.fn(),
+}));
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ navModel: {} as NavModel,
+ users: [] as OrgUser[],
+ invitees: [] as Invitee[],
+ searchQuery: '',
+ externalUserMngInfo: '',
+ revokeInvite: jest.fn(),
+ loadInvitees: jest.fn(),
+ loadUsers: jest.fn(),
+ updateUser: jest.fn(),
+ removeUser: jest.fn(),
+ setUsersSearchQuery: jest.fn(),
+ hasFetched: false,
+ };
+
+ Object.assign(props, propOverrides);
+
+ const wrapper = shallow( );
+ const instance = wrapper.instance() as UsersListPage;
+
+ return {
+ wrapper,
+ instance,
+ };
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const { wrapper } = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render List page', () => {
+ const { wrapper } = setup({
+ hasFetched: true,
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
+
+describe('Functions', () => {
+ it('should emit show remove user modal', () => {
+ const { instance } = setup();
+ const mockUser = getMockUser();
+
+ instance.onRemoveUser(mockUser);
+
+ expect(appEvents.emit).toHaveBeenCalled();
+ });
+});
diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx
new file mode 100644
index 00000000000..234fc6cc1d1
--- /dev/null
+++ b/public/app/features/users/UsersListPage.tsx
@@ -0,0 +1,147 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import Remarkable from 'remarkable';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
+import UsersActionBar from './UsersActionBar';
+import UsersTable from './UsersTable';
+import InviteesTable from './InviteesTable';
+import { Invitee, NavModel, OrgUser } from 'app/types';
+import appEvents from 'app/core/app_events';
+import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
+
+export interface Props {
+ navModel: NavModel;
+ invitees: Invitee[];
+ users: OrgUser[];
+ searchQuery: string;
+ externalUserMngInfo: string;
+ hasFetched: boolean;
+ loadUsers: typeof loadUsers;
+ loadInvitees: typeof loadInvitees;
+ setUsersSearchQuery: typeof setUsersSearchQuery;
+ updateUser: typeof updateUser;
+ removeUser: typeof removeUser;
+ revokeInvite: typeof revokeInvite;
+}
+
+export interface State {
+ showInvites: boolean;
+}
+
+export class UsersListPage extends PureComponent {
+ externalUserMngInfoHtml: string;
+
+ constructor(props) {
+ super(props);
+
+ if (this.props.externalUserMngInfo) {
+ const markdownRenderer = new Remarkable();
+ this.externalUserMngInfoHtml = markdownRenderer.render(this.props.externalUserMngInfo);
+ }
+
+ this.state = {
+ showInvites: false,
+ };
+ }
+
+ componentDidMount() {
+ this.fetchUsers();
+ this.fetchInvitees();
+ }
+
+ async fetchUsers() {
+ return await this.props.loadUsers();
+ }
+
+ async fetchInvitees() {
+ return await this.props.loadInvitees();
+ }
+
+ onRoleChange = (role, user) => {
+ const updatedUser = { ...user, role: role };
+
+ this.props.updateUser(updatedUser);
+ };
+
+ onRemoveUser = user => {
+ appEvents.emit('confirm-modal', {
+ title: 'Delete',
+ text: 'Are you sure you want to delete user ' + user.login + '?',
+ yesText: 'Delete',
+ icon: 'fa-warning',
+ onConfirm: () => {
+ this.props.removeUser(user.userId);
+ },
+ });
+ };
+
+ onRevokeInvite = code => {
+ this.props.revokeInvite(code);
+ };
+
+ onShowInvites = () => {
+ this.setState(prevState => ({
+ showInvites: !prevState.showInvites,
+ }));
+ };
+
+ renderTable() {
+ const { invitees, users } = this.props;
+
+ if (this.state.showInvites) {
+ return this.onRevokeInvite(code)} />;
+ } else {
+ return (
+ this.onRoleChange(role, user)}
+ onRemoveUser={user => this.onRemoveUser(user)}
+ />
+ );
+ }
+ }
+
+ render() {
+ const { navModel, hasFetched } = this.props;
+ const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
+
+ return (
+
+
+
+
+ {externalUserMngInfoHtml && (
+
+ )}
+ {hasFetched ? this.renderTable() :
}
+
+
+ );
+ }
+}
+
+function mapStateToProps(state) {
+ return {
+ navModel: getNavModel(state.navIndex, 'users'),
+ users: getUsers(state.users),
+ searchQuery: getUsersSearchQuery(state.users),
+ invitees: getInvitees(state.users),
+ externalUserMngInfo: state.users.externalUserMngInfo,
+ hasFetched: state.users.hasFetched,
+ };
+}
+
+const mapDispatchToProps = {
+ loadUsers,
+ loadInvitees,
+ setUsersSearchQuery,
+ updateUser,
+ removeUser,
+ revokeInvite,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UsersListPage));
diff --git a/public/app/features/users/UsersTable.test.tsx b/public/app/features/users/UsersTable.test.tsx
new file mode 100644
index 00000000000..6e88dbf4a58
--- /dev/null
+++ b/public/app/features/users/UsersTable.test.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import UsersTable, { Props } from './UsersTable';
+import { OrgUser } from 'app/types';
+import { getMockUsers } from './__mocks__/userMocks';
+
+const setup = (propOverrides?: object) => {
+ const props: Props = {
+ users: [] as OrgUser[],
+ onRoleChange: jest.fn(),
+ onRemoveUser: jest.fn(),
+ };
+
+ Object.assign(props, propOverrides);
+
+ return shallow( );
+};
+
+describe('Render', () => {
+ it('should render component', () => {
+ const wrapper = setup();
+
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render users table', () => {
+ const wrapper = setup({
+ users: getMockUsers(5),
+ });
+
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/public/app/features/users/UsersTable.tsx b/public/app/features/users/UsersTable.tsx
new file mode 100644
index 00000000000..a7bb44d1a62
--- /dev/null
+++ b/public/app/features/users/UsersTable.tsx
@@ -0,0 +1,67 @@
+import React, { SFC } from 'react';
+import { OrgUser } from 'app/types';
+
+export interface Props {
+ users: OrgUser[];
+ onRoleChange: (role: string, user: OrgUser) => void;
+ onRemoveUser: (user: OrgUser) => void;
+}
+
+const UsersTable: SFC = props => {
+ const { users, onRoleChange, onRemoveUser } = props;
+
+ return (
+
+
+
+
+ Login
+ Email
+ Seen
+ Role
+
+
+
+
+ {users.map((user, index) => {
+ return (
+
+
+
+
+ {user.login}
+
+ {user.email}
+
+ {user.lastSeenAtAge}
+
+
+ onRoleChange(event.target.value, user)}
+ >
+ {['Viewer', 'Editor', 'Admin'].map((option, index) => {
+ return (
+
+ {option}
+
+ );
+ })}
+
+
+
+
+ onRemoveUser(user)} className="btn btn-danger btn-mini">
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default UsersTable;
diff --git a/public/app/features/users/__mocks__/userMocks.ts b/public/app/features/users/__mocks__/userMocks.ts
new file mode 100644
index 00000000000..ce299484c13
--- /dev/null
+++ b/public/app/features/users/__mocks__/userMocks.ts
@@ -0,0 +1,56 @@
+export const getMockUsers = (amount: number) => {
+ const users = [];
+
+ for (let i = 0; i <= amount; i++) {
+ users.push({
+ avatarUrl: 'url/to/avatar',
+ email: `user-${i}@test.com`,
+ lastSeenAt: '2018-10-01',
+ lastSeenAtAge: '',
+ login: `user-${i}`,
+ orgId: 1,
+ role: 'Admin',
+ userId: i,
+ });
+ }
+
+ return users;
+};
+
+export const getMockUser = () => {
+ return {
+ avatarUrl: 'url/to/avatar',
+ email: `user@test.com`,
+ lastSeenAt: '2018-10-01',
+ lastSeenAtAge: '',
+ login: `user`,
+ orgId: 1,
+ role: 'Admin',
+ userId: 2,
+ };
+};
+
+export const getMockInvitees = (amount: number) => {
+ const invitees = [];
+
+ for (let i = 0; i <= amount; i++) {
+ invitees.push({
+ code: `asdfasdfsadf-${i}`,
+ createdOn: '2018-10-02',
+ email: `invitee-${i}@test.com`,
+ emailSent: true,
+ emailSentOn: '2018-10-02',
+ id: i,
+ invitedByEmail: 'admin@grafana.com',
+ invitedByLogin: 'admin',
+ invitedByName: 'admin',
+ name: `invitee-${i}`,
+ orgId: 1,
+ role: 'viewer',
+ status: 'not accepted',
+ url: `localhost/invite/$${i}`,
+ });
+ }
+
+ return invitees;
+};
diff --git a/public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap b/public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap
new file mode 100644
index 00000000000..57622bbd9eb
--- /dev/null
+++ b/public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap
@@ -0,0 +1,318 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+
+
+ Email
+
+
+ Name
+
+
+
+
+
+
+
+`;
+
+exports[`Render should render invitees 1`] = `
+
+
+
+
+ Email
+
+
+ Name
+
+
+
+
+
+
+
+
+ invitee-0@test.com
+
+
+ invitee-0
+
+
+
+
+
+ Copy Invite
+
+
+
+
+
+
+
+
+
+
+
+ invitee-1@test.com
+
+
+ invitee-1
+
+
+
+
+
+ Copy Invite
+
+
+
+
+
+
+
+
+
+
+
+ invitee-2@test.com
+
+
+ invitee-2
+
+
+
+
+
+ Copy Invite
+
+
+
+
+
+
+
+
+
+
+
+ invitee-3@test.com
+
+
+ invitee-3
+
+
+
+
+
+ Copy Invite
+
+
+
+
+
+
+
+
+
+
+
+ invitee-4@test.com
+
+
+ invitee-4
+
+
+
+
+
+ Copy Invite
+
+
+
+
+
+
+
+
+
+
+
+ invitee-5@test.com
+
+
+ invitee-5
+
+
+
+
+
+ Copy Invite
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap b/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap
new file mode 100644
index 00000000000..e69accb011b
--- /dev/null
+++ b/public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap
@@ -0,0 +1,155 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+`;
+
+exports[`Render should render pending invites button 1`] = `
+
+
+
+
+
+
+
+
+ Users
+
+
+ Pending Invites (
+ 5
+ )
+
+
+
+
+
+`;
+
+exports[`Render should show external user management button 1`] = `
+
+`;
+
+exports[`Render should show invite button 1`] = `
+
+`;
diff --git a/public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap b/public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
new file mode 100644
index 00000000000..429322eac98
--- /dev/null
+++ b/public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render List page 1`] = `
+
+`;
+
+exports[`Render should render component 1`] = `
+
+`;
diff --git a/public/app/features/users/__snapshots__/UsersTable.test.tsx.snap b/public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
new file mode 100644
index 00000000000..efbc7ae72cb
--- /dev/null
+++ b/public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
@@ -0,0 +1,444 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+
+
+
+
+
+ Login
+
+
+ Email
+
+
+ Seen
+
+
+ Role
+
+
+
+
+
+
+`;
+
+exports[`Render should render users table 1`] = `
+
+
+
+
+
+ Login
+
+
+ Email
+
+
+ Seen
+
+
+ Role
+
+
+
+
+
+
+
+
+
+
+ user-0
+
+
+
+ user-0@test.com
+
+
+
+
+
+
+
+ Viewer
+
+
+ Editor
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ user-1
+
+
+
+ user-1@test.com
+
+
+
+
+
+
+
+ Viewer
+
+
+ Editor
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ user-2
+
+
+
+ user-2@test.com
+
+
+
+
+
+
+
+ Viewer
+
+
+ Editor
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ user-3
+
+
+
+ user-3@test.com
+
+
+
+
+
+
+
+ Viewer
+
+
+ Editor
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ user-4
+
+
+
+ user-4@test.com
+
+
+
+
+
+
+
+ Viewer
+
+
+ Editor
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ user-5
+
+
+
+ user-5@test.com
+
+
+
+
+
+
+
+ Viewer
+
+
+ Editor
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/public/app/features/users/state/actions.ts b/public/app/features/users/state/actions.ts
new file mode 100644
index 00000000000..5c50aa29096
--- /dev/null
+++ b/public/app/features/users/state/actions.ts
@@ -0,0 +1,79 @@
+import { ThunkAction } from 'redux-thunk';
+import { StoreState } from '../../../types';
+import { getBackendSrv } from '../../../core/services/backend_srv';
+import { Invitee, OrgUser } from 'app/types';
+
+export enum ActionTypes {
+ LoadUsers = 'LOAD_USERS',
+ LoadInvitees = 'LOAD_INVITEES',
+ SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
+}
+
+export interface LoadUsersAction {
+ type: ActionTypes.LoadUsers;
+ payload: OrgUser[];
+}
+
+export interface LoadInviteesAction {
+ type: ActionTypes.LoadInvitees;
+ payload: Invitee[];
+}
+
+export interface SetUsersSearchQueryAction {
+ type: ActionTypes.SetUsersSearchQuery;
+ payload: string;
+}
+
+const usersLoaded = (users: OrgUser[]): LoadUsersAction => ({
+ type: ActionTypes.LoadUsers,
+ payload: users,
+});
+
+const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({
+ type: ActionTypes.LoadInvitees,
+ payload: invitees,
+});
+
+export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({
+ type: ActionTypes.SetUsersSearchQuery,
+ payload: query,
+});
+
+export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
+
+type ThunkResult = ThunkAction;
+
+export function loadUsers(): ThunkResult {
+ return async dispatch => {
+ const users = await getBackendSrv().get('/api/org/users');
+ dispatch(usersLoaded(users));
+ };
+}
+
+export function loadInvitees(): ThunkResult {
+ return async dispatch => {
+ const invitees = await getBackendSrv().get('/api/org/invites');
+ dispatch(inviteesLoaded(invitees));
+ };
+}
+
+export function updateUser(user: OrgUser): ThunkResult {
+ return async dispatch => {
+ await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role });
+ dispatch(loadUsers());
+ };
+}
+
+export function removeUser(userId: number): ThunkResult {
+ return async dispatch => {
+ await getBackendSrv().delete(`/api/org/users/${userId}`);
+ dispatch(loadUsers());
+ };
+}
+
+export function revokeInvite(code: string): ThunkResult {
+ return async dispatch => {
+ await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
+ dispatch(loadInvitees());
+ };
+}
diff --git a/public/app/features/users/state/reducers.ts b/public/app/features/users/state/reducers.ts
new file mode 100644
index 00000000000..d31682e0c45
--- /dev/null
+++ b/public/app/features/users/state/reducers.ts
@@ -0,0 +1,33 @@
+import { Invitee, OrgUser, UsersState } from 'app/types';
+import { Action, ActionTypes } from './actions';
+import config from 'app/core/config';
+
+export const initialState: UsersState = {
+ invitees: [] as Invitee[],
+ users: [] as OrgUser[],
+ searchQuery: '',
+ canInvite: !config.disableLoginForm && !config.externalUserMngLinkName,
+ externalUserMngInfo: config.externalUserMngInfo,
+ externalUserMngLinkName: config.externalUserMngLinkName,
+ externalUserMngLinkUrl: config.externalUserMngLinkUrl,
+ hasFetched: false,
+};
+
+export const usersReducer = (state = initialState, action: Action): UsersState => {
+ switch (action.type) {
+ case ActionTypes.LoadUsers:
+ return { ...state, hasFetched: true, users: action.payload };
+
+ case ActionTypes.LoadInvitees:
+ return { ...state, hasFetched: true, invitees: action.payload };
+
+ case ActionTypes.SetUsersSearchQuery:
+ return { ...state, searchQuery: action.payload };
+ }
+
+ return state;
+};
+
+export default {
+ users: usersReducer,
+};
diff --git a/public/app/features/users/state/selectors.ts b/public/app/features/users/state/selectors.ts
new file mode 100644
index 00000000000..8bf052f61bd
--- /dev/null
+++ b/public/app/features/users/state/selectors.ts
@@ -0,0 +1,18 @@
+export const getUsers = state => {
+ const regex = new RegExp(state.searchQuery, 'i');
+
+ return state.users.filter(user => {
+ return regex.test(user.login) || regex.test(user.email);
+ });
+};
+
+export const getInvitees = state => {
+ const regex = new RegExp(state.searchQuery, 'i');
+
+ return state.invitees.filter(invitee => {
+ return regex.test(invitee.name) || regex.test(invitee.email);
+ });
+};
+
+export const getInviteesCount = state => state.invitees.length;
+export const getUsersSearchQuery = state => state.searchQuery;
diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html
index 9506587c515..32acdc435f2 100644
--- a/public/app/partials/dashboard.html
+++ b/public/app/partials/dashboard.html
@@ -7,12 +7,11 @@
class="dashboard-settings">
-
diff --git a/public/app/partials/login.html b/public/app/partials/login.html
index 260de80e389..a2bc8173766 100644
--- a/public/app/partials/login.html
+++ b/public/app/partials/login.html
@@ -2,7 +2,7 @@
-
+
diff --git a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts
index ff0d39944ca..6fe48cb1715 100644
--- a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts
+++ b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts
@@ -1,17 +1,21 @@
+import _ from 'lodash';
export class CloudWatchConfigCtrl {
static templateUrl = 'partials/config.html';
current: any;
+ datasourceSrv: any;
accessKeyExist = false;
secretKeyExist = false;
/** @ngInject */
- constructor($scope) {
+ constructor($scope, datasourceSrv) {
this.current.jsonData.timeField = this.current.jsonData.timeField || '@timestamp';
this.current.jsonData.authType = this.current.jsonData.authType || 'credentials';
this.accessKeyExist = this.current.secureJsonFields.accessKey;
this.secretKeyExist = this.current.secureJsonFields.secretKey;
+ this.datasourceSrv = datasourceSrv;
+ this.getRegions();
}
resetAccessKey() {
@@ -36,4 +40,47 @@ export class CloudWatchConfigCtrl {
{ name: 'Monthly', value: 'Monthly', example: '[logstash-]YYYY.MM' },
{ name: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' },
];
+
+ regions = [
+ 'ap-northeast-1',
+ 'ap-northeast-2',
+ 'ap-northeast-3',
+ 'ap-south-1',
+ 'ap-southeast-1',
+ 'ap-southeast-2',
+ 'ca-central-1',
+ 'cn-north-1',
+ 'cn-northwest-1',
+ 'eu-central-1',
+ 'eu-north-1',
+ 'eu-west-1',
+ 'eu-west-2',
+ 'eu-west-3',
+ 'me-south-1',
+ 'sa-east-1',
+ 'us-east-1',
+ 'us-east-2',
+ 'us-gov-east-1',
+ 'us-gov-west-1',
+ 'us-iso-east-1',
+ 'us-isob-east-1',
+ 'us-west-1',
+ 'us-west-2',
+ ];
+
+ getRegions() {
+ this.datasourceSrv
+ .loadDatasource(this.current.name)
+ .then(ds => {
+ return ds.getRegions();
+ })
+ .then(
+ regions => {
+ this.regions = _.map(regions, 'value');
+ },
+ err => {
+ console.error('failed to get latest regions');
+ }
+ );
+ }
}
diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts
index 34771618095..3eb8eff2d09 100644
--- a/public/app/plugins/datasource/cloudwatch/datasource.ts
+++ b/public/app/plugins/datasource/cloudwatch/datasource.ts
@@ -8,7 +8,6 @@ import * as templatingVariable from 'app/features/templating/variable';
export default class CloudWatchDatasource {
type: any;
name: any;
- supportMetrics: any;
proxyUrl: any;
defaultRegion: any;
instanceSettings: any;
@@ -17,7 +16,6 @@ export default class CloudWatchDatasource {
constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) {
this.type = 'cloudwatch';
this.name = instanceSettings.name;
- this.supportMetrics = true;
this.proxyUrl = instanceSettings.url;
this.defaultRegion = instanceSettings.jsonData.defaultRegion;
this.instanceSettings = instanceSettings;
@@ -39,6 +37,9 @@ export default class CloudWatchDatasource {
item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars);
item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars);
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
+ item.statistics = item.statistics.map(s => {
+ return this.templateSrv.replace(s, options.scopedVars);
+ });
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
item.id = this.templateSrv.replace(item.id, options.scopedVars);
item.expression = this.templateSrv.replace(item.expression, options.scopedVars);
@@ -46,8 +47,14 @@ export default class CloudWatchDatasource {
// valid ExtendedStatistics is like p90.00, check the pattern
const hasInvalidStatistics = item.statistics.some(s => {
- return s.indexOf('p') === 0 && !/p\d{2}\.\d{2}/.test(s);
+ if (s.indexOf('p') === 0) {
+ const matches = /^p\d{2}(?:\.\d{1,2})?$/.exec(s);
+ return !matches || matches[0] !== s;
+ }
+
+ return false;
});
+
if (hasInvalidStatistics) {
throw { message: 'Invalid extended statistics' };
}
@@ -133,7 +140,11 @@ export default class CloudWatchDatasource {
if (res.results) {
_.forEach(res.results, queryRes => {
_.forEach(queryRes.series, series => {
- data.push({ target: series.name, datapoints: series.points });
+ const s = { target: series.name, datapoints: series.points } as any;
+ if (queryRes.meta.unit) {
+ s.unit = queryRes.meta.unit;
+ }
+ data.push(s);
});
});
}
diff --git a/public/app/plugins/datasource/cloudwatch/partials/config.html b/public/app/plugins/datasource/cloudwatch/partials/config.html
index a4df0c14807..40249d32b7e 100644
--- a/public/app/plugins/datasource/cloudwatch/partials/config.html
+++ b/public/app/plugins/datasource/cloudwatch/partials/config.html
@@ -39,7 +39,7 @@
Default Region
-
+
Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region.
diff --git a/public/app/plugins/datasource/cloudwatch/partials/query.parameter.html b/public/app/plugins/datasource/cloudwatch/partials/query.parameter.html
index 7da6e7d2a83..2a951bc9257 100644
--- a/public/app/plugins/datasource/cloudwatch/partials/query.parameter.html
+++ b/public/app/plugins/datasource/cloudwatch/partials/query.parameter.html
@@ -37,8 +37,7 @@
Id
Id can include numbers, letters, and underscore, and must start with a lowercase letter.
-
+
Expression
diff --git a/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts
index 4f4b2961761..ba5a39688b3 100644
--- a/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts
+++ b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts
@@ -1,4 +1,5 @@
import angular from 'angular';
+import coreModule from 'app/core/core_module';
import _ from 'lodash';
export class CloudWatchQueryParameter {
@@ -239,5 +240,5 @@ export class CloudWatchQueryParameterCtrl {
}
}
-angular.module('grafana.controllers').directive('cloudwatchQueryParameter', CloudWatchQueryParameter);
-angular.module('grafana.controllers').controller('CloudWatchQueryParameterCtrl', CloudWatchQueryParameterCtrl);
+coreModule.directive('cloudwatchQueryParameter', CloudWatchQueryParameter);
+coreModule.controller('CloudWatchQueryParameterCtrl', CloudWatchQueryParameterCtrl);
diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
index 497c773687f..512767075a9 100644
--- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts
@@ -60,6 +60,7 @@ describe('CloudWatchDatasource', () => {
A: {
error: '',
refId: 'A',
+ meta: {},
series: [
{
name: 'CPUUtilization_Average',
@@ -121,7 +122,7 @@ describe('CloudWatchDatasource', () => {
});
});
- it('should cancel query for invalid extended statistics', () => {
+ it.each(['pNN.NN', 'p9', 'p99.', 'p99.999'])('should cancel query for invalid extended statistics (%s)', stat => {
const query = {
range: { from: 'now-1h', to: 'now' },
rangeRaw: { from: 1483228800, to: 1483232400 },
@@ -133,7 +134,7 @@ describe('CloudWatchDatasource', () => {
dimensions: {
InstanceId: 'i-12345678',
},
- statistics: ['pNN.NN'],
+ statistics: [stat],
period: '60s',
},
],
@@ -221,6 +222,7 @@ describe('CloudWatchDatasource', () => {
A: {
error: '',
refId: 'A',
+ meta: {},
series: [
{
name: 'TargetResponseTime_p90.00',
diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts b/public/app/plugins/datasource/elasticsearch/bucket_agg.ts
index 8963f2c3f4b..cacf86201fe 100644
--- a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts
+++ b/public/app/plugins/datasource/elasticsearch/bucket_agg.ts
@@ -1,4 +1,4 @@
-import angular from 'angular';
+import coreModule from 'app/core/core_module';
import _ from 'lodash';
import * as queryDef from './query_def';
@@ -226,6 +226,5 @@ export class ElasticBucketAggCtrl {
}
}
-const module = angular.module('grafana.directives');
-module.directive('elasticBucketAgg', elasticBucketAgg);
-module.controller('ElasticBucketAggCtrl', ElasticBucketAggCtrl);
+coreModule.directive('elasticBucketAgg', elasticBucketAgg);
+coreModule.controller('ElasticBucketAggCtrl', ElasticBucketAggCtrl);
diff --git a/public/app/plugins/datasource/elasticsearch/metric_agg.ts b/public/app/plugins/datasource/elasticsearch/metric_agg.ts
index 623eed68914..1dd0d892360 100644
--- a/public/app/plugins/datasource/elasticsearch/metric_agg.ts
+++ b/public/app/plugins/datasource/elasticsearch/metric_agg.ts
@@ -1,4 +1,4 @@
-import angular from 'angular';
+import coreModule from 'app/core/core_module';
import _ from 'lodash';
import * as queryDef from './query_def';
@@ -203,6 +203,5 @@ export class ElasticMetricAggCtrl {
}
}
-const module = angular.module('grafana.directives');
-module.directive('elasticMetricAgg', elasticMetricAgg);
-module.controller('ElasticMetricAggCtrl', ElasticMetricAggCtrl);
+coreModule.directive('elasticMetricAgg', elasticMetricAgg);
+coreModule.controller('ElasticMetricAggCtrl', ElasticMetricAggCtrl);
diff --git a/public/app/plugins/datasource/graphite/add_graphite_func.ts b/public/app/plugins/datasource/graphite/add_graphite_func.ts
index a5c1dc49959..ea3dfe8ff5e 100644
--- a/public/app/plugins/datasource/graphite/add_graphite_func.ts
+++ b/public/app/plugins/datasource/graphite/add_graphite_func.ts
@@ -1,8 +1,8 @@
-import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import rst2html from 'rst2html';
import Drop from 'tether-drop';
+import coreModule from 'app/core/core_module';
/** @ngInject */
export function graphiteAddFunc($compile) {
@@ -130,7 +130,7 @@ export function graphiteAddFunc($compile) {
};
}
-angular.module('grafana.directives').directive('graphiteAddFunc', graphiteAddFunc);
+coreModule.directive('graphiteAddFunc', graphiteAddFunc);
function createFunctionDropDownMenu(funcDefs) {
const categories = {};
diff --git a/public/app/plugins/datasource/graphite/func_editor.ts b/public/app/plugins/datasource/graphite/func_editor.ts
index 68cc6f1452e..9e19083a9c3 100644
--- a/public/app/plugins/datasource/graphite/func_editor.ts
+++ b/public/app/plugins/datasource/graphite/func_editor.ts
@@ -1,7 +1,7 @@
-import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import rst2html from 'rst2html';
+import coreModule from 'app/core/core_module';
/** @ngInject */
export function graphiteFuncEditor($compile, templateSrv, popoverSrv) {
@@ -315,4 +315,4 @@ export function graphiteFuncEditor($compile, templateSrv, popoverSrv) {
};
}
-angular.module('grafana.directives').directive('graphiteFuncEditor', graphiteFuncEditor);
+coreModule.directive('graphiteFuncEditor', graphiteFuncEditor);
diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts
index 5ffbf7cf418..cf9b95882bc 100644
--- a/public/app/plugins/datasource/influxdb/datasource.ts
+++ b/public/app/plugins/datasource/influxdb/datasource.ts
@@ -16,8 +16,6 @@ export default class InfluxDatasource {
basicAuth: any;
withCredentials: any;
interval: any;
- supportAnnotations: boolean;
- supportMetrics: boolean;
responseParser: any;
/** @ngInject */
@@ -34,8 +32,6 @@ export default class InfluxDatasource {
this.basicAuth = instanceSettings.basicAuth;
this.withCredentials = instanceSettings.withCredentials;
this.interval = (instanceSettings.jsonData || {}).timeInterval;
- this.supportAnnotations = true;
- this.supportMetrics = true;
this.responseParser = new ResponseParser();
}
diff --git a/public/app/plugins/datasource/influxdb/influx_series.ts b/public/app/plugins/datasource/influxdb/influx_series.ts
index d2a8482eced..10c1584f488 100644
--- a/public/app/plugins/datasource/influxdb/influx_series.ts
+++ b/public/app/plugins/datasource/influxdb/influx_series.ts
@@ -99,9 +99,6 @@ export default class InfluxSeries {
if (column === 'sequence_number') {
return;
}
- if (!titleCol) {
- titleCol = index;
- }
if (column === this.annotation.titleColumn) {
titleCol = index;
return;
@@ -114,6 +111,10 @@ export default class InfluxSeries {
textCol = index;
return;
}
+ // legacy case
+ if (!titleCol && textCol !== index) {
+ titleCol = index;
+ }
});
_.each(series.values, value => {
diff --git a/public/app/plugins/datasource/influxdb/partials/config.html b/public/app/plugins/datasource/influxdb/partials/config.html
index a70a1de98a4..4de2fadd52d 100644
--- a/public/app/plugins/datasource/influxdb/partials/config.html
+++ b/public/app/plugins/datasource/influxdb/partials/config.html
@@ -6,18 +6,18 @@
diff --git a/public/app/plugins/datasource/influxdb/partials/query.editor.html b/public/app/plugins/datasource/influxdb/partials/query.editor.html
index 9454a6abde7..e1c35d77115 100644
--- a/public/app/plugins/datasource/influxdb/partials/query.editor.html
+++ b/public/app/plugins/datasource/influxdb/partials/query.editor.html
@@ -2,7 +2,7 @@
+
+
Connection limits
+
+
MSSQL details
diff --git a/public/app/plugins/datasource/mysql/datasource.ts b/public/app/plugins/datasource/mysql/datasource.ts
index 4705bfbb6c9..f0381d53b70 100644
--- a/public/app/plugins/datasource/mysql/datasource.ts
+++ b/public/app/plugins/datasource/mysql/datasource.ts
@@ -1,24 +1,27 @@
import _ from 'lodash';
import ResponseParser from './response_parser';
+import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query';
export class MysqlDatasource {
id: any;
name: any;
responseParser: ResponseParser;
+ queryModel: MysqlQuery;
interval: string;
/** @ngInject */
- constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
+ constructor(instanceSettings, private backendSrv, private $q, private templateSrv, private timeSrv) {
this.name = instanceSettings.name;
this.id = instanceSettings.id;
this.responseParser = new ResponseParser(this.$q);
+ this.queryModel = new MysqlQuery({});
this.interval = (instanceSettings.jsonData || {}).timeInterval;
}
- interpolateVariable(value, variable) {
+ interpolateVariable = (value, variable) => {
if (typeof value === 'string') {
if (variable.multi || variable.includeAll) {
- return "'" + value.replace(/'/g, `''`) + "'";
+ return this.queryModel.quoteLiteral(value);
} else {
return value;
}
@@ -28,27 +31,25 @@ export class MysqlDatasource {
return value;
}
- const quotedValues = _.map(value, val => {
- if (typeof value === 'number') {
- return value;
- }
-
- return "'" + val.replace(/'/g, `''`) + "'";
+ const quotedValues = _.map(value, v => {
+ return this.queryModel.quoteLiteral(v);
});
return quotedValues.join(',');
- }
+ };
query(options) {
- const queries = _.filter(options.targets, item => {
- return item.hide !== true;
- }).map(item => {
+ const queries = _.filter(options.targets, target => {
+ return target.hide !== true;
+ }).map(target => {
+ const queryModel = new MysqlQuery(target, this.templateSrv, options.scopedVars);
+
return {
- refId: item.refId,
+ refId: target.refId,
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
datasourceId: this.id,
- rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable),
- format: item.format,
+ rawSql: queryModel.render(this.interpolateVariable),
+ format: target.format,
};
});
@@ -109,8 +110,11 @@ export class MysqlDatasource {
format: 'table',
};
+ const range = this.timeSrv.timeRange();
const data = {
queries: [interpolatedQuery],
+ from: range.from.valueOf().toString(),
+ to: range.to.valueOf().toString(),
};
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
diff --git a/public/app/plugins/datasource/mysql/meta_query.ts b/public/app/plugins/datasource/mysql/meta_query.ts
new file mode 100644
index 00000000000..e5f8ad73ebe
--- /dev/null
+++ b/public/app/plugins/datasource/mysql/meta_query.ts
@@ -0,0 +1,142 @@
+export class MysqlMetaQuery {
+ constructor(private target, private queryModel) {}
+
+ getOperators(datatype: string) {
+ switch (datatype) {
+ case 'double':
+ case 'float': {
+ return ['=', '!=', '<', '<=', '>', '>='];
+ }
+ case 'text':
+ case 'tinytext':
+ case 'mediumtext':
+ case 'longtext':
+ case 'varchar':
+ case 'char': {
+ return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE'];
+ }
+ default: {
+ return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN'];
+ }
+ }
+ }
+
+ // quote identifier as literal to use in metadata queries
+ quoteIdentAsLiteral(value) {
+ return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value));
+ }
+
+ findMetricTable() {
+ // query that returns first table found that has a timestamp(tz) column and a float column
+ const query = `
+ SELECT
+ table_name as table_name,
+ ( SELECT
+ column_name as column_name
+ FROM information_schema.columns c
+ WHERE
+ c.table_schema = t.table_schema AND
+ c.table_name = t.table_name AND
+ c.data_type IN ('timestamp', 'datetime')
+ ORDER BY ordinal_position LIMIT 1
+ ) AS time_column,
+ ( SELECT
+ column_name AS column_name
+ FROM information_schema.columns c
+ WHERE
+ c.table_schema = t.table_schema AND
+ c.table_name = t.table_name AND
+ c.data_type IN('float', 'int', 'bigint')
+ ORDER BY ordinal_position LIMIT 1
+ ) AS value_column
+ FROM information_schema.tables t
+ WHERE
+ t.table_schema = database() AND
+ EXISTS
+ ( SELECT 1
+ FROM information_schema.columns c
+ WHERE
+ c.table_schema = t.table_schema AND
+ c.table_name = t.table_name AND
+ c.data_type IN ('timestamp', 'datetime')
+ ) AND
+ EXISTS
+ ( SELECT 1
+ FROM information_schema.columns c
+ WHERE
+ c.table_schema = t.table_schema AND
+ c.table_name = t.table_name AND
+ c.data_type IN('float', 'int', 'bigint')
+ )
+ LIMIT 1
+;`;
+ return query;
+ }
+
+ buildTableConstraint(table: string) {
+ let query = '';
+
+ // check for schema qualified table
+ if (table.includes('.')) {
+ const parts = table.split('.');
+ query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]);
+ query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]);
+ return query;
+ } else {
+ query = 'table_schema = database() AND table_name = ' + this.quoteIdentAsLiteral(table);
+
+ return query;
+ }
+ }
+
+ buildTableQuery() {
+ return 'SELECT table_name FROM information_schema.tables WHERE table_schema = database() ORDER BY table_name';
+ }
+
+ buildColumnQuery(type?: string) {
+ let query = 'SELECT column_name FROM information_schema.columns WHERE ';
+ query += this.buildTableConstraint(this.target.table);
+
+ switch (type) {
+ case 'time': {
+ query += " AND data_type IN ('timestamp','datetime','bigint','int','double','float')";
+ break;
+ }
+ case 'metric': {
+ query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')";
+ break;
+ }
+ case 'value': {
+ query += " AND data_type IN ('bigint','int','smallint','mediumint','tinyint','double','decimal','float')";
+ query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn);
+ break;
+ }
+ case 'group': {
+ query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')";
+ break;
+ }
+ }
+
+ query += ' ORDER BY column_name';
+
+ return query;
+ }
+
+ buildValueQuery(column: string) {
+ let query = 'SELECT DISTINCT QUOTE(' + column + ')';
+ query += ' FROM ' + this.target.table;
+ query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')';
+ query += ' ORDER BY 1 LIMIT 100';
+ return query;
+ }
+
+ buildDatatypeQuery(column: string) {
+ let query = `
+SELECT data_type
+FROM information_schema.columns
+WHERE `;
+ query += ' table_name = ' + this.quoteIdentAsLiteral(this.target.table);
+ query += ' AND column_name = ' + this.quoteIdentAsLiteral(column);
+ return query;
+ }
+}
diff --git a/public/app/plugins/datasource/mysql/mysql_query.ts b/public/app/plugins/datasource/mysql/mysql_query.ts
new file mode 100644
index 00000000000..e636d66e6fd
--- /dev/null
+++ b/public/app/plugins/datasource/mysql/mysql_query.ts
@@ -0,0 +1,233 @@
+import _ from 'lodash';
+
+export default class MysqlQuery {
+ target: any;
+ templateSrv: any;
+ scopedVars: any;
+
+ /** @ngInject */
+ constructor(target, templateSrv?, scopedVars?) {
+ this.target = target;
+ this.templateSrv = templateSrv;
+ this.scopedVars = scopedVars;
+
+ target.format = target.format || 'time_series';
+ target.timeColumn = target.timeColumn || 'time';
+ target.metricColumn = target.metricColumn || 'none';
+
+ target.group = target.group || [];
+ target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }];
+ target.select = target.select || [[{ type: 'column', params: ['value'] }]];
+
+ // handle pre query gui panels gracefully
+ if (!('rawQuery' in this.target)) {
+ if ('rawSql' in target) {
+ // pre query gui panel
+ target.rawQuery = true;
+ } else {
+ // new panel
+ target.rawQuery = false;
+ }
+ }
+
+ // give interpolateQueryStr access to this
+ this.interpolateQueryStr = this.interpolateQueryStr.bind(this);
+ }
+
+ // remove identifier quoting from identifier to use in metadata queries
+ unquoteIdentifier(value) {
+ if (value[0] === '"' && value[value.length - 1] === '"') {
+ return value.substring(1, value.length - 1).replace(/""/g, '"');
+ } else {
+ return value;
+ }
+ }
+
+ quoteIdentifier(value) {
+ return '"' + value.replace(/"/g, '""') + '"';
+ }
+
+ quoteLiteral(value) {
+ return "'" + value.replace(/'/g, "''") + "'";
+ }
+
+ escapeLiteral(value) {
+ return value.replace(/'/g, "''");
+ }
+
+ hasTimeGroup() {
+ return _.find(this.target.group, (g: any) => g.type === 'time');
+ }
+
+ hasMetricColumn() {
+ return this.target.metricColumn !== 'none';
+ }
+
+ interpolateQueryStr(value, variable, defaultFormatFn) {
+ // if no multi or include all do not regexEscape
+ if (!variable.multi && !variable.includeAll) {
+ return this.escapeLiteral(value);
+ }
+
+ if (typeof value === 'string') {
+ return this.quoteLiteral(value);
+ }
+
+ const escapedValues = _.map(value, this.quoteLiteral);
+ return escapedValues.join(',');
+ }
+
+ render(interpolate?) {
+ const target = this.target;
+
+ // new query with no table set yet
+ if (!this.target.rawQuery && !('table' in this.target)) {
+ return '';
+ }
+
+ if (!target.rawQuery) {
+ target.rawSql = this.buildQuery();
+ }
+
+ if (interpolate) {
+ return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr);
+ } else {
+ return target.rawSql;
+ }
+ }
+
+ hasUnixEpochTimecolumn() {
+ return ['int', 'bigint', 'double'].indexOf(this.target.timeColumnType) > -1;
+ }
+
+ buildTimeColumn(alias = true) {
+ const timeGroup = this.hasTimeGroup();
+ let query;
+ let macro = '$__timeGroup';
+
+ if (timeGroup) {
+ let args;
+ if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') {
+ args = timeGroup.params.join(',');
+ } else {
+ args = timeGroup.params[0];
+ }
+ if (this.hasUnixEpochTimecolumn()) {
+ macro = '$__unixEpochGroup';
+ }
+ if (alias) {
+ macro += 'Alias';
+ }
+ query = macro + '(' + this.target.timeColumn + ',' + args + ')';
+ } else {
+ query = this.target.timeColumn;
+ if (alias) {
+ query += ' AS "time"';
+ }
+ }
+
+ return query;
+ }
+
+ buildMetricColumn() {
+ if (this.hasMetricColumn()) {
+ return this.target.metricColumn + ' AS metric';
+ }
+
+ return '';
+ }
+
+ buildValueColumns() {
+ let query = '';
+ for (const column of this.target.select) {
+ query += ',\n ' + this.buildValueColumn(column);
+ }
+
+ return query;
+ }
+
+ buildValueColumn(column) {
+ let query = '';
+
+ const columnName = _.find(column, (g: any) => g.type === 'column');
+ query = columnName.params[0];
+
+ const aggregate = _.find(column, (g: any) => g.type === 'aggregate');
+
+ if (aggregate) {
+ const func = aggregate.params[0];
+ query = func + '(' + query + ')';
+ }
+
+ const alias = _.find(column, (g: any) => g.type === 'alias');
+ if (alias) {
+ query += ' AS ' + this.quoteIdentifier(alias.params[0]);
+ }
+
+ return query;
+ }
+
+ buildWhereClause() {
+ let query = '';
+ const conditions = _.map(this.target.where, (tag, index) => {
+ switch (tag.type) {
+ case 'macro':
+ return tag.name + '(' + this.target.timeColumn + ')';
+ break;
+ case 'expression':
+ return tag.params.join(' ');
+ break;
+ }
+ });
+
+ if (conditions.length > 0) {
+ query = '\nWHERE\n ' + conditions.join(' AND\n ');
+ }
+
+ return query;
+ }
+
+ buildGroupClause() {
+ let query = '';
+ let groupSection = '';
+
+ for (let i = 0; i < this.target.group.length; i++) {
+ const part = this.target.group[i];
+ if (i > 0) {
+ groupSection += ', ';
+ }
+ if (part.type === 'time') {
+ groupSection += '1';
+ } else {
+ groupSection += part.params[0];
+ }
+ }
+
+ if (groupSection.length) {
+ query = '\nGROUP BY ' + groupSection;
+ if (this.hasMetricColumn()) {
+ query += ',2';
+ }
+ }
+ return query;
+ }
+
+ buildQuery() {
+ let query = 'SELECT';
+
+ query += '\n ' + this.buildTimeColumn();
+ if (this.hasMetricColumn()) {
+ query += ',\n ' + this.buildMetricColumn();
+ }
+ query += this.buildValueColumns();
+
+ query += '\nFROM ' + this.target.table;
+
+ query += this.buildWhereClause();
+ query += this.buildGroupClause();
+
+ query += '\nORDER BY ' + this.buildTimeColumn(false);
+
+ return query;
+ }
+}
diff --git a/public/app/plugins/datasource/mysql/partials/config.html b/public/app/plugins/datasource/mysql/partials/config.html
index 6bc9cceb8f1..a35633c626a 100644
--- a/public/app/plugins/datasource/mysql/partials/config.html
+++ b/public/app/plugins/datasource/mysql/partials/config.html
@@ -24,6 +24,38 @@
+
Connection limits
+
+
+
MySQL details
+
+
-
+
-
-
+
-
+
diff --git a/public/app/plugins/datasource/mysql/query_ctrl.ts b/public/app/plugins/datasource/mysql/query_ctrl.ts
index ced4d6e8b13..8161404a126 100644
--- a/public/app/plugins/datasource/mysql/query_ctrl.ts
+++ b/public/app/plugins/datasource/mysql/query_ctrl.ts
@@ -1,12 +1,10 @@
import _ from 'lodash';
+import appEvents from 'app/core/app_events';
+import { MysqlMetaQuery } from './meta_query';
import { QueryCtrl } from 'app/plugins/sdk';
-
-export interface MysqlQuery {
- refId: string;
- format: string;
- alias: string;
- rawSql: string;
-}
+import { SqlPart } from 'app/core/components/sql_part/sql_part';
+import MysqlQuery from './mysql_query';
+import sqlPart from './sql_part';
export interface QueryMeta {
sql: string;
@@ -26,17 +24,31 @@ export class MysqlQueryCtrl extends QueryCtrl {
showLastQuerySQL: boolean;
formats: any[];
- target: MysqlQuery;
lastQueryMeta: QueryMeta;
lastQueryError: string;
showHelp: boolean;
+ queryModel: MysqlQuery;
+ metaBuilder: MysqlMetaQuery;
+ tableSegment: any;
+ whereAdd: any;
+ timeColumnSegment: any;
+ metricColumnSegment: any;
+ selectMenu: any[];
+ selectParts: SqlPart[][];
+ groupParts: SqlPart[];
+ whereParts: SqlPart[];
+ groupAdd: any;
+
/** @ngInject */
- constructor($scope, $injector) {
+ constructor($scope, $injector, private templateSrv, private $q, private uiSegmentSrv) {
super($scope, $injector);
- this.target.format = this.target.format || 'time_series';
- this.target.alias = '';
+ this.target = this.target;
+ this.queryModel = new MysqlQuery(this.target, templateSrv, this.panel.scopedVars);
+ this.metaBuilder = new MysqlMetaQuery(this.target, this.queryModel);
+ this.updateProjection();
+
this.formats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
if (!this.target.rawSql) {
@@ -44,15 +56,199 @@ export class MysqlQueryCtrl extends QueryCtrl {
if (this.panelCtrl.panel.type === 'table') {
this.target.format = 'table';
this.target.rawSql = 'SELECT 1';
+ this.target.rawQuery = true;
} else {
this.target.rawSql = defaultQuery;
+ this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then(result => {
+ if (result.length > 0) {
+ this.target.table = result[0].text;
+ let segment = this.uiSegmentSrv.newSegment(this.target.table);
+ this.tableSegment.html = segment.html;
+ this.tableSegment.value = segment.value;
+
+ this.target.timeColumn = result[1].text;
+ segment = this.uiSegmentSrv.newSegment(this.target.timeColumn);
+ this.timeColumnSegment.html = segment.html;
+ this.timeColumnSegment.value = segment.value;
+
+ this.target.timeColumnType = 'timestamp';
+ this.target.select = [[{ type: 'column', params: [result[2].text] }]];
+ this.updateProjection();
+ this.panelCtrl.refresh();
+ }
+ });
}
}
+ if (!this.target.table) {
+ this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true });
+ } else {
+ this.tableSegment = uiSegmentSrv.newSegment(this.target.table);
+ }
+
+ this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn);
+ this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn);
+
+ this.buildSelectMenu();
+ this.whereAdd = this.uiSegmentSrv.newPlusButton();
+ this.groupAdd = this.uiSegmentSrv.newPlusButton();
+
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
}
+ updateProjection() {
+ this.selectParts = _.map(this.target.select, (parts: any) => {
+ return _.map(parts, sqlPart.create).filter(n => n);
+ });
+ this.whereParts = _.map(this.target.where, sqlPart.create).filter(n => n);
+ this.groupParts = _.map(this.target.group, sqlPart.create).filter(n => n);
+ }
+
+ updatePersistedParts() {
+ this.target.select = _.map(this.selectParts, selectParts => {
+ return _.map(selectParts, (part: any) => {
+ return { type: part.def.type, datatype: part.datatype, params: part.params };
+ });
+ });
+ this.target.where = _.map(this.whereParts, (part: any) => {
+ return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params };
+ });
+ this.target.group = _.map(this.groupParts, (part: any) => {
+ return { type: part.def.type, datatype: part.datatype, params: part.params };
+ });
+ }
+
+ buildSelectMenu() {
+ this.selectMenu = [];
+ const aggregates = {
+ text: 'Aggregate Functions',
+ value: 'aggregate',
+ submenu: [
+ { text: 'Average', value: 'avg' },
+ { text: 'Count', value: 'count' },
+ { text: 'Maximum', value: 'max' },
+ { text: 'Minimum', value: 'min' },
+ { text: 'Sum', value: 'sum' },
+ { text: 'Standard deviation', value: 'stddev' },
+ { text: 'Variance', value: 'variance' },
+ ],
+ };
+
+ this.selectMenu.push(aggregates);
+ this.selectMenu.push({ text: 'Alias', value: 'alias' });
+ this.selectMenu.push({ text: 'Column', value: 'column' });
+ }
+
+ toggleEditorMode() {
+ if (this.target.rawQuery) {
+ appEvents.emit('confirm-modal', {
+ title: 'Warning',
+ text2: 'Switching to query builder may overwrite your raw SQL.',
+ icon: 'fa-exclamation',
+ yesText: 'Switch',
+ onConfirm: () => {
+ this.target.rawQuery = !this.target.rawQuery;
+ },
+ });
+ } else {
+ this.target.rawQuery = !this.target.rawQuery;
+ }
+ }
+
+ resetPlusButton(button) {
+ const plusButton = this.uiSegmentSrv.newPlusButton();
+ button.html = plusButton.html;
+ button.value = plusButton.value;
+ }
+
+ getTableSegments() {
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildTableQuery())
+ .then(this.transformToSegments({}))
+ .catch(this.handleQueryError.bind(this));
+ }
+
+ tableChanged() {
+ this.target.table = this.tableSegment.value;
+ this.target.where = [];
+ this.target.group = [];
+ this.updateProjection();
+
+ const segment = this.uiSegmentSrv.newSegment('none');
+ this.metricColumnSegment.html = segment.html;
+ this.metricColumnSegment.value = segment.value;
+ this.target.metricColumn = 'none';
+
+ const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then(result => {
+ // check if time column is still valid
+ if (result.length > 0 && !_.find(result, (r: any) => r.text === this.target.timeColumn)) {
+ const segment = this.uiSegmentSrv.newSegment(result[0].text);
+ this.timeColumnSegment.html = segment.html;
+ this.timeColumnSegment.value = segment.value;
+ }
+ return this.timeColumnChanged(false);
+ });
+ const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then(result => {
+ if (result.length > 0) {
+ this.target.select = [[{ type: 'column', params: [result[0].text] }]];
+ this.updateProjection();
+ }
+ });
+
+ this.$q.all([task1, task2]).then(() => {
+ this.panelCtrl.refresh();
+ });
+ }
+
+ getTimeColumnSegments() {
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildColumnQuery('time'))
+ .then(this.transformToSegments({}))
+ .catch(this.handleQueryError.bind(this));
+ }
+
+ timeColumnChanged(refresh?: boolean) {
+ this.target.timeColumn = this.timeColumnSegment.value;
+ return this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)).then(result => {
+ if (result.length === 1) {
+ if (this.target.timeColumnType !== result[0].text) {
+ this.target.timeColumnType = result[0].text;
+ }
+ let partModel;
+ if (this.queryModel.hasUnixEpochTimecolumn()) {
+ partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] });
+ } else {
+ partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] });
+ }
+
+ if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') {
+ // replace current macro
+ this.whereParts[0] = partModel;
+ } else {
+ this.whereParts.splice(0, 0, partModel);
+ }
+ }
+
+ this.updatePersistedParts();
+ if (refresh !== false) {
+ this.panelCtrl.refresh();
+ }
+ });
+ }
+
+ getMetricColumnSegments() {
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildColumnQuery('metric'))
+ .then(this.transformToSegments({ addNone: true }))
+ .catch(this.handleQueryError.bind(this));
+ }
+
+ metricColumnChanged() {
+ this.target.metricColumn = this.metricColumnSegment.value;
+ this.panelCtrl.refresh();
+ }
+
onDataReceived(dataList) {
this.lastQueryMeta = null;
this.lastQueryError = null;
@@ -72,4 +268,356 @@ export class MysqlQueryCtrl extends QueryCtrl {
}
}
}
+
+ transformToSegments(config) {
+ return results => {
+ const segments = _.map(results, segment => {
+ return this.uiSegmentSrv.newSegment({
+ value: segment.text,
+ expandable: segment.expandable,
+ });
+ });
+
+ if (config.addTemplateVars) {
+ for (const variable of this.templateSrv.variables) {
+ let value;
+ value = '$' + variable.name;
+ if (config.templateQuoter && variable.multi === false) {
+ value = config.templateQuoter(value);
+ }
+
+ segments.unshift(
+ this.uiSegmentSrv.newSegment({
+ type: 'template',
+ value: value,
+ expandable: true,
+ })
+ );
+ }
+ }
+
+ if (config.addNone) {
+ segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true }));
+ }
+
+ return segments;
+ };
+ }
+
+ findAggregateIndex(selectParts) {
+ return _.findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile');
+ }
+
+ findWindowIndex(selectParts) {
+ return _.findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window');
+ }
+
+ addSelectPart(selectParts, item, subItem) {
+ let partType = item.value;
+ if (subItem && subItem.type) {
+ partType = subItem.type;
+ }
+ let partModel = sqlPart.create({ type: partType });
+ if (subItem) {
+ partModel.params[0] = subItem.value;
+ }
+ let addAlias = false;
+
+ switch (partType) {
+ case 'column':
+ const parts = _.map(selectParts, (part: any) => {
+ return sqlPart.create({ type: part.def.type, params: _.clone(part.params) });
+ });
+ this.selectParts.push(parts);
+ break;
+ case 'percentile':
+ case 'aggregate':
+ // add group by if no group by yet
+ if (this.target.group.length === 0) {
+ this.addGroup('time', '$__interval');
+ }
+ const aggIndex = this.findAggregateIndex(selectParts);
+ if (aggIndex !== -1) {
+ // replace current aggregation
+ selectParts[aggIndex] = partModel;
+ } else {
+ selectParts.splice(1, 0, partModel);
+ }
+ if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) {
+ addAlias = true;
+ }
+ break;
+ case 'moving_window':
+ case 'window':
+ const windowIndex = this.findWindowIndex(selectParts);
+ if (windowIndex !== -1) {
+ // replace current window function
+ selectParts[windowIndex] = partModel;
+ } else {
+ const aggIndex = this.findAggregateIndex(selectParts);
+ if (aggIndex !== -1) {
+ selectParts.splice(aggIndex + 1, 0, partModel);
+ } else {
+ selectParts.splice(1, 0, partModel);
+ }
+ }
+ if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) {
+ addAlias = true;
+ }
+ break;
+ case 'alias':
+ addAlias = true;
+ break;
+ }
+
+ if (addAlias) {
+ // set initial alias name to column name
+ partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] });
+ if (selectParts[selectParts.length - 1].def.type === 'alias') {
+ selectParts[selectParts.length - 1] = partModel;
+ } else {
+ selectParts.push(partModel);
+ }
+ }
+
+ this.updatePersistedParts();
+ this.panelCtrl.refresh();
+ }
+
+ removeSelectPart(selectParts, part) {
+ if (part.def.type === 'column') {
+ // remove all parts of column unless its last column
+ if (this.selectParts.length > 1) {
+ const modelsIndex = _.indexOf(this.selectParts, selectParts);
+ this.selectParts.splice(modelsIndex, 1);
+ }
+ } else {
+ const partIndex = _.indexOf(selectParts, part);
+ selectParts.splice(partIndex, 1);
+ }
+
+ this.updatePersistedParts();
+ }
+
+ handleSelectPartEvent(selectParts, part, evt) {
+ switch (evt.name) {
+ case 'get-param-options': {
+ switch (part.def.type) {
+ // case 'aggregate':
+ // return this.datasource
+ // .metricFindQuery(this.metaBuilder.buildAggregateQuery())
+ // .then(this.transformToSegments({}))
+ // .catch(this.handleQueryError.bind(this));
+ case 'column':
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildColumnQuery('value'))
+ .then(this.transformToSegments({}))
+ .catch(this.handleQueryError.bind(this));
+ }
+ }
+ case 'part-param-changed': {
+ this.updatePersistedParts();
+ this.panelCtrl.refresh();
+ break;
+ }
+ case 'action': {
+ this.removeSelectPart(selectParts, part);
+ this.panelCtrl.refresh();
+ break;
+ }
+ case 'get-part-actions': {
+ return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
+ }
+ }
+ }
+
+ handleGroupPartEvent(part, index, evt) {
+ switch (evt.name) {
+ case 'get-param-options': {
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildColumnQuery())
+ .then(this.transformToSegments({}))
+ .catch(this.handleQueryError.bind(this));
+ }
+ case 'part-param-changed': {
+ this.updatePersistedParts();
+ this.panelCtrl.refresh();
+ break;
+ }
+ case 'action': {
+ this.removeGroup(part, index);
+ this.panelCtrl.refresh();
+ break;
+ }
+ case 'get-part-actions': {
+ return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
+ }
+ }
+ }
+
+ addGroup(partType, value) {
+ let params = [value];
+ if (partType === 'time') {
+ params = ['$__interval', 'none'];
+ }
+ const partModel = sqlPart.create({ type: partType, params: params });
+
+ if (partType === 'time') {
+ // put timeGroup at start
+ this.groupParts.splice(0, 0, partModel);
+ } else {
+ this.groupParts.push(partModel);
+ }
+
+ // add aggregates when adding group by
+ for (const selectParts of this.selectParts) {
+ if (!selectParts.some(part => part.def.type === 'aggregate')) {
+ const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] });
+ selectParts.splice(1, 0, aggregate);
+ if (!selectParts.some(part => part.def.type === 'alias')) {
+ const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] });
+ selectParts.push(alias);
+ }
+ }
+ }
+
+ this.updatePersistedParts();
+ }
+
+ removeGroup(part, index) {
+ if (part.def.type === 'time') {
+ // remove aggregations
+ this.selectParts = _.map(this.selectParts, (s: any) => {
+ return _.filter(s, (part: any) => {
+ if (part.def.type === 'aggregate' || part.def.type === 'percentile') {
+ return false;
+ }
+ return true;
+ });
+ });
+ }
+
+ this.groupParts.splice(index, 1);
+ this.updatePersistedParts();
+ }
+
+ handleWherePartEvent(whereParts, part, evt, index) {
+ switch (evt.name) {
+ case 'get-param-options': {
+ switch (evt.param.name) {
+ case 'left':
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildColumnQuery())
+ .then(this.transformToSegments({}))
+ .catch(this.handleQueryError.bind(this));
+ case 'right':
+ if (['int', 'bigint', 'double', 'datetime'].indexOf(part.datatype) > -1) {
+ // don't do value lookups for numerical fields
+ return this.$q.when([]);
+ } else {
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0]))
+ .then(
+ this.transformToSegments({
+ addTemplateVars: true,
+ templateQuoter: (v: string) => {
+ return this.queryModel.quoteLiteral(v);
+ },
+ })
+ )
+ .catch(this.handleQueryError.bind(this));
+ }
+ case 'op':
+ return this.$q.when(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype)));
+ default:
+ return this.$q.when([]);
+ }
+ }
+ case 'part-param-changed': {
+ this.updatePersistedParts();
+ this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => {
+ if (d.length === 1) {
+ part.datatype = d[0].text;
+ }
+ });
+ this.panelCtrl.refresh();
+ break;
+ }
+ case 'action': {
+ // remove element
+ whereParts.splice(index, 1);
+ this.updatePersistedParts();
+ this.panelCtrl.refresh();
+ break;
+ }
+ case 'get-part-actions': {
+ return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
+ }
+ }
+ }
+
+ getWhereOptions() {
+ const options = [];
+ if (this.queryModel.hasUnixEpochTimecolumn()) {
+ options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' }));
+ } else {
+ options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' }));
+ }
+ options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' }));
+ return this.$q.when(options);
+ }
+
+ addWhereAction(part, index) {
+ switch (this.whereAdd.type) {
+ case 'macro': {
+ const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] });
+ if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') {
+ // replace current macro
+ this.whereParts[0] = partModel;
+ } else {
+ this.whereParts.splice(0, 0, partModel);
+ }
+ break;
+ }
+ default: {
+ this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] }));
+ }
+ }
+
+ this.updatePersistedParts();
+ this.resetPlusButton(this.whereAdd);
+ this.panelCtrl.refresh();
+ }
+
+ getGroupOptions() {
+ return this.datasource
+ .metricFindQuery(this.metaBuilder.buildColumnQuery('group'))
+ .then(tags => {
+ const options = [];
+ if (!this.queryModel.hasTimeGroup()) {
+ options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' }));
+ }
+ for (const tag of tags) {
+ options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text }));
+ }
+ return options;
+ })
+ .catch(this.handleQueryError.bind(this));
+ }
+
+ addGroupAction() {
+ switch (this.groupAdd.value) {
+ default: {
+ this.addGroup(this.groupAdd.type, this.groupAdd.value);
+ }
+ }
+
+ this.resetPlusButton(this.groupAdd);
+ this.panelCtrl.refresh();
+ }
+
+ handleQueryError(err) {
+ this.error = err.message || 'Failed to issue metric query';
+ return [];
+ }
}
diff --git a/public/app/plugins/datasource/mysql/specs/datasource.test.ts b/public/app/plugins/datasource/mysql/specs/datasource.test.ts
index 2cd9b189ec0..f3fbcd93333 100644
--- a/public/app/plugins/datasource/mysql/specs/datasource.test.ts
+++ b/public/app/plugins/datasource/mysql/specs/datasource.test.ts
@@ -9,12 +9,23 @@ describe('MySQLDatasource', () => {
replace: jest.fn(text => text),
};
+ const raw = {
+ from: moment.utc('2018-04-25 10:00'),
+ to: moment.utc('2018-04-25 11:00'),
+ };
const ctx = {
backendSrv,
+ timeSrvMock: {
+ timeRange: () => ({
+ from: raw.from,
+ to: raw.to,
+ raw: raw,
+ }),
+ },
} as any;
beforeEach(() => {
- ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv);
+ ctx.ds = new MysqlDatasource(instanceSettings, backendSrv, {}, templateSrv, ctx.timeSrvMock);
});
describe('When performing annotationQuery', () => {
diff --git a/public/app/plugins/datasource/mysql/sql_part.ts b/public/app/plugins/datasource/mysql/sql_part.ts
new file mode 100644
index 00000000000..e7984ef1346
--- /dev/null
+++ b/public/app/plugins/datasource/mysql/sql_part.ts
@@ -0,0 +1,86 @@
+import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part';
+
+const index = [];
+
+function createPart(part): any {
+ const def = index[part.type];
+ if (!def) {
+ return null;
+ }
+
+ return new SqlPart(part, def);
+}
+
+function register(options: any) {
+ index[options.type] = new SqlPartDef(options);
+}
+
+register({
+ type: 'column',
+ style: 'label',
+ params: [{ type: 'column', dynamicLookup: true }],
+ defaultParams: ['value'],
+});
+
+register({
+ type: 'expression',
+ style: 'expression',
+ label: 'Expr:',
+ params: [
+ { name: 'left', type: 'string', dynamicLookup: true },
+ { name: 'op', type: 'string', dynamicLookup: true },
+ { name: 'right', type: 'string', dynamicLookup: true },
+ ],
+ defaultParams: ['value', '=', 'value'],
+});
+
+register({
+ type: 'macro',
+ style: 'label',
+ label: 'Macro:',
+ params: [],
+ defaultParams: [],
+});
+
+register({
+ type: 'aggregate',
+ style: 'label',
+ params: [
+ {
+ name: 'name',
+ type: 'string',
+ options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'],
+ },
+ ],
+ defaultParams: ['avg'],
+});
+
+register({
+ type: 'alias',
+ style: 'label',
+ params: [{ name: 'name', type: 'string', quote: 'double' }],
+ defaultParams: ['alias'],
+});
+
+register({
+ type: 'time',
+ style: 'function',
+ label: 'time',
+ params: [
+ {
+ name: 'interval',
+ type: 'interval',
+ options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'],
+ },
+ {
+ name: 'fill',
+ type: 'string',
+ options: ['none', 'NULL', 'previous', '0'],
+ },
+ ],
+ defaultParams: ['$__interval', 'none'],
+});
+
+export default {
+ create: createPart,
+};
diff --git a/public/app/plugins/datasource/opentsdb/datasource.ts b/public/app/plugins/datasource/opentsdb/datasource.ts
index 7cb0806359d..772f2aa7ff9 100644
--- a/public/app/plugins/datasource/opentsdb/datasource.ts
+++ b/public/app/plugins/datasource/opentsdb/datasource.ts
@@ -10,7 +10,6 @@ export default class OpenTsDatasource {
basicAuth: any;
tsdbVersion: any;
tsdbResolution: any;
- supportMetrics: any;
tagKeys: any;
aggregatorsPromise: any;
@@ -26,7 +25,6 @@ export default class OpenTsDatasource {
instanceSettings.jsonData = instanceSettings.jsonData || {};
this.tsdbVersion = instanceSettings.jsonData.tsdbVersion || 1;
this.tsdbResolution = instanceSettings.jsonData.tsdbResolution || 1;
- this.supportMetrics = true;
this.tagKeys = {};
this.aggregatorsPromise = null;
diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts
index f1db05cabe8..13948c5d793 100644
--- a/public/app/plugins/datasource/postgres/datasource.ts
+++ b/public/app/plugins/datasource/postgres/datasource.ts
@@ -20,7 +20,7 @@ export class PostgresDatasource {
this.interval = (instanceSettings.jsonData || {}).timeInterval;
}
- interpolateVariable(value, variable) {
+ interpolateVariable = (value, variable) => {
if (typeof value === 'string') {
if (variable.multi || variable.includeAll) {
return this.queryModel.quoteLiteral(value);
@@ -37,7 +37,7 @@ export class PostgresDatasource {
return this.queryModel.quoteLiteral(v);
});
return quotedValues.join(',');
- }
+ };
query(options) {
const queries = _.filter(options.targets, target => {
diff --git a/public/app/plugins/datasource/postgres/partials/config.html b/public/app/plugins/datasource/postgres/partials/config.html
index c8b551c2aa8..13b74d6b20e 100644
--- a/public/app/plugins/datasource/postgres/partials/config.html
+++ b/public/app/plugins/datasource/postgres/partials/config.html
@@ -38,6 +38,37 @@
+Connection limits
+
+
+
PostgreSQL details
diff --git a/public/app/plugins/datasource/postgres/postgres_query.ts b/public/app/plugins/datasource/postgres/postgres_query.ts
index 89518bb15e0..e315f1fecd2 100644
--- a/public/app/plugins/datasource/postgres/postgres_query.ts
+++ b/public/app/plugins/datasource/postgres/postgres_query.ts
@@ -184,6 +184,11 @@ export default class PostgresQuery {
switch (windows.type) {
case 'window':
switch (windows.params[0]) {
+ case 'delta':
+ curr = query;
+ prev = 'lag(' + curr + ') OVER (' + over + ')';
+ query = curr + ' - ' + prev;
+ break;
case 'increase':
curr = query;
prev = 'lag(' + curr + ') OVER (' + over + ')';
diff --git a/public/app/plugins/datasource/postgres/query_ctrl.ts b/public/app/plugins/datasource/postgres/query_ctrl.ts
index 9343a260a9e..aa66a1594cb 100644
--- a/public/app/plugins/datasource/postgres/query_ctrl.ts
+++ b/public/app/plugins/datasource/postgres/query_ctrl.ts
@@ -158,6 +158,7 @@ export class PostgresQueryCtrl extends QueryCtrl {
text: 'Window Functions',
value: 'window',
submenu: [
+ { text: 'Delta', value: 'delta' },
{ text: 'Increase', value: 'increase' },
{ text: 'Rate', value: 'rate' },
{ text: 'Sum', value: 'sum' },
diff --git a/public/app/plugins/datasource/postgres/sql_part.ts b/public/app/plugins/datasource/postgres/sql_part.ts
index 695060f6366..f8caaf0e39e 100644
--- a/public/app/plugins/datasource/postgres/sql_part.ts
+++ b/public/app/plugins/datasource/postgres/sql_part.ts
@@ -107,7 +107,7 @@ register({
{
name: 'function',
type: 'string',
- options: ['increase', 'rate', 'sum'],
+ options: ['delta', 'increase', 'rate', 'sum'],
},
],
defaultParams: ['increase'],
diff --git a/public/app/plugins/datasource/prometheus/add_label_to_query.ts b/public/app/plugins/datasource/prometheus/add_label_to_query.ts
index 0a1c3ea1878..433bb3e78f9 100644
--- a/public/app/plugins/datasource/prometheus/add_label_to_query.ts
+++ b/public/app/plugins/datasource/prometheus/add_label_to_query.ts
@@ -77,6 +77,7 @@ function addLabelToSelector(selector: string, labelKey: string, labelValue: stri
// Sort labels by key and put them together
return _.chain(parsedLabels)
+ .uniqWith(_.isEqual)
.compact()
.sortBy('key')
.map(({ key, operator, value }) => `${key}${operator}${value}`)
diff --git a/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx
new file mode 100644
index 00000000000..a2d3a03d794
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/components/PromCheatSheet.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+
+const CHEAT_SHEET_ITEMS = [
+ {
+ title: 'Request Rate',
+ expression: 'rate(http_request_total[5m])',
+ label:
+ 'Given an HTTP request counter, this query calculates the per-second average request rate over the last 5 minutes.',
+ },
+ {
+ title: '95th Percentile of Request Latencies',
+ expression: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[5m])) by (le))',
+ label: 'Calculates the 95th percentile of HTTP request rate over 5 minute windows.',
+ },
+ {
+ title: 'Alerts Firing',
+ expression: 'sort_desc(sum(sum_over_time(ALERTS{alertstate="firing"}[24h])) by (alertname))',
+ label: 'Sums up the alerts that have been firing over the last 24 hours.',
+ },
+];
+
+export default (props: any) => (
+
+
PromQL Cheat Sheet
+ {CHEAT_SHEET_ITEMS.map(item => (
+
+
{item.title}
+
props.onClickQuery(item.expression)}>
+ {item.expression}
+
+
{item.label}
+
+ ))}
+
+);
diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx
new file mode 100644
index 00000000000..bc7eb301be1
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx
@@ -0,0 +1,41 @@
+import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
+
+describe('groupMetricsByPrefix()', () => {
+ it('returns an empty group for no metrics', () => {
+ expect(groupMetricsByPrefix([])).toEqual([]);
+ });
+
+ it('returns options grouped by prefix', () => {
+ expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
+ {
+ value: 'foo',
+ children: [
+ {
+ value: 'foo_metric',
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('returns options without prefix as toplevel option', () => {
+ expect(groupMetricsByPrefix(['metric'])).toMatchObject([
+ {
+ value: 'metric',
+ },
+ ]);
+ });
+
+ it('returns recording rules grouped separately', () => {
+ expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
+ {
+ value: RECORDING_RULES_GROUP,
+ children: [
+ {
+ value: ':foo_metric:',
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
new file mode 100644
index 00000000000..649b17ad8cf
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
@@ -0,0 +1,273 @@
+import _ from 'lodash';
+import React from 'react';
+import Cascader from 'rc-cascader';
+import PluginPrism from 'slate-prism';
+import Prism from 'prismjs';
+
+import { TypeaheadOutput } from 'app/types/explore';
+
+// dom also includes Element polyfills
+import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
+import BracesPlugin from 'app/features/explore/slate-plugins/braces';
+import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
+import TypeaheadField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
+
+const HISTOGRAM_GROUP = '__histograms__';
+const METRIC_MARK = 'metric';
+const PRISM_SYNTAX = 'promql';
+export const RECORDING_RULES_GROUP = '__recording_rules__';
+
+export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
+ // Filter out recording rules and insert as first option
+ const ruleRegex = /:\w+:/;
+ const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
+ const rulesOption = {
+ label: 'Recording rules',
+ value: RECORDING_RULES_GROUP,
+ children: ruleNames
+ .slice()
+ .sort()
+ .map(name => ({ label: name, value: name })),
+ };
+
+ const options = ruleNames.length > 0 ? [rulesOption] : [];
+
+ const metricsOptions = _.chain(metrics)
+ .filter(metric => !ruleRegex.test(metric))
+ .groupBy(metric => metric.split(delimiter)[0])
+ .map((metricsForPrefix: string[], prefix: string): CascaderOption => {
+ const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
+ const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m }));
+ return {
+ children,
+ label: prefix,
+ value: prefix,
+ };
+ })
+ .sortBy('label')
+ .value();
+
+ return [...options, ...metricsOptions];
+}
+
+export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
+ // Modify suggestion based on context
+ switch (typeaheadContext) {
+ case 'context-labels': {
+ const nextChar = getNextCharacter();
+ if (!nextChar || nextChar === '}' || nextChar === ',') {
+ suggestion += '=';
+ }
+ break;
+ }
+
+ case 'context-label-values': {
+ // Always add quotes and remove existing ones instead
+ if (!typeaheadText.match(/^(!?=~?"|")/)) {
+ suggestion = `"${suggestion}`;
+ }
+ if (getNextCharacter() !== '"') {
+ suggestion = `${suggestion}"`;
+ }
+ break;
+ }
+
+ default:
+ }
+ return suggestion;
+}
+
+interface CascaderOption {
+ label: string;
+ value: string;
+ children?: CascaderOption[];
+ disabled?: boolean;
+}
+
+interface PromQueryFieldProps {
+ datasource: any;
+ error?: string | JSX.Element;
+ hint?: any;
+ history?: any[];
+ initialQuery?: string | null;
+ metricsByPrefix?: CascaderOption[];
+ onClickHintFix?: (action: any) => void;
+ onPressEnter?: () => void;
+ onQueryChange?: (value: string, override?: boolean) => void;
+}
+
+interface PromQueryFieldState {
+ metricsOptions: any[];
+ metricsByPrefix: CascaderOption[];
+ syntaxLoaded: boolean;
+}
+
+class PromQueryField extends React.PureComponent
{
+ plugins: any[];
+ languageProvider: any;
+
+ constructor(props: PromQueryFieldProps, context) {
+ super(props, context);
+
+ if (props.datasource.languageProvider) {
+ this.languageProvider = props.datasource.languageProvider;
+ }
+
+ this.plugins = [
+ BracesPlugin(),
+ RunnerPlugin({ handler: props.onPressEnter }),
+ PluginPrism({
+ onlyIn: node => node.type === 'code_block',
+ getSyntax: node => 'promql',
+ }),
+ ];
+
+ this.state = {
+ metricsByPrefix: [],
+ metricsOptions: [],
+ syntaxLoaded: false,
+ };
+ }
+
+ componentDidMount() {
+ if (this.languageProvider) {
+ this.languageProvider
+ .start()
+ .then(remaining => {
+ remaining.map(task => task.then(this.onReceiveMetrics).catch(() => {}));
+ })
+ .then(() => this.onReceiveMetrics());
+ }
+ }
+
+ onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
+ let query;
+ if (selectedOptions.length === 1) {
+ if (selectedOptions[0].children.length === 0) {
+ query = selectedOptions[0].value;
+ } else {
+ // Ignore click on group
+ return;
+ }
+ } else {
+ const prefix = selectedOptions[0].value;
+ const metric = selectedOptions[1].value;
+ if (prefix === HISTOGRAM_GROUP) {
+ query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
+ } else {
+ query = metric;
+ }
+ }
+ this.onChangeQuery(query, true);
+ };
+
+ onChangeQuery = (value: string, override?: boolean) => {
+ // Send text change to parent
+ const { onQueryChange } = this.props;
+ if (onQueryChange) {
+ onQueryChange(value, override);
+ }
+ };
+
+ onClickHintFix = () => {
+ const { hint, onClickHintFix } = this.props;
+ if (onClickHintFix && hint && hint.fix) {
+ onClickHintFix(hint.fix.action);
+ }
+ };
+
+ onReceiveMetrics = () => {
+ const { histogramMetrics, metrics } = this.languageProvider;
+ if (!metrics) {
+ return;
+ }
+
+ Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
+ Prism.languages[PRISM_SYNTAX][METRIC_MARK] = {
+ alias: 'variable',
+ pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
+ };
+
+ // Build metrics tree
+ const metricsByPrefix = groupMetricsByPrefix(metrics);
+ const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
+ const metricsOptions =
+ histogramMetrics.length > 0
+ ? [
+ { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions, isLeaf: false },
+ ...metricsByPrefix,
+ ]
+ : metricsByPrefix;
+
+ this.setState({ metricsOptions, syntaxLoaded: true });
+ };
+
+ onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+ if (!this.languageProvider) {
+ return { suggestions: [] };
+ }
+
+ const { history } = this.props;
+ const { prefix, text, value, wrapperNode } = typeahead;
+
+ // Get DOM-dependent context
+ const wrapperClasses = Array.from(wrapperNode.classList);
+ const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
+ const labelKey = labelKeyNode && labelKeyNode.textContent;
+ const nextChar = getNextCharacter();
+
+ const result = this.languageProvider.provideCompletionItems(
+ { text, value, prefix, wrapperClasses, labelKey },
+ { history }
+ );
+
+ console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
+
+ return result;
+ };
+
+ render() {
+ const { error, hint, initialQuery } = this.props;
+ const { metricsOptions, syntaxLoaded } = this.state;
+ const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
+ const chooserText = syntaxLoaded ? 'Metrics' : 'Loading matrics...';
+
+ return (
+
+
+
+
+ {chooserText}
+
+
+
+
+
+ {error ?
{error}
: null}
+ {hint ? (
+
+ ) : null}
+
+
+ );
+ }
+}
+
+export default PromQueryField;
diff --git a/public/app/plugins/datasource/prometheus/components/PromStart.tsx b/public/app/plugins/datasource/prometheus/components/PromStart.tsx
new file mode 100644
index 00000000000..548360b342d
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/components/PromStart.tsx
@@ -0,0 +1,60 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+
+import PromCheatSheet from './PromCheatSheet';
+
+const TAB_MENU_ITEMS = [
+ {
+ text: 'Start',
+ id: 'start',
+ icon: 'fa fa-rocket',
+ },
+];
+
+export default class PromStart extends PureComponent {
+ state = {
+ active: 'start',
+ };
+
+ onClickTab = active => {
+ this.setState({ active });
+ };
+
+ render() {
+ const { active } = this.state;
+ const customCss = '';
+
+ return (
+
+
+
+ {active === 'start' &&
}
+
+
+ );
+ }
+}
diff --git a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json
index aa9384a907d..47975d83d9c 100644
--- a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json
+++ b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json
@@ -753,7 +753,7 @@
"steppedLine": false,
"targets": [
{
- "expr": "increase(grafana_alerting_result_total[1m])",
+ "expr": "increase(grafana_alerting_active_alerts[1m])",
"format": "time_series",
"intervalFactor": 3,
"legendFormat": "{{state}}",
@@ -764,7 +764,7 @@
"thresholds": [],
"timeFrom": null,
"timeShift": null,
- "title": "Grafana alert results",
+ "title": "Grafana active alerts",
"tooltip": {
"shared": true,
"sort": 0,
@@ -1049,4 +1049,4 @@
]
},
"timezone": ""
- }
\ No newline at end of file
+ }
diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts
index b53b9eb34c1..8514b6ca7d4 100644
--- a/public/app/plugins/datasource/prometheus/datasource.ts
+++ b/public/app/plugins/datasource/prometheus/datasource.ts
@@ -5,9 +5,11 @@ import kbn from 'app/core/utils/kbn';
import * as dateMath from 'app/core/utils/datemath';
import PrometheusMetricFindQuery from './metric_find_query';
import { ResultTransformer } from './result_transformer';
+import PrometheusLanguageProvider from './language_provider';
import { BackendSrv } from 'app/core/services/backend_srv';
import addLabelToQuery from './add_label_to_query';
+import { getQueryHints } from './query_hints';
export function alignRange(start, end, step) {
const alignedEnd = Math.ceil(end / step) * step;
@@ -18,104 +20,6 @@ export function alignRange(start, end, step) {
};
}
-export function determineQueryHints(series: any[], datasource?: any): any[] {
- const hints = series.map((s, i) => {
- const query: string = s.query;
- const index: number = s.responseIndex;
- if (query === undefined || index === undefined) {
- return null;
- }
-
- // ..._bucket metric needs a histogram_quantile()
- const histogramMetric = query.trim().match(/^\w+_bucket$/);
- if (histogramMetric) {
- const label = 'Time series has buckets, you probably wanted a histogram.';
- return {
- index,
- label,
- fix: {
- label: 'Fix by adding histogram_quantile().',
- action: {
- type: 'ADD_HISTOGRAM_QUANTILE',
- query,
- index,
- },
- },
- };
- }
-
- // Check for monotony
- const datapoints: number[][] = s.datapoints;
- if (query.indexOf('rate(') === -1 && datapoints.length > 1) {
- let increasing = false;
- const monotonic = datapoints.filter(dp => dp[0] !== null).every((dp, index) => {
- if (index === 0) {
- return true;
- }
- increasing = increasing || dp[0] > datapoints[index - 1][0];
- // monotonic?
- return dp[0] >= datapoints[index - 1][0];
- });
- if (increasing && monotonic) {
- const simpleMetric = query.trim().match(/^\w+$/);
- let label = 'Time series is monotonously increasing.';
- let fix;
- if (simpleMetric) {
- fix = {
- label: 'Fix by adding rate().',
- action: {
- type: 'ADD_RATE',
- query,
- index,
- },
- };
- } else {
- label = `${label} Try applying a rate() function.`;
- }
- return {
- label,
- index,
- fix,
- };
- }
- }
-
- // Check for recording rules expansion
- if (datasource && datasource.ruleMappings) {
- const mapping = datasource.ruleMappings;
- const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
- if (query.search(ruleName) > -1) {
- return {
- ...acc,
- [ruleName]: mapping[ruleName],
- };
- }
- return acc;
- }, {});
- if (_.size(mappingForQuery) > 0) {
- const label = 'Query contains recording rules.';
- return {
- label,
- index,
- fix: {
- label: 'Expand rules',
- action: {
- type: 'EXPAND_RULES',
- query,
- index,
- mapping: mappingForQuery,
- },
- },
- };
- }
- }
-
- // No hint found
- return null;
- });
- return hints;
-}
-
export function extractRuleMappingFromGroups(groups: any[]) {
return groups.reduce(
(mapping, group) =>
@@ -149,8 +53,6 @@ export class PrometheusDatasource {
editorSrc: string;
name: string;
ruleMappings: { [index: string]: string };
- supportsExplore: boolean;
- supportMetrics: boolean;
url: string;
directUrl: string;
basicAuth: any;
@@ -159,6 +61,7 @@ export class PrometheusDatasource {
interval: string;
queryTimeout: string;
httpMethod: string;
+ languageProvider: PrometheusLanguageProvider;
resultTransformer: ResultTransformer;
/** @ngInject */
@@ -166,8 +69,6 @@ export class PrometheusDatasource {
this.type = 'prometheus';
this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
this.name = instanceSettings.name;
- this.supportsExplore = true;
- this.supportMetrics = true;
this.url = instanceSettings.url;
this.directUrl = instanceSettings.directUrl;
this.basicAuth = instanceSettings.basicAuth;
@@ -177,6 +78,7 @@ export class PrometheusDatasource {
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
this.resultTransformer = new ResultTransformer(templateSrv);
this.ruleMappings = {};
+ this.languageProvider = new PrometheusLanguageProvider(this);
}
init() {
@@ -277,7 +179,6 @@ export class PrometheusDatasource {
return this.$q.all(allQueryPromise).then(responseList => {
let result = [];
- let hints = [];
_.each(responseList, (response, index) => {
if (response.status === 'error') {
@@ -297,19 +198,14 @@ export class PrometheusDatasource {
end: queries[index].end,
query: queries[index].expr,
responseListLength: responseList.length,
- responseIndex: index,
refId: activeTargets[index].refId,
+ valueWithRefId: activeTargets[index].valueWithRefId,
};
const series = this.resultTransformer.transform(response, transformerOptions);
result = [...result, ...series];
-
- if (queries[index].hinting) {
- const queryHints = determineQueryHints(series, this);
- hints = [...hints, ...queryHints];
- }
});
- return { data: result, hints };
+ return { data: result };
});
}
@@ -522,10 +418,10 @@ export class PrometheusDatasource {
});
}
- getExploreState(panel) {
+ getExploreState(targets: any[]) {
let state = {};
- if (panel.targets) {
- const queries = panel.targets.map(t => ({
+ if (targets && targets.length > 0) {
+ const queries = targets.map(t => ({
query: this.templateSrv.replace(t.expr, {}, this.interpolateQueryExpr),
format: t.format,
}));
@@ -538,6 +434,10 @@ export class PrometheusDatasource {
return state;
}
+ getQueryHints(query: string, result: any[]) {
+ return getQueryHints(query, result, this);
+ }
+
loadRules() {
this.metadataRequest('/api/v1/rules')
.then(res => res.data || res.json())
@@ -564,6 +464,9 @@ export class PrometheusDatasource {
case 'ADD_RATE': {
return `rate(${query}[5m])`;
}
+ case 'ADD_SUM': {
+ return `sum(${query.trim()}) by ($1)`;
+ }
case 'EXPAND_RULES': {
const mapping = action.mapping;
if (mapping) {
diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts
new file mode 100644
index 00000000000..5f97ebfa7b5
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/language_provider.ts
@@ -0,0 +1,311 @@
+import _ from 'lodash';
+import moment from 'moment';
+
+import {
+ CompletionItem,
+ CompletionItemGroup,
+ LanguageProvider,
+ TypeaheadInput,
+ TypeaheadOutput,
+} from 'app/types/explore';
+
+import { parseSelector, processLabels, RATE_RANGES } from './language_utils';
+import PromqlSyntax, { FUNCTIONS } from './promql';
+
+const DEFAULT_KEYS = ['job', 'instance'];
+const EMPTY_SELECTOR = '{}';
+const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
+const HISTORY_ITEM_COUNT = 5;
+const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
+
+const wrapLabel = (label: string) => ({ label });
+
+const setFunctionMove = (suggestion: CompletionItem): CompletionItem => {
+ suggestion.move = -1;
+ return suggestion;
+};
+
+export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem {
+ const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
+ const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
+ const count = historyForItem.length;
+ const recent = historyForItem[0];
+ let hint = `Queried ${count} times in the last 24h.`;
+ if (recent) {
+ const lastQueried = moment(recent.ts).fromNow();
+ hint = `${hint} Last queried ${lastQueried}.`;
+ }
+ return {
+ ...item,
+ documentation: hint,
+ };
+}
+
+export default class PromQlLanguageProvider extends LanguageProvider {
+ histogramMetrics?: string[];
+ labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
+ labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
+ metrics?: string[];
+ started: boolean;
+
+ constructor(datasource: any, initialValues?: any) {
+ super();
+
+ this.datasource = datasource;
+ this.histogramMetrics = [];
+ this.labelKeys = {};
+ this.labelValues = {};
+ this.metrics = [];
+ this.started = false;
+
+ Object.assign(this, initialValues);
+ }
+ // Strip syntax chars
+ cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
+
+ getSyntax() {
+ return PromqlSyntax;
+ }
+
+ request = url => {
+ return this.datasource.metadataRequest(url);
+ };
+
+ start = () => {
+ if (!this.started) {
+ this.started = true;
+ return this.fetchMetricNames().then(() => [this.fetchHistogramMetrics()]);
+ }
+ return Promise.resolve([]);
+ };
+
+ // Keep this DOM-free for testing
+ provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput {
+ // Syntax spans have 3 classes by default. More indicate a recognized token
+ const tokenRecognized = wrapperClasses.length > 3;
+ // Determine candidates by CSS context
+ if (_.includes(wrapperClasses, 'context-range')) {
+ // Suggestions for metric[|]
+ return this.getRangeCompletionItems();
+ } else if (_.includes(wrapperClasses, 'context-labels')) {
+ // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
+ return this.getLabelCompletionItems.apply(this, arguments);
+ } else if (_.includes(wrapperClasses, 'context-aggregation')) {
+ return this.getAggregationCompletionItems.apply(this, arguments);
+ } else if (
+ // Show default suggestions in a couple of scenarios
+ (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
+ (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
+ text.match(/[+\-*/^%]/) // Anything after binary operator
+ ) {
+ return this.getEmptyCompletionItems(context || {});
+ }
+
+ return {
+ suggestions: [],
+ };
+ }
+
+ getEmptyCompletionItems(context: any): TypeaheadOutput {
+ const { history } = context;
+ const { metrics } = this;
+ const suggestions: CompletionItemGroup[] = [];
+
+ if (history && history.length > 0) {
+ const historyItems = _.chain(history)
+ .uniqBy('query')
+ .take(HISTORY_ITEM_COUNT)
+ .map(h => h.query)
+ .map(wrapLabel)
+ .map(item => addHistoryMetadata(item, history))
+ .value();
+
+ suggestions.push({
+ prefixMatch: true,
+ skipSort: true,
+ label: 'History',
+ items: historyItems,
+ });
+ }
+
+ suggestions.push({
+ prefixMatch: true,
+ label: 'Functions',
+ items: FUNCTIONS.map(setFunctionMove),
+ });
+
+ if (metrics) {
+ suggestions.push({
+ label: 'Metrics',
+ items: metrics.map(wrapLabel),
+ });
+ }
+ return { suggestions };
+ }
+
+ getRangeCompletionItems(): TypeaheadOutput {
+ return {
+ context: 'context-range',
+ suggestions: [
+ {
+ label: 'Range vector',
+ items: [...RATE_RANGES].map(wrapLabel),
+ },
+ ],
+ };
+ }
+
+ getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
+ let refresher: Promise = null;
+ const suggestions: CompletionItemGroup[] = [];
+
+ // Stitch all query lines together to support multi-line queries
+ let queryOffset;
+ const queryText = value.document.getBlocks().reduce((text, block) => {
+ const blockText = block.getText();
+ if (value.anchorBlock.key === block.key) {
+ // Newline characters are not accounted for but this is irrelevant
+ // for the purpose of extracting the selector string
+ queryOffset = value.anchorOffset + text.length;
+ }
+ text += blockText;
+ return text;
+ }, '');
+
+ const leftSide = queryText.slice(0, queryOffset);
+ const openParensAggregationIndex = leftSide.lastIndexOf('(');
+ const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
+ const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
+
+ let selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
+
+ // Range vector syntax not accounted for by subsequent parse so discard it if present
+ selectorString = selectorString.replace(/\[[^\]]+\]$/, '');
+
+ const selector = parseSelector(selectorString, selectorString.length - 2).selector;
+
+ const labelKeys = this.labelKeys[selector];
+ if (labelKeys) {
+ suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
+ } else {
+ refresher = this.fetchSeriesLabels(selector);
+ }
+
+ return {
+ refresher,
+ suggestions,
+ context: 'context-aggregation',
+ };
+ }
+
+ getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
+ let context: string;
+ let refresher: Promise = null;
+ const suggestions: CompletionItemGroup[] = [];
+ const line = value.anchorBlock.getText();
+ const cursorOffset: number = value.anchorOffset;
+
+ // Get normalized selector
+ let selector;
+ let parsedSelector;
+ try {
+ parsedSelector = parseSelector(line, cursorOffset);
+ selector = parsedSelector.selector;
+ } catch {
+ selector = EMPTY_SELECTOR;
+ }
+ const containsMetric = selector.indexOf('__name__=') > -1;
+ const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
+
+ if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
+ // Label values
+ if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
+ const labelValues = this.labelValues[selector][labelKey];
+ context = 'context-label-values';
+ suggestions.push({
+ label: `Label values for "${labelKey}"`,
+ items: labelValues.map(wrapLabel),
+ });
+ }
+ } else {
+ // Label keys
+ const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
+ if (labelKeys) {
+ const possibleKeys = _.difference(labelKeys, existingKeys);
+ if (possibleKeys.length > 0) {
+ context = 'context-labels';
+ suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
+ }
+ }
+ }
+
+ // Query labels for selector
+ if (selector && !this.labelValues[selector]) {
+ if (selector === EMPTY_SELECTOR) {
+ // Query label values for default labels
+ refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
+ } else {
+ refresher = this.fetchSeriesLabels(selector, !containsMetric);
+ }
+ }
+
+ return { context, refresher, suggestions };
+ }
+
+ async fetchMetricNames() {
+ const url = '/api/v1/label/__name__/values';
+ try {
+ const res = await this.request(url);
+ const body = await (res.data || res.json());
+ this.metrics = body.data;
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ async fetchHistogramMetrics() {
+ await this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true);
+ const histogramSeries = this.labelValues[HISTOGRAM_SELECTOR];
+ if (histogramSeries && histogramSeries['__name__']) {
+ this.histogramMetrics = histogramSeries['__name__'].slice().sort();
+ }
+ }
+
+ async fetchLabelValues(key: string) {
+ const url = `/api/v1/label/${key}/values`;
+ try {
+ const res = await this.request(url);
+ const body = await (res.data || res.json());
+ const exisingValues = this.labelValues[EMPTY_SELECTOR];
+ const values = {
+ ...exisingValues,
+ [key]: body.data,
+ };
+ this.labelValues = {
+ ...this.labelValues,
+ [EMPTY_SELECTOR]: values,
+ };
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ async fetchSeriesLabels(name: string, withName?: boolean) {
+ const url = `/api/v1/series?match[]=${name}`;
+ try {
+ const res = await this.request(url);
+ const body = await (res.data || res.json());
+ const { keys, values } = processLabels(body.data, withName);
+ this.labelKeys = {
+ ...this.labelKeys,
+ [name]: keys,
+ };
+ this.labelValues = {
+ ...this.labelValues,
+ [name]: values,
+ };
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
diff --git a/public/app/features/explore/utils/prometheus.ts b/public/app/plugins/datasource/prometheus/language_utils.ts
similarity index 84%
rename from public/app/features/explore/utils/prometheus.ts
rename to public/app/plugins/datasource/prometheus/language_utils.ts
index 8c41b94d684..5995c427cd1 100644
--- a/public/app/features/explore/utils/prometheus.ts
+++ b/public/app/plugins/datasource/prometheus/language_utils.ts
@@ -23,12 +23,9 @@ export function processLabels(labels, withName = false) {
return { values, keys: Object.keys(values) };
}
-// Strip syntax chars
-export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
-
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
const selectorRegexp = /\{[^}]*?\}/;
-const labelRegexp = /\b\w+="[^"\n]*?"/g;
+const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
if (!query.match(selectorRegexp)) {
// Special matcher for metrics
@@ -66,11 +63,8 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any
// Extract clean labels to form clean selector, incomplete labels are dropped
const selector = query.slice(prefixOpen, suffixClose);
const labels = {};
- selector.replace(labelRegexp, match => {
- const delimiterIndex = match.indexOf('=');
- const key = match.slice(0, delimiterIndex);
- const value = match.slice(delimiterIndex + 1, match.length);
- labels[key] = value;
+ selector.replace(labelRegexp, (_, key, operator, value) => {
+ labels[key] = { value, operator };
return '';
});
@@ -78,12 +72,12 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any
const metricPrefix = query.slice(0, prefixOpen);
const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/);
if (metricMatch) {
- labels['__name__'] = `"${metricMatch[0]}"`;
+ labels['__name__'] = { value: `"${metricMatch[0]}"`, operator: '=' };
}
// Build sorted selector
const labelKeys = Object.keys(labels).sort();
- const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(',');
+ const cleanSelector = labelKeys.map(key => `${key}${labels[key].operator}${labels[key].value}`).join(',');
const selectorString = ['{', cleanSelector, '}'].join('');
diff --git a/public/app/plugins/datasource/prometheus/metric_find_query.ts b/public/app/plugins/datasource/prometheus/metric_find_query.ts
index feada28deea..680f7a8fb98 100644
--- a/public/app/plugins/datasource/prometheus/metric_find_query.ts
+++ b/public/app/plugins/datasource/prometheus/metric_find_query.ts
@@ -12,7 +12,7 @@ export default class PrometheusMetricFindQuery {
}
process() {
- const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]+)\)\s*$/;
+ const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/;
const metricNamesRegex = /^metrics\((.+)\)\s*$/;
const queryResultRegex = /^query_result\((.+)\)\s*$/;
diff --git a/public/app/plugins/datasource/prometheus/module.ts b/public/app/plugins/datasource/prometheus/module.ts
index d7e0b8ebe2c..814f6fbe60a 100644
--- a/public/app/plugins/datasource/prometheus/module.ts
+++ b/public/app/plugins/datasource/prometheus/module.ts
@@ -2,6 +2,9 @@ import { PrometheusDatasource } from './datasource';
import { PrometheusQueryCtrl } from './query_ctrl';
import { PrometheusConfigCtrl } from './config_ctrl';
+import PrometheusStartPage from './components/PromStart';
+import PromQueryField from './components/PromQueryField';
+
class PrometheusAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
}
@@ -11,4 +14,6 @@ export {
PrometheusQueryCtrl as QueryCtrl,
PrometheusConfigCtrl as ConfigCtrl,
PrometheusAnnotationsQueryCtrl as AnnotationsQueryCtrl,
+ PromQueryField as ExploreQueryField,
+ PrometheusStartPage as ExploreStartPage,
};
diff --git a/public/app/features/explore/slate-plugins/prism/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts
similarity index 100%
rename from public/app/features/explore/slate-plugins/prism/promql.ts
rename to public/app/plugins/datasource/prometheus/promql.ts
diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts
new file mode 100644
index 00000000000..ea505832468
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/query_hints.ts
@@ -0,0 +1,118 @@
+import _ from 'lodash';
+
+import { QueryHint } from 'app/types/explore';
+
+/**
+ * Number of time series results needed before starting to suggest sum aggregation hints
+ */
+export const SUM_HINT_THRESHOLD_COUNT = 20;
+
+export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] {
+ const hints = [];
+
+ // ..._bucket metric needs a histogram_quantile()
+ const histogramMetric = query.trim().match(/^\w+_bucket$/);
+ if (histogramMetric) {
+ const label = 'Time series has buckets, you probably wanted a histogram.';
+ hints.push({
+ type: 'HISTOGRAM_QUANTILE',
+ label,
+ fix: {
+ label: 'Fix by adding histogram_quantile().',
+ action: {
+ type: 'ADD_HISTOGRAM_QUANTILE',
+ query,
+ },
+ },
+ });
+ }
+
+ // Check for monotony on series (table results are being ignored here)
+ if (series && series.length > 0) {
+ series.forEach(s => {
+ const datapoints: number[][] = s.datapoints;
+ if (query.indexOf('rate(') === -1 && datapoints.length > 1) {
+ let increasing = false;
+ const nonNullData = datapoints.filter(dp => dp[0] !== null);
+ const monotonic = nonNullData.every((dp, index) => {
+ if (index === 0) {
+ return true;
+ }
+ increasing = increasing || dp[0] > nonNullData[index - 1][0];
+ // monotonic?
+ return dp[0] >= nonNullData[index - 1][0];
+ });
+ if (increasing && monotonic) {
+ const simpleMetric = query.trim().match(/^\w+$/);
+ let label = 'Time series is monotonously increasing.';
+ let fix;
+ if (simpleMetric) {
+ fix = {
+ label: 'Fix by adding rate().',
+ action: {
+ type: 'ADD_RATE',
+ query,
+ },
+ };
+ } else {
+ label = `${label} Try applying a rate() function.`;
+ }
+ hints.push({
+ type: 'APPLY_RATE',
+ label,
+ fix,
+ });
+ }
+ }
+ });
+ }
+
+ // Check for recording rules expansion
+ if (datasource && datasource.ruleMappings) {
+ const mapping = datasource.ruleMappings;
+ const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
+ if (query.search(ruleName) > -1) {
+ return {
+ ...acc,
+ [ruleName]: mapping[ruleName],
+ };
+ }
+ return acc;
+ }, {});
+ if (_.size(mappingForQuery) > 0) {
+ const label = 'Query contains recording rules.';
+ hints.push({
+ type: 'EXPAND_RULES',
+ label,
+ fix: {
+ label: 'Expand rules',
+ action: {
+ type: 'EXPAND_RULES',
+ query,
+ mapping: mappingForQuery,
+ },
+ },
+ });
+ }
+ }
+
+ if (series.length >= SUM_HINT_THRESHOLD_COUNT) {
+ const simpleMetric = query.trim().match(/^\w+$/);
+ if (simpleMetric) {
+ hints.push({
+ type: 'ADD_SUM',
+ label: 'Many time series results returned.',
+ fix: {
+ label: 'Consider aggregating with sum().',
+ action: {
+ type: 'ADD_SUM',
+ query: query,
+ preventSubmit: true,
+ },
+ },
+ });
+ }
+ }
+
+ return hints.length > 0 ? hints : null;
+}
diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts
index bf916bebf04..c9693eaf657 100644
--- a/public/app/plugins/datasource/prometheus/result_transformer.ts
+++ b/public/app/plugins/datasource/prometheus/result_transformer.ts
@@ -8,7 +8,14 @@ export class ResultTransformer {
const prometheusResult = response.data.data.result;
if (options.format === 'table') {
- return [this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.refId)];
+ return [
+ this.transformMetricDataToTable(
+ prometheusResult,
+ options.responseListLength,
+ options.refId,
+ options.valueWithRefId
+ ),
+ ];
} else if (options.format === 'heatmap') {
let seriesList = [];
prometheusResult.sort(sortSeriesByLabel);
@@ -66,12 +73,11 @@ export class ResultTransformer {
return {
datapoints: dps,
query: options.query,
- responseIndex: options.responseIndex,
target: metricLabel,
};
}
- transformMetricDataToTable(md, resultCount: number, refId: string) {
+ transformMetricDataToTable(md, resultCount: number, refId: string, valueWithRefId?: boolean) {
const table = new TableModel();
let i, j;
const metricLabels = {};
@@ -96,7 +102,7 @@ export class ResultTransformer {
metricLabels[label] = labelIndex + 1;
table.columns.push({ text: label, filterable: !label.startsWith('__') });
});
- const valueText = resultCount > 1 ? `Value #${refId}` : 'Value';
+ const valueText = resultCount > 1 || valueWithRefId ? `Value #${refId}` : 'Value';
table.columns.push({ text: valueText });
// Populate rows, set value to empty string when label not present.
diff --git a/public/app/plugins/datasource/prometheus/specs/add_label_to_query.test.ts b/public/app/plugins/datasource/prometheus/specs/add_label_to_query.test.ts
index e7c114f7d5f..ae619fda143 100644
--- a/public/app/plugins/datasource/prometheus/specs/add_label_to_query.test.ts
+++ b/public/app/plugins/datasource/prometheus/specs/add_label_to_query.test.ts
@@ -40,4 +40,19 @@ describe('addLabelToQuery()', () => {
'foo{bar="baz",x="yy"} * metric{a="bb",bar="baz",y="zz"} * metric2{bar="baz"}'
);
});
+
+ it('should not add duplicate labels to aquery', () => {
+ expect(addLabelToQuery(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!='), 'bar', 'baz', '!=')).toBe(
+ 'foo{bar!="baz",x="yy"}'
+ );
+ expect(addLabelToQuery(addLabelToQuery('rate(metric[1m])', 'foo', 'bar'), 'foo', 'bar')).toBe(
+ 'rate(metric{foo="bar"}[1m])'
+ );
+ expect(addLabelToQuery(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz'), 'bar', 'baz')).toBe(
+ 'foo{bar="baz",list="a,b,c"}'
+ );
+ expect(addLabelToQuery(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz'), 'bar', 'baz')).toBe(
+ 'avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})'
+ );
+ });
});
diff --git a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts
index 692a1827247..bff35c7ba88 100644
--- a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts
+++ b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts
@@ -3,7 +3,6 @@ import moment from 'moment';
import q from 'q';
import {
alignRange,
- determineQueryHints,
extractRuleMappingFromGroups,
PrometheusDatasource,
prometheusSpecialRegexEscape,
@@ -216,84 +215,6 @@ describe('PrometheusDatasource', () => {
});
});
- describe('determineQueryHints()', () => {
- it('returns no hints for no series', () => {
- expect(determineQueryHints([])).toEqual([]);
- });
-
- it('returns no hints for empty series', () => {
- expect(determineQueryHints([{ datapoints: [], query: '' }])).toEqual([null]);
- });
-
- it('returns no hint for a monotonously decreasing series', () => {
- const series = [{ datapoints: [[23, 1000], [22, 1001]], query: 'metric', responseIndex: 0 }];
- const hints = determineQueryHints(series);
- expect(hints).toEqual([null]);
- });
-
- it('returns a rate hint for a monotonously increasing series', () => {
- const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'metric', responseIndex: 0 }];
- const hints = determineQueryHints(series);
- expect(hints.length).toBe(1);
- expect(hints[0]).toMatchObject({
- label: 'Time series is monotonously increasing.',
- index: 0,
- fix: {
- action: {
- type: 'ADD_RATE',
- query: 'metric',
- },
- },
- });
- });
-
- it('returns no rate hint for a monotonously increasing series that already has a rate', () => {
- const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'rate(metric[1m])', responseIndex: 0 }];
- const hints = determineQueryHints(series);
- expect(hints).toEqual([null]);
- });
-
- it('returns a rate hint w/o action for a complex monotonously increasing series', () => {
- const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'sum(metric)', responseIndex: 0 }];
- const hints = determineQueryHints(series);
- expect(hints.length).toBe(1);
- expect(hints[0].label).toContain('rate()');
- expect(hints[0].fix).toBeUndefined();
- });
-
- it('returns a rate hint for a monotonously increasing series with missing data', () => {
- const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]], query: 'metric', responseIndex: 0 }];
- const hints = determineQueryHints(series);
- expect(hints.length).toBe(1);
- expect(hints[0]).toMatchObject({
- label: 'Time series is monotonously increasing.',
- index: 0,
- fix: {
- action: {
- type: 'ADD_RATE',
- query: 'metric',
- },
- },
- });
- });
-
- it('returns a histogram hint for a bucket series', () => {
- const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }];
- const hints = determineQueryHints(series);
- expect(hints.length).toBe(1);
- expect(hints[0]).toMatchObject({
- label: 'Time series has buckets, you probably wanted a histogram.',
- index: 0,
- fix: {
- action: {
- type: 'ADD_HISTOGRAM_QUANTILE',
- query: 'metric_bucket',
- },
- },
- });
- });
- });
-
describe('extractRuleMappingFromGroups()', () => {
it('returns empty mapping for no rule groups', () => {
expect(extractRuleMappingFromGroups([])).toEqual({});
diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts
new file mode 100644
index 00000000000..20e148efd57
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts
@@ -0,0 +1,273 @@
+import Plain from 'slate-plain-serializer';
+
+import LanguageProvider from '../language_provider';
+
+describe('Language completion provider', () => {
+ const datasource = {
+ metadataRequest: () => ({ data: { data: [] } }),
+ };
+
+ it('returns default suggestions on emtpty context', () => {
+ const instance = new LanguageProvider(datasource);
+ const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] });
+ expect(result.context).toBeUndefined();
+ expect(result.refresher).toBeUndefined();
+ expect(result.suggestions.length).toEqual(2);
+ });
+
+ describe('range suggestions', () => {
+ it('returns range suggestions in range context', () => {
+ const instance = new LanguageProvider(datasource);
+ const result = instance.provideCompletionItems({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
+ expect(result.context).toBe('context-range');
+ expect(result.refresher).toBeUndefined();
+ expect(result.suggestions).toEqual([
+ {
+ items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
+ label: 'Range vector',
+ },
+ ]);
+ });
+ });
+
+ describe('metric suggestions', () => {
+ it('returns metrics suggestions by default', () => {
+ const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
+ const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', wrapperClasses: [] });
+ expect(result.context).toBeUndefined();
+ expect(result.refresher).toBeUndefined();
+ expect(result.suggestions.length).toEqual(2);
+ });
+
+ it('returns default suggestions after a binary operator', () => {
+ const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
+ const result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] });
+ expect(result.context).toBeUndefined();
+ expect(result.refresher).toBeUndefined();
+ expect(result.suggestions.length).toEqual(2);
+ });
+ });
+
+ describe('label suggestions', () => {
+ it('returns default label suggestions on label context and no metric', () => {
+ const instance = new LanguageProvider(datasource);
+ const value = Plain.deserialize('{}');
+ const range = value.selection.merge({
+ anchorOffset: 1,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-labels'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-labels');
+ expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
+ });
+
+ it('returns label suggestions on label context and metric', () => {
+ const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
+ const value = Plain.deserialize('metric{}');
+ const range = value.selection.merge({
+ anchorOffset: 7,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-labels'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-labels');
+ expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+ });
+
+ it('returns label suggestions on label context but leaves out labels that already exist', () => {
+ const instance = new LanguageProvider(datasource, {
+ labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] },
+ });
+ const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
+ const range = value.selection.merge({
+ anchorOffset: 36,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-labels'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-labels');
+ expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+ });
+
+ it('returns label value suggestions inside a label value context after a negated matching operator', () => {
+ const instance = new LanguageProvider(datasource, {
+ labelKeys: { '{}': ['label'] },
+ labelValues: { '{}': { label: ['a', 'b', 'c'] } },
+ });
+ const value = Plain.deserialize('{label!=}');
+ const range = value.selection.merge({ anchorOffset: 8 });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '!=',
+ prefix: '',
+ wrapperClasses: ['context-labels'],
+ labelKey: 'label',
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-label-values');
+ expect(result.suggestions).toEqual([
+ {
+ items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
+ label: 'Label values for "label"',
+ },
+ ]);
+ });
+
+ it('returns a refresher on label context and unavailable metric', () => {
+ const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } });
+ const value = Plain.deserialize('metric{}');
+ const range = value.selection.merge({
+ anchorOffset: 7,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-labels'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBeUndefined();
+ expect(result.refresher).toBeInstanceOf(Promise);
+ expect(result.suggestions).toEqual([]);
+ });
+
+ it('returns label values on label context when given a metric and a label key', () => {
+ const instance = new LanguageProvider(datasource, {
+ labelKeys: { '{__name__="metric"}': ['bar'] },
+ labelValues: { '{__name__="metric"}': { bar: ['baz'] } },
+ });
+ const value = Plain.deserialize('metric{bar=ba}');
+ const range = value.selection.merge({
+ anchorOffset: 13,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '=ba',
+ prefix: 'ba',
+ wrapperClasses: ['context-labels'],
+ labelKey: 'bar',
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-label-values');
+ expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
+ });
+
+ it('returns label suggestions on aggregation context and metric w/ selector', () => {
+ const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } });
+ const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
+ const range = value.selection.merge({
+ anchorOffset: 26,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-aggregation'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-aggregation');
+ expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+ });
+
+ it('returns label suggestions on aggregation context and metric w/o selector', () => {
+ const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
+ const value = Plain.deserialize('sum(metric) by ()');
+ const range = value.selection.merge({
+ anchorOffset: 16,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-aggregation'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-aggregation');
+ expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
+ });
+
+ it('returns label suggestions inside a multi-line aggregation context', () => {
+ const instance = new LanguageProvider(datasource, {
+ labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
+ });
+ const value = Plain.deserialize('sum(\nmetric\n)\nby ()');
+ const aggregationTextBlock = value.document.getBlocksAsArray()[3];
+ const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-aggregation'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-aggregation');
+ expect(result.suggestions).toEqual([
+ {
+ items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
+ label: 'Labels',
+ },
+ ]);
+ });
+
+ it('returns label suggestions inside an aggregation context with a range vector', () => {
+ const instance = new LanguageProvider(datasource, {
+ labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
+ });
+ const value = Plain.deserialize('sum(rate(metric[1h])) by ()');
+ const range = value.selection.merge({
+ anchorOffset: 26,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-aggregation'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-aggregation');
+ expect(result.suggestions).toEqual([
+ {
+ items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
+ label: 'Labels',
+ },
+ ]);
+ });
+
+ it('returns label suggestions inside an aggregation context with a range vector and label', () => {
+ const instance = new LanguageProvider(datasource, {
+ labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] },
+ });
+ const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()');
+ const range = value.selection.merge({
+ anchorOffset: 42,
+ });
+ const valueWithSelection = value.change().select(range).value;
+ const result = instance.provideCompletionItems({
+ text: '',
+ prefix: '',
+ wrapperClasses: ['context-aggregation'],
+ value: valueWithSelection,
+ });
+ expect(result.context).toBe('context-aggregation');
+ expect(result.suggestions).toEqual([
+ {
+ items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }],
+ label: 'Labels',
+ },
+ ]);
+ });
+ });
+});
diff --git a/public/app/features/explore/utils/prometheus.test.ts b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts
similarity index 97%
rename from public/app/features/explore/utils/prometheus.test.ts
rename to public/app/plugins/datasource/prometheus/specs/language_utils.test.ts
index 4e84deaa7e8..748217e21b7 100644
--- a/public/app/features/explore/utils/prometheus.test.ts
+++ b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts
@@ -1,4 +1,4 @@
-import { parseSelector } from './prometheus';
+import { parseSelector } from '../language_utils';
describe('parseSelector()', () => {
let parsed;
diff --git a/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts
new file mode 100644
index 00000000000..3b782d3dd09
--- /dev/null
+++ b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts
@@ -0,0 +1,103 @@
+import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from '../query_hints';
+
+describe('getQueryHints()', () => {
+ it('returns no hints for no series', () => {
+ expect(getQueryHints('', [])).toEqual(null);
+ });
+
+ it('returns no hints for empty series', () => {
+ expect(getQueryHints('', [{ datapoints: [] }])).toEqual(null);
+ });
+
+ it('returns no hint for a monotonously decreasing series', () => {
+ const series = [{ datapoints: [[23, 1000], [22, 1001]] }];
+ const hints = getQueryHints('metric', series);
+ expect(hints).toEqual(null);
+ });
+
+ it('returns no hint for a flat series', () => {
+ const series = [{ datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]] }];
+ const hints = getQueryHints('metric', series);
+ expect(hints).toEqual(null);
+ });
+
+ it('returns a rate hint for a monotonously increasing series', () => {
+ const series = [{ datapoints: [[23, 1000], [24, 1001]] }];
+ const hints = getQueryHints('metric', series);
+ expect(hints.length).toBe(1);
+ expect(hints[0]).toMatchObject({
+ label: 'Time series is monotonously increasing.',
+ fix: {
+ action: {
+ type: 'ADD_RATE',
+ query: 'metric',
+ },
+ },
+ });
+ });
+
+ it('returns no rate hint for a monotonously increasing series that already has a rate', () => {
+ const series = [{ datapoints: [[23, 1000], [24, 1001]] }];
+ const hints = getQueryHints('rate(metric[1m])', series);
+ expect(hints).toEqual(null);
+ });
+
+ it('returns a rate hint w/o action for a complex monotonously increasing series', () => {
+ const series = [{ datapoints: [[23, 1000], [24, 1001]] }];
+ const hints = getQueryHints('sum(metric)', series);
+ expect(hints.length).toBe(1);
+ expect(hints[0].label).toContain('rate()');
+ expect(hints[0].fix).toBeUndefined();
+ });
+
+ it('returns a rate hint for a monotonously increasing series with missing data', () => {
+ const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]] }];
+ const hints = getQueryHints('metric', series);
+ expect(hints.length).toBe(1);
+ expect(hints[0]).toMatchObject({
+ label: 'Time series is monotonously increasing.',
+ fix: {
+ action: {
+ type: 'ADD_RATE',
+ query: 'metric',
+ },
+ },
+ });
+ });
+
+ it('returns a histogram hint for a bucket series', () => {
+ const series = [{ datapoints: [[23, 1000]] }];
+ const hints = getQueryHints('metric_bucket', series);
+ expect(hints.length).toBe(1);
+ expect(hints[0]).toMatchObject({
+ label: 'Time series has buckets, you probably wanted a histogram.',
+ fix: {
+ action: {
+ type: 'ADD_HISTOGRAM_QUANTILE',
+ query: 'metric_bucket',
+ },
+ },
+ });
+ });
+
+ it('returns a sum hint when many time series results are returned for a simple metric', () => {
+ const seriesCount = SUM_HINT_THRESHOLD_COUNT;
+ const series = Array.from({ length: seriesCount }, _ => ({
+ datapoints: [[0, 0], [0, 0]],
+ }));
+ const hints = getQueryHints('metric', series);
+ expect(hints.length).toBe(1);
+ expect(hints[0]).toMatchObject({
+ type: 'ADD_SUM',
+ label: 'Many time series results returned.',
+ fix: {
+ label: 'Consider aggregating with sum().',
+ action: {
+ type: 'ADD_SUM',
+ query: 'metric',
+ preventSubmit: true,
+ },
+ },
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/stackdriver/README.md b/public/app/plugins/datasource/stackdriver/README.md
new file mode 100644
index 00000000000..6cb6f1ad4d4
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/README.md
@@ -0,0 +1,7 @@
+# Stackdriver Datasource - Native Plugin
+
+Grafana ships with built-in support for Google Stackdriver. You just have to add it as a datasource and you will be ready to build dashboards for your Stackdriver metrics.
+
+Read more about it here:
+
+[http://docs.grafana.org/datasources/stackdriver/](http://docs.grafana.org/datasources/stackdriver/)
diff --git a/public/app/plugins/datasource/stackdriver/annotations_query_ctrl.ts b/public/app/plugins/datasource/stackdriver/annotations_query_ctrl.ts
new file mode 100644
index 00000000000..bccac02bf38
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/annotations_query_ctrl.ts
@@ -0,0 +1,31 @@
+import _ from 'lodash';
+import './query_filter_ctrl';
+
+export class StackdriverAnnotationsQueryCtrl {
+ static templateUrl = 'partials/annotations.editor.html';
+ annotation: any;
+ datasource: any;
+
+ defaultDropdownValue = 'Select Metric';
+ defaultServiceValue = 'All Services';
+
+ defaults = {
+ project: {
+ id: 'default',
+ name: 'loading project...',
+ },
+ metricType: this.defaultDropdownValue,
+ service: this.defaultServiceValue,
+ metric: '',
+ filters: [],
+ metricKind: '',
+ valueType: '',
+ };
+
+ /** @ngInject */
+ constructor() {
+ this.annotation.target = this.annotation.target || {};
+ this.annotation.target.refId = 'annotationQuery';
+ _.defaultsDeep(this.annotation.target, this.defaults);
+ }
+}
diff --git a/public/app/plugins/datasource/stackdriver/config_ctrl.ts b/public/app/plugins/datasource/stackdriver/config_ctrl.ts
new file mode 100644
index 00000000000..e74659536c8
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/config_ctrl.ts
@@ -0,0 +1,88 @@
+export class StackdriverConfigCtrl {
+ static templateUrl = 'public/app/plugins/datasource/stackdriver/partials/config.html';
+ datasourceSrv: any;
+ current: any;
+ jsonText: string;
+ validationErrors: string[] = [];
+ inputDataValid: boolean;
+ authenticationTypes: any[];
+ defaultAuthenticationType: string;
+
+ /** @ngInject */
+ constructor(datasourceSrv) {
+ this.defaultAuthenticationType = 'jwt';
+ this.datasourceSrv = datasourceSrv;
+ this.current.jsonData = this.current.jsonData || {};
+ this.current.jsonData.authenticationType = this.current.jsonData.authenticationType
+ ? this.current.jsonData.authenticationType
+ : this.defaultAuthenticationType;
+ this.current.secureJsonData = this.current.secureJsonData || {};
+ this.current.secureJsonFields = this.current.secureJsonFields || {};
+ this.authenticationTypes = [
+ { key: this.defaultAuthenticationType, value: 'Google JWT File' },
+ { key: 'gce', value: 'GCE Default Service Account' },
+ ];
+ }
+
+ save(jwt) {
+ this.current.secureJsonData.privateKey = jwt.private_key;
+ this.current.jsonData.tokenUri = jwt.token_uri;
+ this.current.jsonData.clientEmail = jwt.client_email;
+ this.current.jsonData.defaultProject = jwt.project_id;
+ }
+
+ validateJwt(jwt) {
+ this.resetValidationMessages();
+ if (!jwt.private_key || jwt.private_key.length === 0) {
+ this.validationErrors.push('Private key field missing in JWT file.');
+ }
+
+ if (!jwt.token_uri || jwt.token_uri.length === 0) {
+ this.validationErrors.push('Token URI field missing in JWT file.');
+ }
+
+ if (!jwt.client_email || jwt.client_email.length === 0) {
+ this.validationErrors.push('Client Email field missing in JWT file.');
+ }
+
+ if (!jwt.project_id || jwt.project_id.length === 0) {
+ this.validationErrors.push('Project Id field missing in JWT file.');
+ }
+
+ if (this.validationErrors.length === 0) {
+ this.inputDataValid = true;
+ return true;
+ }
+
+ return false;
+ }
+
+ onUpload(json) {
+ this.jsonText = '';
+ if (this.validateJwt(json)) {
+ this.save(json);
+ }
+ }
+
+ onPasteJwt(e) {
+ try {
+ const json = JSON.parse(e.originalEvent.clipboardData.getData('text/plain') || this.jsonText);
+ if (this.validateJwt(json)) {
+ this.save(json);
+ }
+ } catch (error) {
+ this.resetValidationMessages();
+ this.validationErrors.push(`Invalid json: ${error.message}`);
+ }
+ }
+
+ resetValidationMessages() {
+ this.validationErrors = [];
+ this.inputDataValid = false;
+ this.jsonText = '';
+
+ this.current.jsonData = Object.assign({}, { authenticationType: this.current.jsonData.authenticationType });
+ this.current.secureJsonData = {};
+ this.current.secureJsonFields = {};
+ }
+}
diff --git a/public/app/plugins/datasource/stackdriver/constants.ts b/public/app/plugins/datasource/stackdriver/constants.ts
new file mode 100644
index 00000000000..b11f4a1bcb1
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/constants.ts
@@ -0,0 +1,258 @@
+export enum MetricKind {
+ METRIC_KIND_UNSPECIFIED = 'METRIC_KIND_UNSPECIFIED',
+ GAUGE = 'GAUGE',
+ DELTA = 'DELTA',
+ CUMULATIVE = 'CUMULATIVE',
+}
+
+export enum ValueTypes {
+ VALUE_TYPE_UNSPECIFIED = 'VALUE_TYPE_UNSPECIFIED',
+ BOOL = 'BOOL',
+ INT64 = 'INT64',
+ DOUBLE = 'DOUBLE',
+ STRING = 'STRING',
+ DISTRIBUTION = 'DISTRIBUTION',
+ MONEY = 'MONEY',
+}
+
+export const alignOptions = [
+ {
+ text: 'delta',
+ value: 'ALIGN_DELTA',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.CUMULATIVE, MetricKind.DELTA],
+ },
+ {
+ text: 'rate',
+ value: 'ALIGN_RATE',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.CUMULATIVE, MetricKind.DELTA],
+ },
+ {
+ text: 'interpolate',
+ value: 'ALIGN_INTERPOLATE',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE],
+ },
+ {
+ text: 'next older',
+ value: 'ALIGN_NEXT_OLDER',
+ valueTypes: [
+ ValueTypes.INT64,
+ ValueTypes.DOUBLE,
+ ValueTypes.MONEY,
+ ValueTypes.DISTRIBUTION,
+ ValueTypes.STRING,
+ ValueTypes.VALUE_TYPE_UNSPECIFIED,
+ ValueTypes.BOOL,
+ ],
+ metricKinds: [MetricKind.GAUGE],
+ },
+ {
+ text: 'min',
+ value: 'ALIGN_MIN',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'max',
+ value: 'ALIGN_MAX',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'mean',
+ value: 'ALIGN_MEAN',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'count',
+ value: 'ALIGN_COUNT',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.BOOL],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'sum',
+ value: 'ALIGN_SUM',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'stddev',
+ value: 'ALIGN_STDDEV',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'count true',
+ value: 'ALIGN_COUNT_TRUE',
+ valueTypes: [ValueTypes.BOOL],
+ metricKinds: [MetricKind.GAUGE],
+ },
+ {
+ text: 'count false',
+ value: 'ALIGN_COUNT_FALSE',
+ valueTypes: [ValueTypes.BOOL],
+ metricKinds: [MetricKind.GAUGE],
+ },
+ {
+ text: 'fraction true',
+ value: 'ALIGN_FRACTION_TRUE',
+ valueTypes: [ValueTypes.BOOL],
+ metricKinds: [MetricKind.GAUGE],
+ },
+ {
+ text: 'percentile 99',
+ value: 'ALIGN_PERCENTILE_99',
+ valueTypes: [ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'percentile 95',
+ value: 'ALIGN_PERCENTILE_95',
+ valueTypes: [ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'percentile 50',
+ value: 'ALIGN_PERCENTILE_50',
+ valueTypes: [ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'percentile 05',
+ value: 'ALIGN_PERCENTILE_05',
+ valueTypes: [ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'percent change',
+ value: 'ALIGN_PERCENT_CHANGE',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+];
+
+export const aggOptions = [
+ {
+ text: 'none',
+ value: 'REDUCE_NONE',
+ valueTypes: [
+ ValueTypes.INT64,
+ ValueTypes.DOUBLE,
+ ValueTypes.MONEY,
+ ValueTypes.DISTRIBUTION,
+ ValueTypes.BOOL,
+ ValueTypes.STRING,
+ ],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA, MetricKind.CUMULATIVE, MetricKind.METRIC_KIND_UNSPECIFIED],
+ },
+ {
+ text: 'mean',
+ value: 'REDUCE_MEAN',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'min',
+ value: 'REDUCE_MIN',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'max',
+ value: 'REDUCE_MAX',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'sum',
+ value: 'REDUCE_SUM',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'std. dev.',
+ value: 'REDUCE_STDDEV',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'count',
+ value: 'REDUCE_COUNT',
+ valueTypes: [
+ ValueTypes.INT64,
+ ValueTypes.DOUBLE,
+ ValueTypes.MONEY,
+ ValueTypes.DISTRIBUTION,
+ ValueTypes.BOOL,
+ ValueTypes.STRING,
+ ],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'count true',
+ value: 'REDUCE_COUNT_TRUE',
+ valueTypes: [ValueTypes.BOOL],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: 'count false',
+ value: 'REDUCE_COUNT_FALSE',
+ valueTypes: [ValueTypes.BOOL],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: '99th percentile',
+ value: 'REDUCE_PERCENTILE_99',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: '95th percentile',
+ value: 'REDUCE_PERCENTILE_95',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: '50th percentile',
+ value: 'REDUCE_PERCENTILE_50',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+ {
+ text: '5th percentile',
+ value: 'REDUCE_PERCENTILE_05',
+ valueTypes: [ValueTypes.INT64, ValueTypes.DOUBLE, ValueTypes.MONEY, ValueTypes.DISTRIBUTION],
+ metricKinds: [MetricKind.GAUGE, MetricKind.DELTA],
+ },
+];
+
+export const alignmentPeriods = [
+ { text: 'grafana auto', value: 'grafana-auto' },
+ { text: 'stackdriver auto', value: 'stackdriver-auto' },
+ { text: '1m', value: '+60s' },
+ { text: '5m', value: '+300s' },
+ { text: '30m', value: '+1800s' },
+ { text: '1h', value: '+3600s' },
+ { text: '6h', value: '+21600s' },
+ { text: '1d', value: '+86400s' },
+ { text: '1w', value: '+604800s' },
+];
+
+export const stackdriverUnitMappings = {
+ bit: 'bits',
+ By: 'bytes',
+ s: 's',
+ min: 'm',
+ h: 'h',
+ d: 'd',
+ us: 'µs',
+ ms: 'ms',
+ ns: 'ns',
+ percent: 'percent',
+ MiBy: 'mbytes',
+ 'By/s': 'Bps',
+ GBy: 'decgbytes',
+};
diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts
new file mode 100644
index 00000000000..034333cbb86
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/datasource.ts
@@ -0,0 +1,294 @@
+import { stackdriverUnitMappings } from './constants';
+import appEvents from 'app/core/app_events';
+import _ from 'lodash';
+
+export default class StackdriverDatasource {
+ id: number;
+ url: string;
+ baseUrl: string;
+ projectName: string;
+ authenticationType: string;
+ queryPromise: Promise;
+
+ /** @ngInject */
+ constructor(instanceSettings, private backendSrv, private templateSrv, private timeSrv) {
+ this.baseUrl = `/stackdriver/`;
+ this.url = instanceSettings.url;
+ this.doRequest = this.doRequest;
+ this.id = instanceSettings.id;
+ this.projectName = instanceSettings.jsonData.defaultProject || '';
+ this.authenticationType = instanceSettings.jsonData.authenticationType || 'jwt';
+ }
+
+ async getTimeSeries(options) {
+ const queries = options.targets
+ .filter(target => {
+ return !target.hide && target.metricType;
+ })
+ .map(t => {
+ if (!t.hasOwnProperty('aggregation')) {
+ t.aggregation = {
+ crossSeriesReducer: 'REDUCE_MEAN',
+ groupBys: [],
+ };
+ }
+ return {
+ refId: t.refId,
+ intervalMs: options.intervalMs,
+ datasourceId: this.id,
+ metricType: this.templateSrv.replace(t.metricType, options.scopedVars || {}),
+ primaryAggregation: this.templateSrv.replace(t.aggregation.crossSeriesReducer, options.scopedVars || {}),
+ perSeriesAligner: this.templateSrv.replace(t.aggregation.perSeriesAligner, options.scopedVars || {}),
+ alignmentPeriod: this.templateSrv.replace(t.aggregation.alignmentPeriod, options.scopedVars || {}),
+ groupBys: this.interpolateGroupBys(t.aggregation.groupBys, options.scopedVars),
+ view: t.view || 'FULL',
+ filters: (t.filters || []).map(f => {
+ return this.templateSrv.replace(f, options.scopedVars || {});
+ }),
+ aliasBy: this.templateSrv.replace(t.aliasBy, options.scopedVars || {}),
+ type: 'timeSeriesQuery',
+ };
+ });
+
+ if (queries.length > 0) {
+ const { data } = await this.backendSrv.datasourceRequest({
+ url: '/api/tsdb/query',
+ method: 'POST',
+ data: {
+ from: options.range.from.valueOf().toString(),
+ to: options.range.to.valueOf().toString(),
+ queries,
+ },
+ });
+ return data;
+ } else {
+ return { results: [] };
+ }
+ }
+
+ async getLabels(metricType, refId) {
+ return await this.getTimeSeries({
+ targets: [
+ {
+ refId: refId,
+ datasourceId: this.id,
+ metricType: this.templateSrv.replace(metricType),
+ aggregation: {
+ crossSeriesReducer: 'REDUCE_NONE',
+ },
+ view: 'HEADERS',
+ },
+ ],
+ range: this.timeSrv.timeRange(),
+ });
+ }
+
+ interpolateGroupBys(groupBys: string[], scopedVars): string[] {
+ let interpolatedGroupBys = [];
+ (groupBys || []).forEach(gb => {
+ const interpolated = this.templateSrv.replace(gb, scopedVars || {}, 'csv').split(',');
+ if (Array.isArray(interpolated)) {
+ interpolatedGroupBys = interpolatedGroupBys.concat(interpolated);
+ } else {
+ interpolatedGroupBys.push(interpolated);
+ }
+ });
+ return interpolatedGroupBys;
+ }
+
+ resolvePanelUnitFromTargets(targets: any[]) {
+ let unit;
+ if (targets.length > 0 && targets.every(t => t.unit === targets[0].unit)) {
+ if (stackdriverUnitMappings.hasOwnProperty(targets[0].unit)) {
+ unit = stackdriverUnitMappings[targets[0].unit];
+ }
+ }
+ return unit;
+ }
+
+ async query(options) {
+ const result = [];
+ const data = await this.getTimeSeries(options);
+ if (data.results) {
+ Object['values'](data.results).forEach(queryRes => {
+ if (!queryRes.series) {
+ return;
+ }
+ const unit = this.resolvePanelUnitFromTargets(options.targets);
+ queryRes.series.forEach(series => {
+ let timeSerie: any = {
+ target: series.name,
+ datapoints: series.points,
+ refId: queryRes.refId,
+ meta: queryRes.meta,
+ };
+ if (unit) {
+ timeSerie = { ...timeSerie, unit };
+ }
+ result.push(timeSerie);
+ });
+ });
+ return { data: result };
+ } else {
+ return { data: [] };
+ }
+ }
+
+ async annotationQuery(options) {
+ const annotation = options.annotation;
+ const queries = [
+ {
+ refId: 'annotationQuery',
+ datasourceId: this.id,
+ metricType: this.templateSrv.replace(annotation.target.metricType, options.scopedVars || {}),
+ primaryAggregation: 'REDUCE_NONE',
+ perSeriesAligner: 'ALIGN_NONE',
+ title: this.templateSrv.replace(annotation.target.title, options.scopedVars || {}),
+ text: this.templateSrv.replace(annotation.target.text, options.scopedVars || {}),
+ tags: this.templateSrv.replace(annotation.target.tags, options.scopedVars || {}),
+ view: 'FULL',
+ filters: (annotation.target.filters || []).map(f => {
+ return this.templateSrv.replace(f, options.scopedVars || {});
+ }),
+ type: 'annotationQuery',
+ },
+ ];
+
+ const { data } = await this.backendSrv.datasourceRequest({
+ url: '/api/tsdb/query',
+ method: 'POST',
+ data: {
+ from: options.range.from.valueOf().toString(),
+ to: options.range.to.valueOf().toString(),
+ queries,
+ },
+ });
+
+ const results = data.results['annotationQuery'].tables[0].rows.map(v => {
+ return {
+ annotation: annotation,
+ time: Date.parse(v[0]),
+ title: v[1],
+ tags: [],
+ text: v[3],
+ };
+ });
+
+ return results;
+ }
+
+ metricFindQuery(query) {
+ throw new Error('Template variables support is not yet imlemented');
+ }
+
+ async testDatasource() {
+ let status, message;
+ const defaultErrorMessage = 'Cannot connect to Stackdriver API';
+ try {
+ const projectName = await this.getDefaultProject();
+ const path = `v3/projects/${projectName}/metricDescriptors`;
+ const response = await this.doRequest(`${this.baseUrl}${path}`);
+ if (response.status === 200) {
+ status = 'success';
+ message = 'Successfully queried the Stackdriver API.';
+ } else {
+ status = 'error';
+ message = response.statusText ? response.statusText : defaultErrorMessage;
+ }
+ } catch (error) {
+ status = 'error';
+ if (_.isString(error)) {
+ message = error;
+ } else {
+ message = 'Stackdriver: ';
+ message += error.statusText ? error.statusText : defaultErrorMessage;
+ if (error.data && error.data.error && error.data.error.code) {
+ message += ': ' + error.data.error.code + '. ' + error.data.error.message;
+ }
+ }
+ } finally {
+ return {
+ status,
+ message,
+ };
+ }
+ }
+
+ formatStackdriverError(error) {
+ let message = 'Stackdriver: ';
+ message += error.statusText ? error.statusText + ': ' : '';
+ if (error.data && error.data.error) {
+ try {
+ const res = JSON.parse(error.data.error);
+ message += res.error.code + '. ' + res.error.message;
+ } catch (err) {
+ message += error.data.error;
+ }
+ } else {
+ message += 'Cannot connect to Stackdriver API';
+ }
+ return message;
+ }
+
+ async getDefaultProject() {
+ try {
+ if (this.authenticationType === 'gce' || !this.projectName) {
+ const { data } = await this.backendSrv.datasourceRequest({
+ url: '/api/tsdb/query',
+ method: 'POST',
+ data: {
+ queries: [
+ {
+ refId: 'ensureDefaultProjectQuery',
+ type: 'ensureDefaultProjectQuery',
+ datasourceId: this.id,
+ },
+ ],
+ },
+ });
+ this.projectName = data.results.ensureDefaultProjectQuery.meta.defaultProject;
+ return this.projectName;
+ } else {
+ return this.projectName;
+ }
+ } catch (error) {
+ throw this.formatStackdriverError(error);
+ }
+ }
+
+ async getMetricTypes(projectName: string) {
+ try {
+ const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`;
+ const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`);
+
+ const metrics = data.metricDescriptors.map(m => {
+ const [service] = m.type.split('/');
+ const [serviceShortName] = service.split('.');
+ m.service = service;
+ m.serviceShortName = serviceShortName;
+ m.displayName = m.displayName || m.type;
+ return m;
+ });
+
+ return metrics;
+ } catch (error) {
+ appEvents.emit('ds-request-error', this.formatStackdriverError(error));
+ return [];
+ }
+ }
+
+ async doRequest(url, maxRetries = 1) {
+ return this.backendSrv
+ .datasourceRequest({
+ url: this.url + url,
+ method: 'GET',
+ })
+ .catch(error => {
+ if (maxRetries > 0) {
+ return this.doRequest(url, maxRetries - 1);
+ }
+
+ throw error;
+ });
+ }
+}
diff --git a/public/app/plugins/datasource/stackdriver/filter_segments.ts b/public/app/plugins/datasource/stackdriver/filter_segments.ts
new file mode 100644
index 00000000000..5adb56e2fcf
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/filter_segments.ts
@@ -0,0 +1,116 @@
+export const DefaultRemoveFilterValue = '-- remove filter --';
+export const DefaultFilterValue = 'select value';
+
+export class FilterSegments {
+ filterSegments: any[];
+ removeSegment: any;
+
+ constructor(private uiSegmentSrv, private target, private getFilterKeysFunc, private getFilterValuesFunc) {}
+
+ buildSegmentModel() {
+ this.removeSegment = this.uiSegmentSrv.newSegment({ fake: true, value: DefaultRemoveFilterValue });
+
+ this.filterSegments = [];
+ this.target.filters.forEach((f, index) => {
+ switch (index % 4) {
+ case 0:
+ this.filterSegments.push(this.uiSegmentSrv.newKey(f));
+ break;
+ case 1:
+ this.filterSegments.push(this.uiSegmentSrv.newOperator(f));
+ break;
+ case 2:
+ this.filterSegments.push(this.uiSegmentSrv.newKeyValue(f));
+ break;
+ case 3:
+ this.filterSegments.push(this.uiSegmentSrv.newCondition(f));
+ break;
+ }
+ });
+ this.ensurePlusButton(this.filterSegments);
+ }
+
+ async getFilters(segment, index, hasNoFilterKeys) {
+ if (segment.type === 'condition') {
+ return [this.uiSegmentSrv.newSegment('AND')];
+ }
+
+ if (segment.type === 'operator') {
+ return this.uiSegmentSrv.newOperators(['=', '!=', '=~', '!=~']);
+ }
+
+ if (segment.type === 'key' || segment.type === 'plus-button') {
+ if (hasNoFilterKeys && segment.value && segment.value !== DefaultRemoveFilterValue) {
+ this.removeSegment.value = DefaultRemoveFilterValue;
+ return Promise.resolve([this.removeSegment]);
+ } else {
+ return this.getFilterKeysFunc(segment, DefaultRemoveFilterValue);
+ }
+ }
+
+ if (segment.type === 'value') {
+ const filterValues = this.getFilterValuesFunc(index);
+
+ if (filterValues.length > 0) {
+ return this.getValuesForFilterKey(filterValues);
+ }
+ }
+
+ return [];
+ }
+
+ getValuesForFilterKey(labels: any[]) {
+ const filterValues = labels.map(l => {
+ return this.uiSegmentSrv.newSegment({
+ value: `${l}`,
+ expandable: false,
+ });
+ });
+
+ return filterValues;
+ }
+
+ addNewFilterSegments(segment, index) {
+ if (index > 2) {
+ this.filterSegments.splice(index, 0, this.uiSegmentSrv.newCondition('AND'));
+ }
+ segment.type = 'key';
+ this.filterSegments.push(this.uiSegmentSrv.newOperator('='));
+ this.filterSegments.push(this.uiSegmentSrv.newFake(DefaultFilterValue, 'value', 'query-segment-value'));
+ }
+
+ removeFilterSegment(index) {
+ this.filterSegments.splice(index, 3);
+ // remove trailing condition
+ if (index > 2 && this.filterSegments[index - 1].type === 'condition') {
+ this.filterSegments.splice(index - 1, 1);
+ }
+
+ // remove condition if it is first segment
+ if (index === 0 && this.filterSegments.length > 0 && this.filterSegments[0].type === 'condition') {
+ this.filterSegments.splice(0, 1);
+ }
+ }
+
+ ensurePlusButton(segments) {
+ const count = segments.length;
+ const lastSegment = segments[Math.max(count - 1, 0)];
+
+ if (!lastSegment || lastSegment.type !== 'plus-button') {
+ segments.push(this.uiSegmentSrv.newPlusButton());
+ }
+ }
+
+ filterSegmentUpdated(segment, index) {
+ if (segment.type === 'plus-button') {
+ this.addNewFilterSegments(segment, index);
+ } else if (segment.type === 'key' && segment.value === DefaultRemoveFilterValue) {
+ this.removeFilterSegment(index);
+ this.ensurePlusButton(this.filterSegments);
+ } else if (segment.type === 'value' && segment.value !== DefaultFilterValue) {
+ this.ensurePlusButton(this.filterSegments);
+ }
+
+ return this.filterSegments.filter(s => s.type !== 'plus-button').map(seg => seg.value);
+ }
+}
diff --git a/public/app/plugins/datasource/stackdriver/img/stackdriver_logo.png b/public/app/plugins/datasource/stackdriver/img/stackdriver_logo.png
new file mode 100644
index 00000000000..cd52e773deb
Binary files /dev/null and b/public/app/plugins/datasource/stackdriver/img/stackdriver_logo.png differ
diff --git a/public/app/plugins/datasource/stackdriver/module.ts b/public/app/plugins/datasource/stackdriver/module.ts
new file mode 100644
index 00000000000..183c5c9ff88
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/module.ts
@@ -0,0 +1,11 @@
+import StackdriverDatasource from './datasource';
+import { StackdriverQueryCtrl } from './query_ctrl';
+import { StackdriverConfigCtrl } from './config_ctrl';
+import { StackdriverAnnotationsQueryCtrl } from './annotations_query_ctrl';
+
+export {
+ StackdriverDatasource as Datasource,
+ StackdriverQueryCtrl as QueryCtrl,
+ StackdriverConfigCtrl as ConfigCtrl,
+ StackdriverAnnotationsQueryCtrl as AnnotationsQueryCtrl,
+};
diff --git a/public/app/plugins/datasource/stackdriver/partials/annotations.editor.html b/public/app/plugins/datasource/stackdriver/partials/annotations.editor.html
new file mode 100644
index 00000000000..0c2ce32f894
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/partials/annotations.editor.html
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/public/app/plugins/datasource/stackdriver/partials/config.html b/public/app/plugins/datasource/stackdriver/partials/config.html
new file mode 100644
index 00000000000..9be7eef0e68
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/partials/config.html
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+ Do not forget to save your changes after uploading a file.
+
+
+ Verify GCE default service account by clicking Save & Test
diff --git a/public/app/plugins/datasource/stackdriver/partials/query.aggregation.html b/public/app/plugins/datasource/stackdriver/partials/query.aggregation.html
new file mode 100755
index 00000000000..379b9a36dc3
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/partials/query.aggregation.html
@@ -0,0 +1,46 @@
+
+
+
\ No newline at end of file
diff --git a/public/app/plugins/datasource/stackdriver/partials/query.editor.html b/public/app/plugins/datasource/stackdriver/partials/query.editor.html
new file mode 100755
index 00000000000..98c8fcc83e8
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/partials/query.editor.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/public/app/plugins/datasource/stackdriver/partials/query.filter.html b/public/app/plugins/datasource/stackdriver/partials/query.filter.html
new file mode 100644
index 00000000000..5043161c492
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/partials/query.filter.html
@@ -0,0 +1,37 @@
+
+
+
diff --git a/public/app/plugins/datasource/stackdriver/plugin.json b/public/app/plugins/datasource/stackdriver/plugin.json
new file mode 100644
index 00000000000..2ec44ae4ca5
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/plugin.json
@@ -0,0 +1,53 @@
+{
+ "name": "Stackdriver",
+ "type": "datasource",
+ "id": "stackdriver",
+ "metrics": true,
+ "alerting": true,
+ "annotations": true,
+ "state": "beta",
+ "queryOptions": {
+ "maxDataPoints": true,
+ "cacheTimeout": true
+ },
+ "info": {
+ "description": "Google Stackdriver Datasource for Grafana",
+ "version": "1.0.0",
+ "logos": {
+ "small": "img/stackdriver_logo.png",
+ "large": "img/stackdriver_logo.png"
+ },
+ "author": {
+ "name": "Grafana Project",
+ "url": "https://grafana.com"
+ }
+ },
+ "routes": [
+ {
+ "path": "stackdriver",
+ "method": "GET",
+ "url": "https://content-monitoring.googleapis.com",
+ "jwtTokenAuth": {
+ "scopes": ["https://www.googleapis.com/auth/monitoring.read"],
+ "params": {
+ "token_uri": "{{.JsonData.tokenUri}}",
+ "client_email": "{{.JsonData.clientEmail}}",
+ "private_key": "{{.SecureJsonData.privateKey}}"
+ }
+ }
+ },
+ {
+ "path": "cloudresourcemanager",
+ "method": "GET",
+ "url": "https://cloudresourcemanager.googleapis.com",
+ "jwtTokenAuth": {
+ "scopes": ["https://www.googleapis.com/auth/cloudplatformprojects.readonly"],
+ "params": {
+ "token_uri": "{{.JsonData.tokenUri}}",
+ "client_email": "{{.JsonData.clientEmail}}",
+ "private_key": "{{.SecureJsonData.privateKey}}"
+ }
+ }
+ }
+ ]
+}
diff --git a/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts
new file mode 100644
index 00000000000..6cd6c805463
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts
@@ -0,0 +1,87 @@
+import coreModule from 'app/core/core_module';
+import _ from 'lodash';
+import * as options from './constants';
+import kbn from 'app/core/utils/kbn';
+
+export class StackdriverAggregation {
+ constructor() {
+ return {
+ templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.aggregation.html',
+ controller: 'StackdriverAggregationCtrl',
+ restrict: 'E',
+ scope: {
+ target: '=',
+ alignmentPeriod: '<',
+ refresh: '&',
+ },
+ };
+ }
+}
+
+export class StackdriverAggregationCtrl {
+ alignmentPeriods: any[];
+ aggOptions: any[];
+ alignOptions: any[];
+ target: any;
+
+ /** @ngInject */
+ constructor(private $scope) {
+ this.$scope.ctrl = this;
+ this.target = $scope.target;
+ this.alignmentPeriods = options.alignmentPeriods;
+ this.aggOptions = options.aggOptions;
+ this.alignOptions = options.alignOptions;
+ this.setAggOptions();
+ this.setAlignOptions();
+ const self = this;
+ $scope.$on('metricTypeChanged', () => {
+ self.setAggOptions();
+ self.setAlignOptions();
+ });
+ }
+
+ setAlignOptions() {
+ this.alignOptions = !this.target.valueType
+ ? []
+ : options.alignOptions.filter(i => {
+ return (
+ i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
+ );
+ });
+ if (!this.alignOptions.find(o => o.value === this.target.aggregation.perSeriesAligner)) {
+ this.target.aggregation.perSeriesAligner = this.alignOptions.length > 0 ? this.alignOptions[0].value : '';
+ }
+ }
+
+ setAggOptions() {
+ this.aggOptions = !this.target.metricKind
+ ? []
+ : options.aggOptions.filter(i => {
+ return (
+ i.valueTypes.indexOf(this.target.valueType) !== -1 && i.metricKinds.indexOf(this.target.metricKind) !== -1
+ );
+ });
+
+ if (!this.aggOptions.find(o => o.value === this.target.aggregation.crossSeriesReducer)) {
+ this.deselectAggregationOption('REDUCE_NONE');
+ }
+
+ if (this.target.aggregation.groupBys.length > 0) {
+ this.aggOptions = this.aggOptions.filter(o => o.value !== 'REDUCE_NONE');
+ this.deselectAggregationOption('REDUCE_NONE');
+ }
+ }
+
+ formatAlignmentText() {
+ const selectedAlignment = this.alignOptions.find(ap => ap.value === this.target.aggregation.perSeriesAligner);
+ return `${kbn.secondsToHms(this.$scope.alignmentPeriod)} interval (${selectedAlignment.text})`;
+ }
+
+ deselectAggregationOption(notValidOptionValue: string) {
+ const newValue = this.aggOptions.find(o => o.value !== notValidOptionValue);
+ this.target.aggregation.crossSeriesReducer = newValue ? newValue.value : '';
+ }
+}
+
+coreModule.directive('stackdriverAggregation', StackdriverAggregation);
+coreModule.controller('StackdriverAggregationCtrl', StackdriverAggregationCtrl);
diff --git a/public/app/plugins/datasource/stackdriver/query_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_ctrl.ts
new file mode 100644
index 00000000000..3a1961eb14e
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/query_ctrl.ts
@@ -0,0 +1,99 @@
+import _ from 'lodash';
+import { QueryCtrl } from 'app/plugins/sdk';
+import './query_aggregation_ctrl';
+import './query_filter_ctrl';
+
+export interface QueryMeta {
+ alignmentPeriod: string;
+ rawQuery: string;
+ rawQueryString: string;
+ metricLabels: { [key: string]: string[] };
+ resourceLabels: { [key: string]: string[] };
+}
+
+export class StackdriverQueryCtrl extends QueryCtrl {
+ static templateUrl = 'partials/query.editor.html';
+ target: {
+ defaultProject: string;
+ unit: string;
+ metricType: string;
+ service: string;
+ refId: string;
+ aggregation: {
+ crossSeriesReducer: string;
+ alignmentPeriod: string;
+ perSeriesAligner: string;
+ groupBys: string[];
+ };
+ filters: string[];
+ aliasBy: string;
+ metricKind: any;
+ valueType: any;
+ };
+
+ defaultDropdownValue = 'Select Metric';
+ defaultServiceValue = 'All Services';
+
+ defaults = {
+ defaultProject: 'loading project...',
+ metricType: this.defaultDropdownValue,
+ service: this.defaultServiceValue,
+ metric: '',
+ unit: '',
+ aggregation: {
+ crossSeriesReducer: 'REDUCE_MEAN',
+ alignmentPeriod: 'stackdriver-auto',
+ perSeriesAligner: 'ALIGN_MEAN',
+ groupBys: [],
+ },
+ filters: [],
+ showAggregationOptions: false,
+ aliasBy: '',
+ metricKind: '',
+ valueType: '',
+ };
+
+ showHelp: boolean;
+ showLastQuery: boolean;
+ lastQueryMeta: QueryMeta;
+ lastQueryError?: string;
+
+ /** @ngInject */
+ constructor($scope, $injector) {
+ super($scope, $injector);
+ _.defaultsDeep(this.target, this.defaults);
+
+ this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
+ this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
+ }
+
+ onDataReceived(dataList) {
+ this.lastQueryError = null;
+ this.lastQueryMeta = null;
+
+ const anySeriesFromQuery: any = _.find(dataList, { refId: this.target.refId });
+ if (anySeriesFromQuery) {
+ this.lastQueryMeta = anySeriesFromQuery.meta;
+ this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
+ }
+ }
+
+ onDataError(err) {
+ if (err.data && err.data.results) {
+ const queryRes = err.data.results[this.target.refId];
+ if (queryRes && queryRes.error) {
+ this.lastQueryMeta = queryRes.meta;
+ this.lastQueryMeta.rawQueryString = decodeURIComponent(this.lastQueryMeta.rawQuery);
+
+ let jsonBody;
+ try {
+ jsonBody = JSON.parse(queryRes.error);
+ } catch {
+ this.lastQueryError = queryRes.error;
+ }
+
+ this.lastQueryError = jsonBody.error.message;
+ }
+ }
+ }
+}
diff --git a/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts
new file mode 100644
index 00000000000..4c383e5d09e
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts
@@ -0,0 +1,323 @@
+import coreModule from 'app/core/core_module';
+import _ from 'lodash';
+import { FilterSegments } from './filter_segments';
+import appEvents from 'app/core/app_events';
+
+export class StackdriverFilter {
+ /** @ngInject */
+ constructor() {
+ return {
+ templateUrl: 'public/app/plugins/datasource/stackdriver/partials/query.filter.html',
+ controller: 'StackdriverFilterCtrl',
+ controllerAs: 'ctrl',
+ restrict: 'E',
+ scope: {
+ target: '=',
+ datasource: '=',
+ refresh: '&',
+ defaultDropdownValue: '<',
+ defaultServiceValue: '<',
+ hideGroupBys: '<',
+ },
+ };
+ }
+}
+
+export class StackdriverFilterCtrl {
+ metricLabels: { [key: string]: string[] };
+ resourceLabels: { [key: string]: string[] };
+ resourceTypes: string[];
+
+ defaultRemoveGroupByValue = '-- remove group by --';
+ resourceTypeValue = 'resource.type';
+ loadLabelsPromise: Promise;
+
+ service: string;
+ metricType: string;
+ metricDescriptors: any[];
+ metrics: any[];
+ services: any[];
+ groupBySegments: any[];
+ filterSegments: FilterSegments;
+ removeSegment: any;
+ target: any;
+ datasource: any;
+
+ /** @ngInject */
+ constructor(private $scope, private uiSegmentSrv, private templateSrv, private $rootScope) {
+ this.datasource = $scope.datasource;
+ this.target = $scope.target;
+ this.metricType = $scope.defaultDropdownValue;
+ this.service = $scope.defaultServiceValue;
+
+ this.metricDescriptors = [];
+ this.metrics = [];
+ this.services = [];
+
+ this.getCurrentProject()
+ .then(this.loadMetricDescriptors.bind(this))
+ .then(this.getLabels.bind(this));
+
+ this.initSegments($scope.hideGroupBys);
+ }
+
+ initSegments(hideGroupBys: boolean) {
+ if (!hideGroupBys) {
+ this.groupBySegments = this.target.aggregation.groupBys.map(groupBy => {
+ return this.uiSegmentSrv.getSegmentForValue(groupBy);
+ });
+ this.ensurePlusButton(this.groupBySegments);
+ }
+
+ this.removeSegment = this.uiSegmentSrv.newSegment({ fake: true, value: '-- remove group by --' });
+
+ this.filterSegments = new FilterSegments(
+ this.uiSegmentSrv,
+ this.target,
+ this.getFilterKeys.bind(this),
+ this.getFilterValues.bind(this)
+ );
+ this.filterSegments.buildSegmentModel();
+ }
+
+ async getCurrentProject() {
+ return new Promise(async (resolve, reject) => {
+ try {
+ if (!this.target.defaultProject || this.target.defaultProject === 'loading project...') {
+ this.target.defaultProject = await this.datasource.getDefaultProject();
+ }
+ resolve(this.target.defaultProject);
+ } catch (error) {
+ appEvents.emit('ds-request-error', error);
+ reject();
+ }
+ });
+ }
+
+ async loadMetricDescriptors() {
+ if (this.target.defaultProject !== 'loading project...') {
+ this.metricDescriptors = await this.datasource.getMetricTypes(this.target.defaultProject);
+ this.services = this.getServicesList();
+ this.metrics = this.getMetricsList();
+ return this.metricDescriptors;
+ } else {
+ return [];
+ }
+ }
+
+ getServicesList() {
+ const defaultValue = { value: this.$scope.defaultServiceValue, text: this.$scope.defaultServiceValue };
+ const services = this.metricDescriptors.map(m => {
+ return {
+ value: m.service,
+ text: m.serviceShortName,
+ };
+ });
+
+ if (services.find(m => m.value === this.target.service)) {
+ this.service = this.target.service;
+ }
+
+ return services.length > 0 ? [defaultValue, ..._.uniqBy(services, 'value')] : [];
+ }
+
+ getMetricsList() {
+ const metrics = this.metricDescriptors.map(m => {
+ return {
+ service: m.service,
+ value: m.type,
+ serviceShortName: m.serviceShortName,
+ text: m.displayName,
+ title: m.description,
+ };
+ });
+
+ let result;
+ if (this.target.service === this.$scope.defaultServiceValue) {
+ result = metrics.map(m => ({ ...m, text: `${m.service} - ${m.text}` }));
+ } else {
+ result = metrics.filter(m => m.service === this.target.service);
+ }
+
+ if (result.find(m => m.value === this.target.metricType)) {
+ this.metricType = this.target.metricType;
+ } else if (result.length > 0) {
+ this.metricType = this.target.metricType = result[0].value;
+ }
+ return result;
+ }
+
+ async getLabels() {
+ this.loadLabelsPromise = new Promise(async resolve => {
+ try {
+ const data = await this.datasource.getLabels(this.target.metricType, this.target.refId);
+ this.metricLabels = data.results[this.target.refId].meta.metricLabels;
+ this.resourceLabels = data.results[this.target.refId].meta.resourceLabels;
+ this.resourceTypes = data.results[this.target.refId].meta.resourceTypes;
+ resolve();
+ } catch (error) {
+ if (error.data && error.data.message) {
+ console.log(error.data.message);
+ } else {
+ console.log(error);
+ }
+ appEvents.emit('alert-error', ['Error', 'Error loading metric labels for ' + this.target.metricType]);
+ resolve();
+ }
+ });
+ }
+
+ onServiceChange() {
+ this.target.service = this.service;
+ this.metrics = this.getMetricsList();
+ this.setMetricType();
+ this.getLabels();
+ if (!this.metrics.find(m => m.value === this.target.metricType)) {
+ this.target.metricType = this.$scope.defaultDropdownValue;
+ } else {
+ this.$scope.refresh();
+ }
+ }
+
+ async onMetricTypeChange() {
+ this.setMetricType();
+ this.$scope.refresh();
+ this.getLabels();
+ }
+
+ setMetricType() {
+ this.target.metricType = this.metricType;
+ const { valueType, metricKind, unit } = this.metricDescriptors.find(m => m.type === this.target.metricType);
+ this.target.unit = unit;
+ this.target.valueType = valueType;
+ this.target.metricKind = metricKind;
+ this.$rootScope.$broadcast('metricTypeChanged');
+ }
+
+ async createLabelKeyElements() {
+ await this.loadLabelsPromise;
+
+ let elements = Object.keys(this.metricLabels || {}).map(l => {
+ return this.uiSegmentSrv.newSegment({
+ value: `metric.label.${l}`,
+ expandable: false,
+ });
+ });
+
+ elements = [
+ ...elements,
+ ...Object.keys(this.resourceLabels || {}).map(l => {
+ return this.uiSegmentSrv.newSegment({
+ value: `resource.label.${l}`,
+ expandable: false,
+ });
+ }),
+ ];
+
+ if (this.resourceTypes && this.resourceTypes.length > 0) {
+ elements = [
+ ...elements,
+ this.uiSegmentSrv.newSegment({
+ value: this.resourceTypeValue,
+ expandable: false,
+ }),
+ ];
+ }
+
+ return elements;
+ }
+
+ async getFilterKeys(segment, removeText?: string) {
+ let elements = await this.createLabelKeyElements();
+
+ if (this.target.filters.indexOf(this.resourceTypeValue) !== -1) {
+ elements = elements.filter(e => e.value !== this.resourceTypeValue);
+ }
+
+ const noValueOrPlusButton = !segment || segment.type === 'plus-button';
+ if (noValueOrPlusButton && elements.length === 0) {
+ return [];
+ }
+
+ this.removeSegment.value = removeText;
+ return [...elements, this.removeSegment];
+ }
+
+ async getGroupBys(segment) {
+ let elements = await this.createLabelKeyElements();
+
+ elements = elements.filter(e => this.target.aggregation.groupBys.indexOf(e.value) === -1);
+ const noValueOrPlusButton = !segment || segment.type === 'plus-button';
+ if (noValueOrPlusButton && elements.length === 0) {
+ return [];
+ }
+
+ this.removeSegment.value = this.defaultRemoveGroupByValue;
+ return [...elements, this.removeSegment];
+ }
+
+ groupByChanged(segment, index) {
+ if (segment.value === this.removeSegment.value) {
+ this.groupBySegments.splice(index, 1);
+ } else {
+ segment.type = 'value';
+ }
+
+ const reducer = (memo, seg) => {
+ if (!seg.fake) {
+ memo.push(seg.value);
+ }
+ return memo;
+ };
+
+ this.target.aggregation.groupBys = this.groupBySegments.reduce(reducer, []);
+ this.ensurePlusButton(this.groupBySegments);
+ this.$rootScope.$broadcast('metricTypeChanged');
+ this.$scope.refresh();
+ }
+
+ async getFilters(segment, index) {
+ const hasNoFilterKeys = this.metricLabels && Object.keys(this.metricLabels).length === 0;
+ return this.filterSegments.getFilters(segment, index, hasNoFilterKeys);
+ }
+
+ getFilterValues(index) {
+ const filterKey = this.templateSrv.replace(this.filterSegments.filterSegments[index - 2].value);
+ if (!filterKey || !this.metricLabels || Object.keys(this.metricLabels).length === 0) {
+ return [];
+ }
+
+ const shortKey = filterKey.substring(filterKey.indexOf('.label.') + 7);
+
+ if (filterKey.startsWith('metric.label.') && this.metricLabels.hasOwnProperty(shortKey)) {
+ return this.metricLabels[shortKey];
+ }
+
+ if (filterKey.startsWith('resource.label.') && this.resourceLabels.hasOwnProperty(shortKey)) {
+ return this.resourceLabels[shortKey];
+ }
+
+ if (filterKey === this.resourceTypeValue) {
+ return this.resourceTypes;
+ }
+
+ return [];
+ }
+
+ filterSegmentUpdated(segment, index) {
+ this.target.filters = this.filterSegments.filterSegmentUpdated(segment, index);
+ this.$scope.refresh();
+ }
+
+ ensurePlusButton(segments) {
+ const count = segments.length;
+ const lastSegment = segments[Math.max(count - 1, 0)];
+
+ if (!lastSegment || lastSegment.type !== 'plus-button') {
+ segments.push(this.uiSegmentSrv.newPlusButton());
+ }
+ }
+}
+
+coreModule.directive('stackdriverFilter', StackdriverFilter);
+coreModule.controller('StackdriverFilterCtrl', StackdriverFilterCtrl);
diff --git a/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts
new file mode 100644
index 00000000000..d0d8462e506
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts
@@ -0,0 +1,244 @@
+import StackdriverDataSource from '../datasource';
+import { metricDescriptors } from './testData';
+import moment from 'moment';
+import { TemplateSrvStub } from 'test/specs/helpers';
+
+describe('StackdriverDataSource', () => {
+ const instanceSettings = {
+ jsonData: {
+ defaultProject: 'testproject',
+ },
+ };
+ const templateSrv = new TemplateSrvStub();
+ const timeSrv = {};
+
+ describe('when performing testDataSource', () => {
+ describe('and call to stackdriver api succeeds', () => {
+ let ds;
+ let result;
+ beforeEach(async () => {
+ const backendSrv = {
+ async datasourceRequest() {
+ return Promise.resolve({ status: 200 });
+ },
+ };
+ ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+ result = await ds.testDatasource();
+ });
+ it('should return successfully', () => {
+ expect(result.status).toBe('success');
+ });
+ });
+
+ describe('and a list of metricDescriptors are returned', () => {
+ let ds;
+ let result;
+ beforeEach(async () => {
+ const backendSrv = {
+ datasourceRequest: async () => Promise.resolve({ status: 200, data: metricDescriptors }),
+ };
+ ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+ result = await ds.testDatasource();
+ });
+ it('should return status success', () => {
+ expect(result.status).toBe('success');
+ });
+ });
+
+ describe('and call to stackdriver api fails with 400 error', () => {
+ let ds;
+ let result;
+ beforeEach(async () => {
+ const backendSrv = {
+ datasourceRequest: async () =>
+ Promise.reject({
+ statusText: 'Bad Request',
+ data: {
+ error: { code: 400, message: 'Field interval.endTime had an invalid value' },
+ },
+ }),
+ };
+ ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+ result = await ds.testDatasource();
+ });
+
+ it('should return error status and a detailed error message', () => {
+ expect(result.status).toEqual('error');
+ expect(result.message).toBe('Stackdriver: Bad Request: 400. Field interval.endTime had an invalid value');
+ });
+ });
+ });
+
+ describe('When performing query', () => {
+ const options = {
+ range: {
+ from: moment.utc('2017-08-22T20:00:00Z'),
+ to: moment.utc('2017-08-22T23:59:00Z'),
+ },
+ rangeRaw: {
+ from: 'now-4h',
+ to: 'now',
+ },
+ targets: [
+ {
+ refId: 'A',
+ aggregation: {},
+ },
+ ],
+ };
+
+ describe('and no time series data is returned', () => {
+ let ds;
+ const response = {
+ results: {
+ A: {
+ refId: 'A',
+ meta: {
+ rawQuery: 'arawquerystring',
+ },
+ series: null,
+ tables: null,
+ },
+ },
+ };
+
+ beforeEach(() => {
+ const backendSrv = {
+ datasourceRequest: async () => Promise.resolve({ status: 200, data: response }),
+ };
+ ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+ });
+
+ it('should return a list of datapoints', () => {
+ return ds.query(options).then(results => {
+ expect(results.data.length).toBe(0);
+ });
+ });
+ });
+ });
+
+ describe('when performing getMetricTypes', () => {
+ describe('and call to stackdriver api succeeds', () => {});
+ let ds;
+ let result;
+ beforeEach(async () => {
+ const backendSrv = {
+ async datasourceRequest() {
+ return Promise.resolve({
+ data: {
+ metricDescriptors: [
+ {
+ displayName: 'test metric name 1',
+ type: 'compute.googleapis.com/instance/cpu/test-metric-type-1',
+ description: 'A description',
+ },
+ {
+ type: 'logging.googleapis.com/user/logbased-metric-with-no-display-name',
+ },
+ ],
+ },
+ });
+ },
+ };
+ ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv);
+ result = await ds.getMetricTypes();
+ });
+ it('should return successfully', () => {
+ expect(result.length).toBe(2);
+ expect(result[0].service).toBe('compute.googleapis.com');
+ expect(result[0].serviceShortName).toBe('compute');
+ expect(result[0].type).toBe('compute.googleapis.com/instance/cpu/test-metric-type-1');
+ expect(result[0].displayName).toBe('test metric name 1');
+ expect(result[0].description).toBe('A description');
+ expect(result[1].type).toBe('logging.googleapis.com/user/logbased-metric-with-no-display-name');
+ expect(result[1].displayName).toBe('logging.googleapis.com/user/logbased-metric-with-no-display-name');
+ });
+ });
+
+ describe('when interpolating a template variable for group bys', () => {
+ let interpolated;
+
+ describe('and is single value variable', () => {
+ beforeEach(() => {
+ templateSrv.data = {
+ test: 'groupby1',
+ };
+ const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+ interpolated = ds.interpolateGroupBys(['[[test]]'], {});
+ });
+
+ it('should replace the variable with the value', () => {
+ expect(interpolated.length).toBe(1);
+ expect(interpolated[0]).toBe('groupby1');
+ });
+ });
+
+ describe('and is multi value variable', () => {
+ beforeEach(() => {
+ templateSrv.data = {
+ test: 'groupby1,groupby2',
+ };
+ const ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+ interpolated = ds.interpolateGroupBys(['[[test]]'], {});
+ });
+
+ it('should replace the variable with an array of group bys', () => {
+ expect(interpolated.length).toBe(2);
+ expect(interpolated[0]).toBe('groupby1');
+ expect(interpolated[1]).toBe('groupby2');
+ });
+ });
+ });
+
+ describe('unit parsing', () => {
+ let ds, res;
+ beforeEach(() => {
+ ds = new StackdriverDataSource(instanceSettings, {}, templateSrv, timeSrv);
+ });
+ describe('when theres only one target', () => {
+ describe('and the stackdriver unit doesnt have a corresponding grafana unit', () => {
+ beforeEach(() => {
+ res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }]);
+ });
+ it('should return undefined', () => {
+ expect(res).toBeUndefined();
+ });
+ });
+ describe('and the stackdriver unit has a corresponding grafana unit', () => {
+ beforeEach(() => {
+ res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }]);
+ });
+ it('should return bits', () => {
+ expect(res).toEqual('bits');
+ });
+ });
+ });
+
+ describe('when theres more than one target', () => {
+ describe('and all target units are the same', () => {
+ beforeEach(() => {
+ res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'bit' }]);
+ });
+ it('should return bits', () => {
+ expect(res).toEqual('bits');
+ });
+ });
+ describe('and all target units are the same but doesnt have grafana mappings', () => {
+ beforeEach(() => {
+ res = ds.resolvePanelUnitFromTargets([{ unit: 'megaseconds' }, { unit: 'megaseconds' }]);
+ });
+ it('should return the default value of undefined', () => {
+ expect(res).toBeUndefined();
+ });
+ });
+ describe('and all target units are not the same', () => {
+ beforeEach(() => {
+ res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'min' }]);
+ });
+ it('should return the default value of undefined', () => {
+ expect(res).toBeUndefined();
+ });
+ });
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts b/public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts
new file mode 100644
index 00000000000..ac9ea2ac6bc
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/specs/query_aggregation_ctrl.test.ts
@@ -0,0 +1,60 @@
+import { StackdriverAggregationCtrl } from '../query_aggregation_ctrl';
+
+describe('StackdriverAggregationCtrl', () => {
+ let ctrl;
+ describe('aggregation and alignment options', () => {
+ describe('when new query result is returned from the server', () => {
+ describe('and result is double and gauge and no group by is used', () => {
+ beforeEach(async () => {
+ ctrl = new StackdriverAggregationCtrl({
+ $on: () => {},
+ target: { valueType: 'DOUBLE', metricKind: 'GAUGE', aggregation: { crossSeriesReducer: '', groupBys: [] } },
+ });
+ });
+
+ it('should populate all aggregate options except two', () => {
+ ctrl.setAggOptions();
+ expect(ctrl.aggOptions.length).toBe(11);
+ expect(ctrl.aggOptions.map(o => o.value)).toEqual(
+ expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
+ );
+ });
+
+ it('should populate all alignment options except two', () => {
+ ctrl.setAlignOptions();
+ expect(ctrl.alignOptions.length).toBe(9);
+ expect(ctrl.alignOptions.map(o => o.value)).toEqual(
+ expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
+ );
+ });
+ });
+
+ describe('and result is double and gauge and a group by is used', () => {
+ beforeEach(async () => {
+ ctrl = new StackdriverAggregationCtrl({
+ $on: () => {},
+ target: {
+ valueType: 'DOUBLE',
+ metricKind: 'GAUGE',
+ aggregation: { crossSeriesReducer: 'REDUCE_NONE', groupBys: ['resource.label.projectid'] },
+ },
+ });
+ });
+
+ it('should populate all aggregate options except three', () => {
+ ctrl.setAggOptions();
+ expect(ctrl.aggOptions.length).toBe(10);
+ expect(ctrl.aggOptions.map(o => o.value)).toEqual(
+ expect['not'].arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE', 'REDUCE_NONE'])
+ );
+ });
+
+ it('should select some other reducer than REDUCE_NONE', () => {
+ ctrl.setAggOptions();
+ expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('');
+ expect(ctrl.target.aggregation.crossSeriesReducer).not.toBe('REDUCE_NONE');
+ });
+ });
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/stackdriver/specs/query_filter_ctrl.test.ts b/public/app/plugins/datasource/stackdriver/specs/query_filter_ctrl.test.ts
new file mode 100644
index 00000000000..020db584508
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/specs/query_filter_ctrl.test.ts
@@ -0,0 +1,442 @@
+import { StackdriverFilterCtrl } from '../query_filter_ctrl';
+import { TemplateSrvStub } from 'test/specs/helpers';
+import { DefaultRemoveFilterValue, DefaultFilterValue } from '../filter_segments';
+
+describe('StackdriverQueryFilterCtrl', () => {
+ let ctrl;
+ let result;
+
+ describe('when initializing query editor', () => {
+ beforeEach(() => {
+ const existingFilters = ['key1', '=', 'val1', 'AND', 'key2', '=', 'val2'];
+ ctrl = createCtrlWithFakes(existingFilters);
+ });
+
+ it('should initialize filter segments using the target filter values', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(8);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ expect(ctrl.filterSegments.filterSegments[3].type).toBe('condition');
+ expect(ctrl.filterSegments.filterSegments[4].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[5].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[6].type).toBe('value');
+ expect(ctrl.filterSegments.filterSegments[7].type).toBe('plus-button');
+ });
+ });
+
+ describe('group bys', () => {
+ beforeEach(() => {
+ ctrl = createCtrlWithFakes();
+ });
+
+ describe('when labels are fetched', () => {
+ beforeEach(async () => {
+ ctrl.metricLabels = { 'metric-key-1': ['metric-value-1'] };
+ ctrl.resourceLabels = { 'resource-key-1': ['resource-value-1'] };
+
+ result = await ctrl.getGroupBys();
+ });
+
+ it('should populate group bys segments', () => {
+ expect(result.length).toBe(3);
+ expect(result[0].value).toBe('metric.label.metric-key-1');
+ expect(result[1].value).toBe('resource.label.resource-key-1');
+ expect(result[2].value).toBe('-- remove group by --');
+ });
+ });
+
+ describe('when a group by label is selected', () => {
+ beforeEach(async () => {
+ ctrl.metricLabels = {
+ 'metric-key-1': ['metric-value-1'],
+ 'metric-key-2': ['metric-value-2'],
+ };
+ ctrl.resourceLabels = {
+ 'resource-key-1': ['resource-value-1'],
+ 'resource-key-2': ['resource-value-2'],
+ };
+ ctrl.target.aggregation.groupBys = ['metric.label.metric-key-1', 'resource.label.resource-key-1'];
+
+ result = await ctrl.getGroupBys();
+ });
+
+ it('should not be used to populate group bys segments', () => {
+ expect(result.length).toBe(3);
+ expect(result[0].value).toBe('metric.label.metric-key-2');
+ expect(result[1].value).toBe('resource.label.resource-key-2');
+ expect(result[2].value).toBe('-- remove group by --');
+ });
+ });
+
+ describe('when a group by is selected', () => {
+ beforeEach(() => {
+ const removeSegment = { fake: true, value: '-- remove group by --' };
+ const segment = { value: 'groupby1' };
+ ctrl.groupBySegments = [segment, removeSegment];
+ ctrl.groupByChanged(segment);
+ });
+
+ it('should be added to group bys list', () => {
+ expect(ctrl.target.aggregation.groupBys.length).toBe(1);
+ });
+ });
+
+ describe('when a selected group by is removed', () => {
+ beforeEach(() => {
+ const removeSegment = { fake: true, value: '-- remove group by --' };
+ const segment = { value: 'groupby1' };
+ ctrl.groupBySegments = [segment, removeSegment];
+ ctrl.groupByChanged(removeSegment);
+ });
+
+ it('should be added to group bys list', () => {
+ expect(ctrl.target.aggregation.groupBys.length).toBe(0);
+ });
+ });
+ });
+
+ describe('filters', () => {
+ beforeEach(() => {
+ ctrl = createCtrlWithFakes();
+ });
+
+ describe('when values for a condition filter part are fetched', () => {
+ beforeEach(async () => {
+ const segment = { type: 'condition' };
+ result = await ctrl.getFilters(segment, 0);
+ });
+
+ it('should populate condition segments', () => {
+ expect(result.length).toBe(1);
+ expect(result[0].value).toBe('AND');
+ });
+ });
+
+ describe('when values for a operator filter part are fetched', () => {
+ beforeEach(async () => {
+ const segment = { type: 'operator' };
+ result = await ctrl.getFilters(segment, 0);
+ });
+
+ it('should populate group bys segments', () => {
+ expect(result.length).toBe(4);
+ expect(result[0].value).toBe('=');
+ expect(result[1].value).toBe('!=');
+ expect(result[2].value).toBe('=~');
+ expect(result[3].value).toBe('!=~');
+ });
+ });
+
+ describe('when values for a key filter part are fetched', () => {
+ beforeEach(async () => {
+ ctrl.metricLabels = {
+ 'metric-key-1': ['metric-value-1'],
+ 'metric-key-2': ['metric-value-2'],
+ };
+ ctrl.resourceLabels = {
+ 'resource-key-1': ['resource-value-1'],
+ 'resource-key-2': ['resource-value-2'],
+ };
+
+ const segment = { type: 'key' };
+ result = await ctrl.getFilters(segment, 0);
+ });
+
+ it('should populate filter key segments', () => {
+ expect(result.length).toBe(5);
+ expect(result[0].value).toBe('metric.label.metric-key-1');
+ expect(result[1].value).toBe('metric.label.metric-key-2');
+ expect(result[2].value).toBe('resource.label.resource-key-1');
+ expect(result[3].value).toBe('resource.label.resource-key-2');
+ expect(result[4].value).toBe('-- remove filter --');
+ });
+ });
+
+ describe('when values for a value filter part are fetched', () => {
+ beforeEach(async () => {
+ ctrl.metricLabels = {
+ 'metric-key-1': ['metric-value-1'],
+ 'metric-key-2': ['metric-value-2'],
+ };
+ ctrl.resourceLabels = {
+ 'resource-key-1': ['resource-value-1'],
+ 'resource-key-2': ['resource-value-2'],
+ };
+
+ ctrl.filterSegments.filterSegments = [
+ { type: 'key', value: 'metric.label.metric-key-1' },
+ { type: 'operator', value: '=' },
+ ];
+
+ const segment = { type: 'value' };
+ result = await ctrl.getFilters(segment, 2);
+ });
+
+ it('should populate filter value segments', () => {
+ expect(result.length).toBe(1);
+ expect(result[0].value).toBe('metric-value-1');
+ });
+ });
+
+ describe('when a filter is created by clicking on plus button', () => {
+ describe('and there are no other filters', () => {
+ beforeEach(() => {
+ const segment = { value: 'filterkey1', type: 'plus-button' };
+ ctrl.filterSegments.filterSegments = [segment];
+ ctrl.filterSegmentUpdated(segment, 0);
+ });
+
+ it('should transform the plus button segment to a key segment', () => {
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ });
+
+ it('should add an operator, value segment and plus button segment', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(3);
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ });
+ });
+ });
+ describe('when has one existing filter', () => {
+ describe('and user clicks on key segment', () => {
+ beforeEach(() => {
+ const existingKeySegment = { value: 'filterkey1', type: 'key' };
+ const existingOperatorSegment = { value: '=', type: 'operator' };
+ const existingValueSegment = { value: 'filtervalue', type: 'value' };
+ const plusSegment = { value: '', type: 'plus-button' };
+ ctrl.filterSegments.filterSegments = [
+ existingKeySegment,
+ existingOperatorSegment,
+ existingValueSegment,
+ plusSegment,
+ ];
+ ctrl.filterSegmentUpdated(existingKeySegment, 0);
+ });
+
+ it('should not add any new segments', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ });
+ });
+ describe('and user clicks on value segment and value not equal to fake value', () => {
+ beforeEach(() => {
+ const existingKeySegment = { value: 'filterkey1', type: 'key' };
+ const existingOperatorSegment = { value: '=', type: 'operator' };
+ const existingValueSegment = { value: 'filtervalue', type: 'value' };
+ ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
+ ctrl.filterSegmentUpdated(existingValueSegment, 2);
+ });
+
+ it('should ensure that plus segment exists', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
+ });
+ });
+
+ describe('and user clicks on value segment and value is equal to fake value', () => {
+ beforeEach(() => {
+ const existingKeySegment = { value: 'filterkey1', type: 'key' };
+ const existingOperatorSegment = { value: '=', type: 'operator' };
+ const existingValueSegment = { value: DefaultFilterValue, type: 'value' };
+ ctrl.filterSegments.filterSegments = [existingKeySegment, existingOperatorSegment, existingValueSegment];
+ ctrl.filterSegmentUpdated(existingValueSegment, 2);
+ });
+
+ it('should not add plus segment', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(3);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ });
+ });
+ describe('and user removes key segment', () => {
+ beforeEach(() => {
+ const existingKeySegment = { value: DefaultRemoveFilterValue, type: 'key' };
+ const existingOperatorSegment = { value: '=', type: 'operator' };
+ const existingValueSegment = { value: 'filtervalue', type: 'value' };
+ const plusSegment = { value: '', type: 'plus-button' };
+ ctrl.filterSegments.filterSegments = [
+ existingKeySegment,
+ existingOperatorSegment,
+ existingValueSegment,
+ plusSegment,
+ ];
+ ctrl.filterSegmentUpdated(existingKeySegment, 0);
+ });
+
+ it('should remove filter segments', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(1);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('plus-button');
+ });
+ });
+
+ describe('and user removes key segment and there is a previous filter', () => {
+ beforeEach(() => {
+ const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
+ const existingKeySegment2 = { value: DefaultRemoveFilterValue, type: 'key' };
+ const existingOperatorSegment = { value: '=', type: 'operator' };
+ const existingValueSegment = { value: 'filtervalue', type: 'value' };
+ const conditionSegment = { value: 'AND', type: 'condition' };
+ const plusSegment = { value: '', type: 'plus-button' };
+ ctrl.filterSegments.filterSegments = [
+ existingKeySegment1,
+ existingOperatorSegment,
+ existingValueSegment,
+ conditionSegment,
+ existingKeySegment2,
+ Object.assign({}, existingOperatorSegment),
+ Object.assign({}, existingValueSegment),
+ plusSegment,
+ ];
+ ctrl.filterSegmentUpdated(existingKeySegment2, 4);
+ });
+
+ it('should remove filter segments and the condition segment', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
+ });
+ });
+
+ describe('and user removes key segment and there is a filter after it', () => {
+ beforeEach(() => {
+ const existingKeySegment1 = { value: DefaultRemoveFilterValue, type: 'key' };
+ const existingKeySegment2 = { value: DefaultRemoveFilterValue, type: 'key' };
+ const existingOperatorSegment = { value: '=', type: 'operator' };
+ const existingValueSegment = { value: 'filtervalue', type: 'value' };
+ const conditionSegment = { value: 'AND', type: 'condition' };
+ const plusSegment = { value: '', type: 'plus-button' };
+ ctrl.filterSegments.filterSegments = [
+ existingKeySegment1,
+ existingOperatorSegment,
+ existingValueSegment,
+ conditionSegment,
+ existingKeySegment2,
+ Object.assign({}, existingOperatorSegment),
+ Object.assign({}, existingValueSegment),
+ plusSegment,
+ ];
+ ctrl.filterSegmentUpdated(existingKeySegment1, 0);
+ });
+
+ it('should remove filter segments and the condition segment', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(4);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ expect(ctrl.filterSegments.filterSegments[3].type).toBe('plus-button');
+ });
+ });
+
+ describe('and user clicks on plus button', () => {
+ beforeEach(() => {
+ const existingKeySegment = { value: 'filterkey1', type: 'key' };
+ const existingOperatorSegment = { value: '=', type: 'operator' };
+ const existingValueSegment = { value: 'filtervalue', type: 'value' };
+ const plusSegment = { value: 'filterkey2', type: 'plus-button' };
+ ctrl.filterSegments.filterSegments = [
+ existingKeySegment,
+ existingOperatorSegment,
+ existingValueSegment,
+ plusSegment,
+ ];
+ ctrl.filterSegmentUpdated(plusSegment, 3);
+ });
+
+ it('should condition segment and new filter segments', () => {
+ expect(ctrl.filterSegments.filterSegments.length).toBe(7);
+ expect(ctrl.filterSegments.filterSegments[0].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[1].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[2].type).toBe('value');
+ expect(ctrl.filterSegments.filterSegments[3].type).toBe('condition');
+ expect(ctrl.filterSegments.filterSegments[4].type).toBe('key');
+ expect(ctrl.filterSegments.filterSegments[5].type).toBe('operator');
+ expect(ctrl.filterSegments.filterSegments[6].type).toBe('value');
+ });
+ });
+ });
+ });
+});
+
+function createCtrlWithFakes(existingFilters?: string[]) {
+ StackdriverFilterCtrl.prototype.loadMetricDescriptors = () => {
+ return Promise.resolve([]);
+ };
+ StackdriverFilterCtrl.prototype.getLabels = () => {
+ return Promise.resolve();
+ };
+
+ const fakeSegmentServer = {
+ newKey: val => {
+ return { value: val, type: 'key' };
+ },
+ newKeyValue: val => {
+ return { value: val, type: 'value' };
+ },
+ newSegment: obj => {
+ return { value: obj.value ? obj.value : obj };
+ },
+ newOperators: ops => {
+ return ops.map(o => {
+ return { type: 'operator', value: o };
+ });
+ },
+ newFake: (value, type, cssClass) => {
+ return { value, type, cssClass };
+ },
+ newOperator: op => {
+ return { value: op, type: 'operator' };
+ },
+ newPlusButton: () => {
+ return { type: 'plus-button' };
+ },
+ newCondition: val => {
+ return { type: 'condition', value: val };
+ },
+ };
+ const scope = {
+ target: createTarget(existingFilters),
+ datasource: {
+ getDefaultProject: () => {
+ return 'project';
+ },
+ },
+ defaultDropdownValue: 'Select Metric',
+ defaultServiceValue: 'All Services',
+ refresh: () => {},
+ };
+
+ return new StackdriverFilterCtrl(scope, fakeSegmentServer, new TemplateSrvStub(), { $broadcast: param => {} });
+}
+
+function createTarget(existingFilters?: string[]) {
+ return {
+ project: {
+ id: '',
+ name: '',
+ },
+ unit: '',
+ metricType: 'ametric',
+ service: '',
+ refId: 'A',
+ aggregation: {
+ crossSeriesReducer: '',
+ alignmentPeriod: '',
+ perSeriesAligner: '',
+ groupBys: [],
+ },
+ filters: existingFilters || [],
+ aliasBy: '',
+ metricService: '',
+ metricKind: '',
+ valueType: '',
+ };
+}
diff --git a/public/app/plugins/datasource/stackdriver/specs/testData.ts b/public/app/plugins/datasource/stackdriver/specs/testData.ts
new file mode 100644
index 00000000000..b97aad7c6ce
--- /dev/null
+++ b/public/app/plugins/datasource/stackdriver/specs/testData.ts
@@ -0,0 +1,42 @@
+export const metricDescriptors = [
+ {
+ name: 'projects/grafana-prod/metricDescriptors/agent.googleapis.com/agent/api_request_count',
+ labels: [
+ {
+ key: 'state',
+ description: 'Request state',
+ },
+ ],
+ metricKind: 'CUMULATIVE',
+ valueType: 'INT64',
+ unit: '1',
+ description: 'API request count',
+ displayName: 'API Request Count',
+ type: 'agent.googleapis.com/agent/api_request_count',
+ metadata: {
+ launchStage: 'GA',
+ samplePeriod: '60s',
+ ingestDelay: '0s',
+ },
+ },
+ {
+ name: 'projects/grafana-prod/metricDescriptors/agent.googleapis.com/agent/log_entry_count',
+ labels: [
+ {
+ key: 'response_code',
+ description: 'HTTP response code',
+ },
+ ],
+ metricKind: 'CUMULATIVE',
+ valueType: 'INT64',
+ unit: '1',
+ description: 'Count of log entry writes',
+ displayName: 'Log Entry Count',
+ type: 'agent.googleapis.com/agent/log_entry_count',
+ metadata: {
+ launchStage: 'GA',
+ samplePeriod: '60s',
+ ingestDelay: '0s',
+ },
+ },
+];
diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts
index cdd5478c4fd..0197626cd0b 100644
--- a/public/app/plugins/datasource/testdata/datasource.ts
+++ b/public/app/plugins/datasource/testdata/datasource.ts
@@ -1,4 +1,5 @@
import _ from 'lodash';
+import TableModel from 'app/core/table_model';
class TestDataDatasource {
id: any;
@@ -29,16 +30,29 @@ class TestDataDatasource {
}
return this.backendSrv
- .post('/api/tsdb/query', {
- from: options.range.from.valueOf().toString(),
- to: options.range.to.valueOf().toString(),
- queries: queries,
+ .datasourceRequest({
+ method: 'POST',
+ url: '/api/tsdb/query',
+ data: {
+ from: options.range.from.valueOf().toString(),
+ to: options.range.to.valueOf().toString(),
+ queries: queries,
+ },
})
.then(res => {
const data = [];
- if (res.results) {
- _.forEach(res.results, queryRes => {
+ if (res.data.results) {
+ _.forEach(res.data.results, queryRes => {
+ if (queryRes.tables) {
+ for (const table of queryRes.tables) {
+ const model = new TableModel();
+ model.rows = table.rows;
+ model.columns = table.columns;
+
+ data.push(model);
+ }
+ }
for (const series of queryRes.series) {
data.push({
target: series.name,
@@ -53,12 +67,22 @@ class TestDataDatasource {
}
annotationQuery(options) {
- return this.backendSrv.get('/api/annotations', {
- from: options.range.from.valueOf(),
- to: options.range.to.valueOf(),
- limit: options.limit,
- type: options.type,
- });
+ let timeWalker = options.range.from.valueOf();
+ const to = options.range.to.valueOf();
+ const events = [];
+ const eventCount = 10;
+ const step = (to - timeWalker) / eventCount;
+
+ for (let i = 0; i < eventCount; i++) {
+ events.push({
+ annotation: options.annotation,
+ time: timeWalker,
+ text: 'This is the text, Grafana.com ',
+ tags: ['text', 'server'],
+ });
+ timeWalker += step;
+ }
+ return this.$q.when(events);
}
}
diff --git a/public/app/plugins/datasource/testdata/module.ts b/public/app/plugins/datasource/testdata/module.ts
index f0db2d4e341..ea01849b997 100644
--- a/public/app/plugins/datasource/testdata/module.ts
+++ b/public/app/plugins/datasource/testdata/module.ts
@@ -6,7 +6,7 @@ class TestDataAnnotationsQueryCtrl {
constructor() {}
- static template = 'test data ';
+ static template = 'Annotation scenario ';
}
export {
diff --git a/public/app/plugins/panel/graph/Legend/Legend.tsx b/public/app/plugins/panel/graph/Legend/Legend.tsx
new file mode 100644
index 00000000000..7af61fde4e9
--- /dev/null
+++ b/public/app/plugins/panel/graph/Legend/Legend.tsx
@@ -0,0 +1,321 @@
+import _ from 'lodash';
+import React, { PureComponent } from 'react';
+import { TimeSeries } from 'app/core/core';
+import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
+import { LegendItem, LEGEND_STATS } from './LegendSeriesItem';
+
+interface LegendProps {
+ seriesList: TimeSeries[];
+ optionalClass?: string;
+}
+
+interface LegendEventHandlers {
+ onToggleSeries?: (hiddenSeries) => void;
+ onToggleSort?: (sortBy, sortDesc) => void;
+ onToggleAxis?: (series: TimeSeries) => void;
+ onColorChange?: (series: TimeSeries, color: string) => void;
+}
+
+interface LegendComponentEventHandlers {
+ onToggleSeries?: (series, event) => void;
+ onToggleSort?: (sortBy, sortDesc) => void;
+ onToggleAxis?: (series: TimeSeries) => void;
+ onColorChange?: (series: TimeSeries, color: string) => void;
+}
+
+interface LegendDisplayProps {
+ hiddenSeries: any;
+ hideEmpty?: boolean;
+ hideZero?: boolean;
+ alignAsTable?: boolean;
+ rightSide?: boolean;
+ sideWidth?: number;
+}
+
+interface LegendValuesProps {
+ values?: boolean;
+ min?: boolean;
+ max?: boolean;
+ avg?: boolean;
+ current?: boolean;
+ total?: boolean;
+}
+
+interface LegendSortProps {
+ sort?: 'min' | 'max' | 'avg' | 'current' | 'total';
+ sortDesc?: boolean;
+}
+
+export type GraphLegendProps = LegendProps &
+ LegendDisplayProps &
+ LegendValuesProps &
+ LegendSortProps &
+ LegendEventHandlers;
+export type LegendComponentProps = LegendProps &
+ LegendDisplayProps &
+ LegendValuesProps &
+ LegendSortProps &
+ LegendComponentEventHandlers;
+
+interface LegendState {
+ hiddenSeries: { [seriesAlias: string]: boolean };
+}
+
+export class GraphLegend extends PureComponent {
+ static defaultProps: Partial = {
+ values: false,
+ min: false,
+ max: false,
+ avg: false,
+ current: false,
+ total: false,
+ alignAsTable: false,
+ rightSide: false,
+ sort: undefined,
+ sortDesc: false,
+ optionalClass: '',
+ onToggleSeries: () => {},
+ onToggleSort: () => {},
+ onToggleAxis: () => {},
+ onColorChange: () => {},
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ hiddenSeries: this.props.hiddenSeries,
+ };
+ }
+
+ sortLegend() {
+ let seriesList = [...this.props.seriesList] || [];
+ if (this.props.sort) {
+ seriesList = _.sortBy(seriesList, series => {
+ let sort = series.stats[this.props.sort];
+ if (sort === null) {
+ sort = -Infinity;
+ }
+ return sort;
+ });
+ if (this.props.sortDesc) {
+ seriesList = seriesList.reverse();
+ }
+ }
+ return seriesList;
+ }
+
+ onToggleSeries = (series, event) => {
+ let hiddenSeries = { ...this.state.hiddenSeries };
+ if (event.ctrlKey || event.metaKey || event.shiftKey) {
+ if (hiddenSeries[series.alias]) {
+ delete hiddenSeries[series.alias];
+ } else {
+ hiddenSeries[series.alias] = true;
+ }
+ } else {
+ hiddenSeries = this.toggleSeriesExclusiveMode(series);
+ }
+ this.setState({ hiddenSeries: hiddenSeries });
+ this.props.onToggleSeries(hiddenSeries);
+ };
+
+ toggleSeriesExclusiveMode(series) {
+ const hiddenSeries = { ...this.state.hiddenSeries };
+
+ if (hiddenSeries[series.alias]) {
+ delete hiddenSeries[series.alias];
+ }
+
+ // check if every other series is hidden
+ const alreadyExclusive = this.props.seriesList.every(value => {
+ if (value.alias === series.alias) {
+ return true;
+ }
+
+ return hiddenSeries[value.alias];
+ });
+
+ if (alreadyExclusive) {
+ // remove all hidden series
+ this.props.seriesList.forEach(value => {
+ delete hiddenSeries[value.alias];
+ });
+ } else {
+ // hide all but this serie
+ this.props.seriesList.forEach(value => {
+ if (value.alias === series.alias) {
+ return;
+ }
+
+ hiddenSeries[value.alias] = true;
+ });
+ }
+
+ return hiddenSeries;
+ }
+
+ render() {
+ const {
+ optionalClass,
+ rightSide,
+ sideWidth,
+ sort,
+ sortDesc,
+ hideEmpty,
+ hideZero,
+ values,
+ min,
+ max,
+ avg,
+ current,
+ total,
+ } = this.props;
+ const seriesValuesProps = { values, min, max, avg, current, total };
+ const hiddenSeries = this.state.hiddenSeries;
+ const seriesHideProps = { hideEmpty, hideZero };
+ const sortProps = { sort, sortDesc };
+ const seriesList = this.sortLegend().filter(series => !series.hideFromLegend(seriesHideProps));
+ const legendClass = `${this.props.alignAsTable ? 'graph-legend-table' : ''} ${optionalClass}`;
+
+ // Set min-width if side style and there is a value, otherwise remove the CSS property
+ // Set width so it works with IE11
+ const width: any = rightSide && sideWidth ? sideWidth : undefined;
+ const ieWidth: any = rightSide && sideWidth ? sideWidth - 1 : undefined;
+ const legendStyle: React.CSSProperties = {
+ minWidth: width,
+ width: ieWidth,
+ };
+
+ const legendProps: LegendComponentProps = {
+ seriesList: seriesList,
+ hiddenSeries: hiddenSeries,
+ onToggleSeries: this.onToggleSeries,
+ onToggleAxis: this.props.onToggleAxis,
+ onToggleSort: this.props.onToggleSort,
+ onColorChange: this.props.onColorChange,
+ ...seriesValuesProps,
+ ...sortProps,
+ };
+
+ return (
+
+ {this.props.alignAsTable ? : }
+
+ );
+ }
+}
+
+class LegendSeriesList extends PureComponent {
+ render() {
+ const { seriesList, hiddenSeries, values, min, max, avg, current, total } = this.props;
+ const seriesValuesProps = { values, min, max, avg, current, total };
+ return seriesList.map((series, i) => (
+
+ ));
+ }
+}
+
+class LegendTable extends PureComponent> {
+ onToggleSort = stat => {
+ let sortDesc = this.props.sortDesc;
+ let sortBy = this.props.sort;
+ if (stat !== sortBy) {
+ sortDesc = null;
+ }
+
+ // if already sort ascending, disable sorting
+ if (sortDesc === false) {
+ sortBy = null;
+ sortDesc = null;
+ } else {
+ sortDesc = !sortDesc;
+ sortBy = stat;
+ }
+ this.props.onToggleSort(sortBy, sortDesc);
+ };
+
+ render() {
+ const seriesList = this.props.seriesList;
+ const { values, min, max, avg, current, total, sort, sortDesc, hiddenSeries } = this.props;
+ const seriesValuesProps = { values, min, max, avg, current, total };
+ return (
+
+
+
+
+
+
+
+ {LEGEND_STATS.map(
+ statName =>
+ seriesValuesProps[statName] && (
+
+ )
+ )}
+
+
+
+ {seriesList.map((series, i) => (
+
+ ))}
+
+
+ );
+ }
+}
+
+interface LegendTableHeaderProps {
+ statName: string;
+ onClick?: (statName: string) => void;
+}
+
+class LegendTableHeaderItem extends PureComponent {
+ onClick = () => this.props.onClick(this.props.statName);
+
+ render() {
+ const { statName, sort, sortDesc } = this.props;
+ return (
+
+ {statName}
+ {sort === statName && }
+
+ );
+ }
+}
+
+export class Legend extends PureComponent {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+export default Legend;
diff --git a/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx
new file mode 100644
index 00000000000..2105687d8e1
--- /dev/null
+++ b/public/app/plugins/panel/graph/Legend/LegendSeriesItem.tsx
@@ -0,0 +1,196 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import { TimeSeries } from 'app/core/core';
+import { SeriesColorPicker } from 'app/core/components/colorpicker/SeriesColorPicker';
+
+export const LEGEND_STATS = ['min', 'max', 'avg', 'current', 'total'];
+
+export interface LegendLabelProps {
+ series: TimeSeries;
+ asTable?: boolean;
+ hidden?: boolean;
+ onLabelClick?: (series, event) => void;
+ onColorChange?: (series, color: string) => void;
+ onToggleAxis?: (series) => void;
+}
+
+export interface LegendValuesProps {
+ values?: boolean;
+ min?: boolean;
+ max?: boolean;
+ avg?: boolean;
+ current?: boolean;
+ total?: boolean;
+}
+
+type LegendItemProps = LegendLabelProps & LegendValuesProps;
+
+interface LegendItemState {
+ yaxis: number;
+}
+
+export class LegendItem extends PureComponent {
+ static defaultProps = {
+ asTable: false,
+ hidden: false,
+ onLabelClick: () => {},
+ onColorChange: () => {},
+ onToggleAxis: () => {},
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ yaxis: this.props.series.yaxis,
+ };
+ }
+
+ onLabelClick = e => this.props.onLabelClick(this.props.series, e);
+
+ onToggleAxis = () => {
+ const yaxis = this.state.yaxis === 2 ? 1 : 2;
+ const info = { alias: this.props.series.alias, yaxis: yaxis };
+ this.setState({ yaxis: yaxis });
+ this.props.onToggleAxis(info);
+ };
+
+ onColorChange = color => {
+ this.props.onColorChange(this.props.series, color);
+ // Because of PureComponent nature it makes only shallow props comparison and changing of series.color doesn't run
+ // component re-render. In this case we can't rely on color, selected by user, because it may be overwritten
+ // by series overrides. So we need to use forceUpdate() to make sure we have proper series color.
+ this.forceUpdate();
+ };
+
+ renderLegendValues() {
+ const { series, asTable } = this.props;
+ const legendValueItems = [];
+ for (const valueName of LEGEND_STATS) {
+ if (this.props[valueName]) {
+ const valueFormatted = series.formatValue(series.stats[valueName]);
+ legendValueItems.push(
+
+ );
+ }
+ }
+ return legendValueItems;
+ }
+
+ render() {
+ const { series, values, asTable, hidden } = this.props;
+ const seriesOptionClasses = classNames({
+ 'graph-legend-series-hidden': hidden,
+ 'graph-legend-series--right-y': series.yaxis === 2,
+ });
+ const valueItems = values ? this.renderLegendValues() : [];
+ const seriesLabel = (
+
+ );
+
+ if (asTable) {
+ return (
+
+ {seriesLabel}
+ {valueItems}
+
+ );
+ } else {
+ return (
+
+ {seriesLabel}
+ {valueItems}
+
+ );
+ }
+ }
+}
+
+interface LegendSeriesLabelProps {
+ label: string;
+ color: string;
+ yaxis?: number;
+ onLabelClick?: (event) => void;
+}
+
+class LegendSeriesLabel extends PureComponent {
+ static defaultProps = {
+ yaxis: undefined,
+ onLabelClick: () => {},
+ };
+
+ render() {
+ const { label, color, yaxis } = this.props;
+ const { onColorChange, onToggleAxis } = this.props;
+ return [
+ ,
+ this.props.onLabelClick(e)}>
+ {label}
+ ,
+ ];
+ }
+}
+
+interface LegendSeriesIconProps {
+ color: string;
+ yaxis?: number;
+ onColorChange?: (color: string) => void;
+ onToggleAxis?: () => void;
+}
+
+interface LegendSeriesIconState {
+ color: string;
+}
+
+function SeriesIcon(props) {
+ return ;
+}
+
+class LegendSeriesIcon extends PureComponent {
+ static defaultProps = {
+ yaxis: undefined,
+ onColorChange: () => {},
+ onToggleAxis: () => {},
+ };
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+interface LegendValueProps {
+ value: string;
+ valueName: string;
+ asTable?: boolean;
+}
+
+function LegendValue(props: LegendValueProps) {
+ const value = props.value;
+ const valueName = props.valueName;
+ if (props.asTable) {
+ return {value} ;
+ }
+ return {value}
;
+}
diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts
index 33db0e7220a..01afd0716e6 100755
--- a/public/app/plugins/panel/graph/graph.ts
+++ b/public/app/plugins/panel/graph/graph.ts
@@ -20,6 +20,9 @@ import { EventManager } from 'app/features/annotations/all';
import { convertToHistogramData } from './histogram';
import { alignYLevel } from './align_yaxes';
import config from 'app/core/config';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Legend, GraphLegendProps } from './Legend/Legend';
import { GraphCtrl } from './module';
@@ -35,6 +38,7 @@ class GraphElement {
panelWidth: number;
eventManager: EventManager;
thresholdManager: ThresholdManager;
+ legendElem: HTMLElement;
constructor(private scope, private elem, private timeSrv) {
this.ctrl = scope.ctrl;
@@ -50,7 +54,7 @@ class GraphElement {
});
// panel events
- this.ctrl.events.on('panel-teardown', this.onPanelteardown.bind(this));
+ this.ctrl.events.on('panel-teardown', this.onPanelTeardown.bind(this));
/**
* Split graph rendering into two parts.
@@ -63,13 +67,14 @@ class GraphElement {
// global events
appEvents.on('graph-hover', this.onGraphHover.bind(this), scope);
-
appEvents.on('graph-hover-clear', this.onGraphHoverClear.bind(this), scope);
-
this.elem.bind('plotselected', this.onPlotSelected.bind(this));
-
this.elem.bind('plotclick', this.onPlotClick.bind(this));
- scope.$on('$destroy', this.onScopeDestroy.bind(this));
+
+ // get graph legend element
+ if (this.elem && this.elem.parent) {
+ this.legendElem = this.elem.parent().find('.graph-legend')[0];
+ }
}
onRender(renderData) {
@@ -82,7 +87,26 @@ class GraphElement {
const graphHeight = this.elem.height();
updateLegendValues(this.data, this.panel, graphHeight);
- this.ctrl.events.emit('render-legend');
+ const { values, min, max, avg, current, total } = this.panel.legend;
+ const { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero } = this.panel.legend;
+ const legendOptions = { alignAsTable, rightSide, sideWidth, sort, sortDesc, hideEmpty, hideZero };
+ const valueOptions = { values, min, max, avg, current, total };
+ const legendProps: GraphLegendProps = {
+ seriesList: this.data,
+ hiddenSeries: this.ctrl.hiddenSeries,
+ ...legendOptions,
+ ...valueOptions,
+ onToggleSeries: this.ctrl.onToggleSeries,
+ onToggleSort: this.ctrl.onToggleSort,
+ onColorChange: this.ctrl.onColorChange,
+ onToggleAxis: this.ctrl.onToggleAxis,
+ };
+ const legendReactElem = React.createElement(Legend, legendProps);
+ ReactDOM.render(legendReactElem, this.legendElem, () => this.onLegendRenderingComplete());
+ }
+
+ onLegendRenderingComplete() {
+ this.render_panel();
}
onGraphHover(evt) {
@@ -99,17 +123,19 @@ class GraphElement {
this.tooltip.show(evt.pos);
}
- onPanelteardown() {
+ onPanelTeardown() {
this.thresholdManager = null;
if (this.plot) {
this.plot.destroy();
this.plot = null;
}
- }
- onLegendRenderingComplete() {
- this.render_panel();
+ this.tooltip.destroy();
+ this.elem.off();
+ this.elem.remove();
+
+ ReactDOM.unmountComponentAtNode(this.legendElem);
}
onGraphHoverClear(event, info) {
@@ -157,12 +183,6 @@ class GraphElement {
}
}
- onScopeDestroy() {
- this.tooltip.destroy();
- this.elem.off();
- this.elem.remove();
- }
-
shouldAbortRender() {
if (!this.data) {
return true;
@@ -713,7 +733,9 @@ class GraphElement {
if (min && max && ticks) {
const range = max - min;
const secPerTick = range / ticks / 1000;
- const oneDay = 86400000;
+ // Need have 10 milisecond margin on the day range
+ // As sometimes last 24 hour dashboard evaluates to more than 86400000
+ const oneDay = 86400010;
const oneYear = 31536000000;
if (secPerTick <= 45) {
diff --git a/public/app/plugins/panel/graph/legend.ts b/public/app/plugins/panel/graph/legend.ts
deleted file mode 100644
index cf317389941..00000000000
--- a/public/app/plugins/panel/graph/legend.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-import $ from 'jquery';
-import baron from 'baron';
-
-const module = angular.module('grafana.directives');
-
-module.directive('graphLegend', (popoverSrv, $timeout) => {
- return {
- link: (scope, elem) => {
- let firstRender = true;
- const ctrl = scope.ctrl;
- const panel = ctrl.panel;
- let data;
- let seriesList;
- let i;
- let legendScrollbar;
- const legendRightDefaultWidth = 10;
- const legendElem = elem.parent();
-
- scope.$on('$destroy', () => {
- destroyScrollbar();
- });
-
- ctrl.events.on('render-legend', () => {
- data = ctrl.seriesList;
- if (data) {
- render();
- }
- ctrl.events.emit('legend-rendering-complete');
- });
-
- function getSeriesIndexForElement(el) {
- return el.parents('[data-series-index]').data('series-index');
- }
-
- function openColorSelector(e) {
- // if we clicked inside poup container ignore click
- if ($(e.target).parents('.popover').length) {
- return;
- }
-
- const el = $(e.currentTarget).find('.fa-minus');
- const index = getSeriesIndexForElement(el);
- const series = seriesList[index];
-
- $timeout(() => {
- popoverSrv.show({
- element: el[0],
- position: 'bottom left',
- targetAttachment: 'top left',
- template:
- '' +
- ' ',
- openOn: 'hover',
- model: {
- series: series,
- toggleAxis: () => {
- ctrl.toggleAxis(series);
- },
- colorSelected: color => {
- ctrl.changeSeriesColor(series, color);
- },
- },
- });
- });
- }
-
- function toggleSeries(e) {
- const el = $(e.currentTarget);
- const index = getSeriesIndexForElement(el);
- const seriesInfo = seriesList[index];
- const scrollPosition = legendScrollbar.scroller.scrollTop;
- ctrl.toggleSeries(seriesInfo, e);
- legendScrollbar.scroller.scrollTop = scrollPosition;
- }
-
- function sortLegend(e) {
- const el = $(e.currentTarget);
- const stat = el.data('stat');
-
- if (stat !== panel.legend.sort) {
- panel.legend.sortDesc = null;
- }
-
- // if already sort ascending, disable sorting
- if (panel.legend.sortDesc === false) {
- panel.legend.sort = null;
- panel.legend.sortDesc = null;
- ctrl.render();
- return;
- }
-
- panel.legend.sortDesc = !panel.legend.sortDesc;
- panel.legend.sort = stat;
- ctrl.render();
- }
-
- function getTableHeaderHtml(statName) {
- if (!panel.legend[statName]) {
- return '';
- }
- let html = '' + statName;
-
- if (panel.legend.sort === statName) {
- const cssClass = panel.legend.sortDesc ? 'fa fa-caret-down' : 'fa fa-caret-up';
- html += ' ';
- }
-
- return html + ' ';
- }
-
- function render() {
- const legendWidth = legendElem.width();
- if (!ctrl.panel.legend.show) {
- elem.empty();
- firstRender = true;
- return;
- }
-
- if (firstRender) {
- elem.on('click', '.graph-legend-icon', openColorSelector);
- elem.on('click', '.graph-legend-alias', toggleSeries);
- elem.on('click', 'th', sortLegend);
- firstRender = false;
- }
-
- seriesList = data;
-
- elem.empty();
-
- // Set min-width if side style and there is a value, otherwise remove the CSS property
- // Set width so it works with IE11
- const width: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth + 'px' : '';
- const ieWidth: any = panel.legend.rightSide && panel.legend.sideWidth ? panel.legend.sideWidth - 1 + 'px' : '';
- legendElem.css('min-width', width);
- legendElem.css('width', ieWidth);
-
- elem.toggleClass('graph-legend-table', panel.legend.alignAsTable === true);
-
- let tableHeaderElem;
- if (panel.legend.alignAsTable) {
- let header = '';
- header += ' ';
- if (panel.legend.values) {
- header += getTableHeaderHtml('min');
- header += getTableHeaderHtml('max');
- header += getTableHeaderHtml('avg');
- header += getTableHeaderHtml('current');
- header += getTableHeaderHtml('total');
- }
- header += ' ';
- tableHeaderElem = $(header);
- }
-
- if (panel.legend.sort) {
- seriesList = _.sortBy(seriesList, series => {
- let sort = series.stats[panel.legend.sort];
- if (sort === null) {
- sort = -Infinity;
- }
- return sort;
- });
- if (panel.legend.sortDesc) {
- seriesList = seriesList.reverse();
- }
- }
-
- // render first time for getting proper legend height
- if (!panel.legend.rightSide || (panel.legend.rightSide && legendWidth !== legendRightDefaultWidth)) {
- renderLegendElement(tableHeaderElem);
- elem.empty();
- }
-
- renderLegendElement(tableHeaderElem);
- }
-
- function renderSeriesLegendElements() {
- const seriesElements = [];
- for (i = 0; i < seriesList.length; i++) {
- const series = seriesList[i];
-
- if (series.hideFromLegend(panel.legend)) {
- continue;
- }
-
- let html = '';
- html += '
';
- html += ' ';
- html += '
';
-
- html +=
- '
' + series.aliasEscaped + ' ';
-
- if (panel.legend.values) {
- const avg = series.formatValue(series.stats.avg);
- const current = series.formatValue(series.stats.current);
- const min = series.formatValue(series.stats.min);
- const max = series.formatValue(series.stats.max);
- const total = series.formatValue(series.stats.total);
-
- if (panel.legend.min) {
- html += '
' + min + '
';
- }
- if (panel.legend.max) {
- html += '
' + max + '
';
- }
- if (panel.legend.avg) {
- html += '
' + avg + '
';
- }
- if (panel.legend.current) {
- html += '
' + current + '
';
- }
- if (panel.legend.total) {
- html += '
' + total + '
';
- }
- }
-
- html += '
';
- seriesElements.push($(html));
- }
- return seriesElements;
- }
-
- function renderLegendElement(tableHeaderElem) {
- const legendWidth = elem.width();
-
- const seriesElements = renderSeriesLegendElements();
-
- if (panel.legend.alignAsTable) {
- const tbodyElem = $(' ');
- tbodyElem.append(tableHeaderElem);
- tbodyElem.append(seriesElements);
- elem.append(tbodyElem);
- tbodyElem.wrap('
');
- } else {
- elem.append('
');
- elem.find('.graph-legend-scroll').append(seriesElements);
- }
-
- if (!panel.legend.rightSide || (panel.legend.rightSide && legendWidth !== legendRightDefaultWidth)) {
- addScrollbar();
- } else {
- destroyScrollbar();
- }
- }
-
- function addScrollbar() {
- const scrollRootClass = 'baron baron__root';
- const scrollerClass = 'baron__scroller';
- const scrollBarHTML = `
-
- `;
-
- const scrollRoot = elem;
- const scroller = elem.find('.graph-legend-scroll');
-
- // clear existing scroll bar track to prevent duplication
- scrollRoot.find('.baron__track').remove();
-
- scrollRoot.addClass(scrollRootClass);
- $(scrollBarHTML).appendTo(scrollRoot);
- scroller.addClass(scrollerClass);
-
- const scrollbarParams = {
- root: scrollRoot[0],
- scroller: scroller[0],
- bar: '.baron__bar',
- track: '.baron__track',
- barOnCls: '_scrollbar',
- scrollingCls: '_scrolling',
- };
-
- if (!legendScrollbar) {
- legendScrollbar = baron(scrollbarParams);
- } else {
- destroyScrollbar();
- legendScrollbar = baron(scrollbarParams);
- }
-
- // #11830 - compensates for Firefox scrollbar calculation error in the baron framework
- scroller[0].style.marginRight = '-' + (scroller[0].offsetWidth - scroller[0].clientWidth) + 'px';
-
- legendScrollbar.scroll();
- }
-
- function destroyScrollbar() {
- if (legendScrollbar) {
- legendScrollbar.dispose();
- legendScrollbar = undefined;
- }
- }
- },
- };
-});
diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts
index f0751ddd816..a6c5190d937 100644
--- a/public/app/plugins/panel/graph/module.ts
+++ b/public/app/plugins/panel/graph/module.ts
@@ -1,5 +1,4 @@
import './graph';
-import './legend';
import './series_overrides_ctrl';
import './thresholds_form';
@@ -134,9 +133,9 @@ class GraphCtrl extends MetricsPanelCtrl {
}
onInitEditMode() {
+ this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4);
this.addEditorTab('Axes', axesEditorComponent, 2);
this.addEditorTab('Legend', 'public/app/plugins/panel/graph/tab_legend.html', 3);
- this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4);
if (config.alertingEnabled) {
this.addEditorTab('Alert', alertTab, 5);
@@ -147,7 +146,7 @@ class GraphCtrl extends MetricsPanelCtrl {
onInitPanelActions(actions) {
actions.push({ text: 'Export CSV', click: 'ctrl.exportCsv()' });
- actions.push({ text: 'Toggle legend', click: 'ctrl.toggleLegend()' });
+ actions.push({ text: 'Toggle legend', click: 'ctrl.toggleLegend()', shortcut: 'p l' });
}
issueQueries(datasource) {
@@ -156,7 +155,16 @@ class GraphCtrl extends MetricsPanelCtrl {
panel: this.panel,
range: this.range,
});
- return super.issueQueries(datasource);
+
+ /* Wait for annotationSrv requests to get datasources to
+ * resolve before issuing queries. This allows the annotations
+ * service to fire annotations queries before graph queries
+ * (but not wait for completion). This resolves
+ * issue 11806.
+ */
+ return this.annotationsSrv.datasourcePromises.then(r => {
+ return super.issueQueries(datasource);
+ });
}
zoomOut(evt) {
@@ -235,67 +243,32 @@ class GraphCtrl extends MetricsPanelCtrl {
}
}
- changeSeriesColor(series, color) {
+ onColorChange = (series, color) => {
series.setColor(color);
this.panel.aliasColors[series.alias] = series.color;
this.render();
- }
+ };
- toggleSeries(serie, event) {
- if (event.ctrlKey || event.metaKey || event.shiftKey) {
- if (this.hiddenSeries[serie.alias]) {
- delete this.hiddenSeries[serie.alias];
- } else {
- this.hiddenSeries[serie.alias] = true;
- }
- } else {
- this.toggleSeriesExclusiveMode(serie);
- }
+ onToggleSeries = hiddenSeries => {
+ this.hiddenSeries = hiddenSeries;
this.render();
- }
+ };
- toggleSeriesExclusiveMode(serie) {
- const hidden = this.hiddenSeries;
+ onToggleSort = (sortBy, sortDesc) => {
+ this.panel.legend.sort = sortBy;
+ this.panel.legend.sortDesc = sortDesc;
+ this.render();
+ };
- if (hidden[serie.alias]) {
- delete hidden[serie.alias];
- }
-
- // check if every other series is hidden
- const alreadyExclusive = _.every(this.seriesList, value => {
- if (value.alias === serie.alias) {
- return true;
- }
-
- return hidden[value.alias];
- });
-
- if (alreadyExclusive) {
- // remove all hidden series
- _.each(this.seriesList, value => {
- delete this.hiddenSeries[value.alias];
- });
- } else {
- // hide all but this serie
- _.each(this.seriesList, value => {
- if (value.alias === serie.alias) {
- return;
- }
-
- this.hiddenSeries[value.alias] = true;
- });
- }
- }
-
- toggleAxis(info) {
+ onToggleAxis = info => {
let override = _.find(this.panel.seriesOverrides, { alias: info.alias });
if (!override) {
override = { alias: info.alias };
this.panel.seriesOverrides.push(override);
}
- info.yaxis = override.yaxis = info.yaxis === 2 ? 1 : 2;
+ override.yaxis = info.yaxis;
this.render();
- }
+ };
addSeriesOverride(override) {
this.panel.seriesOverrides.push(override || {});
diff --git a/public/app/plugins/panel/graph/series_overrides_ctrl.ts b/public/app/plugins/panel/graph/series_overrides_ctrl.ts
index deb7bd8ba61..934fd835347 100644
--- a/public/app/plugins/panel/graph/series_overrides_ctrl.ts
+++ b/public/app/plugins/panel/graph/series_overrides_ctrl.ts
@@ -1,5 +1,5 @@
import _ from 'lodash';
-import angular from 'angular';
+import coreModule from 'app/core/core_module';
/** @ngInject */
export function SeriesOverridesCtrl($scope, $element, popoverSrv) {
@@ -53,7 +53,7 @@ export function SeriesOverridesCtrl($scope, $element, popoverSrv) {
element: $element.find('.dropdown')[0],
position: 'top center',
openOn: 'click',
- template: ' ',
+ template: ' ',
model: {
autoClose: true,
colorSelected: $scope.colorSelected,
@@ -156,4 +156,4 @@ export function SeriesOverridesCtrl($scope, $element, popoverSrv) {
$scope.updateCurrentOverrides();
}
-angular.module('grafana.controllers').controller('SeriesOverridesCtrl', SeriesOverridesCtrl);
+coreModule.controller('SeriesOverridesCtrl', SeriesOverridesCtrl);
diff --git a/public/app/plugins/panel/graph2/README.md b/public/app/plugins/panel/graph2/README.md
new file mode 100644
index 00000000000..667ab51784a
--- /dev/null
+++ b/public/app/plugins/panel/graph2/README.md
@@ -0,0 +1,5 @@
+# Text Panel - Native Plugin
+
+The Text Panel is **included** with Grafana.
+
+The Text Panel is a very simple panel that displays text. The source text is written in the Markdown syntax meaning you can format the text. Read [GitHub's Mastering Markdown](https://guides.github.com/features/mastering-markdown/) to learn more.
diff --git a/public/app/plugins/panel/graph2/img/icn-text-panel.svg b/public/app/plugins/panel/graph2/img/icn-text-panel.svg
new file mode 100644
index 00000000000..a9d0a1d2c4a
--- /dev/null
+++ b/public/app/plugins/panel/graph2/img/icn-text-panel.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/app/plugins/panel/graph2/module.tsx b/public/app/plugins/panel/graph2/module.tsx
new file mode 100644
index 00000000000..4011458bea9
--- /dev/null
+++ b/public/app/plugins/panel/graph2/module.tsx
@@ -0,0 +1,51 @@
+// Libraries
+import _ from 'lodash';
+import React, { PureComponent } from 'react';
+
+// Components
+import Graph from 'app/viz/Graph';
+import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
+import { Switch } from 'app/core/components/Switch/Switch';
+
+// Types
+import { PanelProps, NullValueMode } from 'app/types';
+
+interface Options {
+ showBars: boolean;
+}
+
+interface Props extends PanelProps {
+ options: Options;
+}
+
+export class Graph2 extends PureComponent {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ const { timeSeries, timeRange } = this.props;
+
+ const vmSeries = getTimeSeriesVMs({
+ timeSeries: timeSeries,
+ nullValueMode: NullValueMode.Ignore,
+ });
+
+ return ;
+ }
+}
+
+export class TextOptions extends PureComponent {
+ onChange = () => {};
+
+ render() {
+ return (
+
+
Draw Modes
+
+
+ );
+ }
+}
+
+export { Graph2 as PanelComponent, TextOptions as PanelOptions };
diff --git a/public/app/plugins/panel/graph2/plugin.json b/public/app/plugins/panel/graph2/plugin.json
new file mode 100644
index 00000000000..2e674ab3557
--- /dev/null
+++ b/public/app/plugins/panel/graph2/plugin.json
@@ -0,0 +1,19 @@
+{
+ "type": "panel",
+ "name": "React Graph",
+ "id": "graph2",
+
+ "state": "alpha",
+
+ "info": {
+ "author": {
+ "name": "Grafana Project",
+ "url": "https://grafana.com"
+ },
+ "logos": {
+ "small": "img/icn-text-panel.svg",
+ "large": "img/icn-text-panel.svg"
+ }
+ }
+}
+
diff --git a/public/app/plugins/panel/heatmap/color_legend.ts b/public/app/plugins/panel/heatmap/color_legend.ts
index 628186569dd..0e011e59439 100644
--- a/public/app/plugins/panel/heatmap/color_legend.ts
+++ b/public/app/plugins/panel/heatmap/color_legend.ts
@@ -1,12 +1,10 @@
-import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import * as d3 from 'd3';
import { contextSrv } from 'app/core/core';
import { tickStep } from 'app/core/utils/ticks';
import { getColorScale, getOpacityScale } from './color_scale';
-
-const module = angular.module('grafana.directives');
+import coreModule from 'app/core/core_module';
const LEGEND_HEIGHT_PX = 6;
const LEGEND_WIDTH_PX = 100;
@@ -16,7 +14,7 @@ const LEGEND_VALUE_MARGIN = 0;
/**
* Color legend for heatmap editor.
*/
-module.directive('colorLegend', () => {
+coreModule.directive('colorLegend', () => {
return {
restrict: 'E',
template: '
',
@@ -52,7 +50,7 @@ module.directive('colorLegend', () => {
/**
* Heatmap legend with scale values.
*/
-module.directive('heatmapLegend', () => {
+coreModule.directive('heatmapLegend', () => {
return {
restrict: 'E',
template: `
`,
diff --git a/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg b/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg
index a1e15d4d58d..746687d360f 100644
--- a/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg
+++ b/public/app/plugins/panel/singlestat/img/icn-singlestat-panel.svg
@@ -1,33 +1,83 @@
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/public/app/plugins/panel/table/column_options.ts b/public/app/plugins/panel/table/column_options.ts
index ca6d271643b..4c810d9987d 100644
--- a/public/app/plugins/panel/table/column_options.ts
+++ b/public/app/plugins/panel/table/column_options.ts
@@ -41,6 +41,7 @@ export class ColumnOptionsCtrl {
{ text: 'YYYY-MM-DD HH:mm:ss.SSS', value: 'YYYY-MM-DD HH:mm:ss.SSS' },
{ text: 'MM/DD/YY h:mm:ss a', value: 'MM/DD/YY h:mm:ss a' },
{ text: 'MMMM D, YYYY LT', value: 'MMMM D, YYYY LT' },
+ { text: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
];
this.mappingTypes = [{ text: 'Value to text', value: 1 }, { text: 'Range to text', value: 2 }];
diff --git a/public/app/plugins/panel/table/specs/transformers.test.ts b/public/app/plugins/panel/table/specs/transformers.test.ts
index 8d581b68842..49926aa00a8 100644
--- a/public/app/plugins/panel/table/specs/transformers.test.ts
+++ b/public/app/plugins/panel/table/specs/transformers.test.ts
@@ -143,24 +143,6 @@ describe('when transforming time series table', () => {
},
];
- const multipleQueriesDataDifferentLabels = [
- {
- type: 'table',
- columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #A' }],
- rows: [[time, 'Label Value 1', 42]],
- },
- {
- type: 'table',
- columns: [{ text: 'Time' }, { text: 'Label Key 2' }, { text: 'Value #B' }],
- rows: [[time, 'Label Value 2', 13]],
- },
- {
- type: 'table',
- columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #C' }],
- rows: [[time, 'Label Value 3', 7]],
- },
- ];
-
describe('getColumns', () => {
it('should return data columns given a single query', () => {
const columns = transformers[transform].getColumns(singleQueryData);
@@ -177,16 +159,6 @@ describe('when transforming time series table', () => {
expect(columns[3].text).toBe('Value #A');
expect(columns[4].text).toBe('Value #B');
});
-
- it('should return the union of data columns given a multiple queries with different labels', () => {
- const columns = transformers[transform].getColumns(multipleQueriesDataDifferentLabels);
- expect(columns[0].text).toBe('Time');
- expect(columns[1].text).toBe('Label Key 1');
- expect(columns[2].text).toBe('Value #A');
- expect(columns[3].text).toBe('Label Key 2');
- expect(columns[4].text).toBe('Value #B');
- expect(columns[5].text).toBe('Value #C');
- });
});
describe('transform', () => {
@@ -237,26 +209,6 @@ describe('when transforming time series table', () => {
expect(table.rows[1][4]).toBeUndefined();
expect(table.rows[1][5]).toBe(7);
});
-
- it('should return 2 rows for multiple queries with different label values', () => {
- table = transformDataToTable(multipleQueriesDataDifferentLabels, panel);
- expect(table.rows.length).toBe(2);
- expect(table.columns.length).toBe(6);
-
- expect(table.rows[0][0]).toBe(time);
- expect(table.rows[0][1]).toBe('Label Value 1');
- expect(table.rows[0][2]).toBe(42);
- expect(table.rows[0][3]).toBe('Label Value 2');
- expect(table.rows[0][4]).toBe(13);
- expect(table.rows[0][5]).toBeUndefined();
-
- expect(table.rows[1][0]).toBe(time);
- expect(table.rows[1][1]).toBe('Label Value 3');
- expect(table.rows[1][2]).toBeUndefined();
- expect(table.rows[1][3]).toBeUndefined();
- expect(table.rows[1][4]).toBeUndefined();
- expect(table.rows[1][5]).toBe(7);
- });
});
});
});
diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts
index 5a75fa7acf6..c56d385505b 100644
--- a/public/app/plugins/panel/table/transformers.ts
+++ b/public/app/plugins/panel/table/transformers.ts
@@ -1,7 +1,7 @@
import _ from 'lodash';
-import flatten from '../../../core/utils/flatten';
-import TimeSeries from '../../../core/time_series2';
-import TableModel from '../../../core/table_model';
+import flatten from 'app/core/utils/flatten';
+import TimeSeries from 'app/core/time_series2';
+import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
const transformers = {};
@@ -168,97 +168,7 @@ transformers['table'] = {
};
}
- // Single query returns data columns and rows as is
- if (data.length === 1) {
- model.columns = [...data[0].columns];
- model.rows = [...data[0].rows];
- return;
- }
-
- // Track column indexes of union: name -> index
- const columnNames = {};
-
- // Union of all non-value columns
- const columnsUnion = data.reduce((acc, series) => {
- series.columns.forEach(col => {
- const { text } = col;
- if (columnNames[text] === undefined) {
- columnNames[text] = acc.length;
- acc.push(col);
- }
- });
- return acc;
- }, []);
-
- // Map old column index to union index per series, e.g.,
- // given columnNames {A: 0, B: 1} and
- // data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]]
- const columnIndexMapper = data.map(series => series.columns.map(col => columnNames[col.text]));
-
- // Flatten rows of all series and adjust new column indexes
- const flattenedRows = data.reduce((acc, series, seriesIndex) => {
- const mapper = columnIndexMapper[seriesIndex];
- series.rows.forEach(row => {
- const alteredRow = [];
- // Shifting entries according to index mapper
- mapper.forEach((to, from) => {
- alteredRow[to] = row[from];
- });
- acc.push(alteredRow);
- });
- return acc;
- }, []);
-
- // Returns true if both rows have matching non-empty fields as well as matching
- // indexes where one field is empty and the other is not
- function areRowsMatching(columns, row, otherRow) {
- let foundFieldToMatch = false;
- for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
- if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) {
- if (row[columnIndex] !== otherRow[columnIndex]) {
- return false;
- }
- } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) {
- foundFieldToMatch = true;
- }
- }
- return foundFieldToMatch;
- }
-
- // Merge rows that have same values for columns
- const mergedRows = {};
- const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => {
- if (!mergedRows[rowIndex]) {
- // Look from current row onwards
- let offset = rowIndex + 1;
- // More than one row can be merged into current row
- while (offset < flattenedRows.length) {
- // Find next row that could be merged
- const match = _.findIndex(flattenedRows, otherRow => areRowsMatching(columnsUnion, row, otherRow), offset);
- if (match > -1) {
- const matchedRow = flattenedRows[match];
- // Merge values from match into current row if there is a gap in the current row
- for (let columnIndex = 0; columnIndex < columnsUnion.length; columnIndex++) {
- if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) {
- row[columnIndex] = matchedRow[columnIndex];
- }
- }
- // Don't visit this row again
- mergedRows[match] = matchedRow;
- // Keep looking for more rows to merge
- offset = match + 1;
- } else {
- // No match found, stop looking
- break;
- }
- }
- acc.push(row);
- }
- return acc;
- }, []);
-
- model.columns = columnsUnion;
- model.rows = compactedRows;
+ mergeTablesIntoModel(model, ...data);
},
};
diff --git a/public/app/plugins/panel/text2/README.md b/public/app/plugins/panel/text2/README.md
new file mode 100644
index 00000000000..667ab51784a
--- /dev/null
+++ b/public/app/plugins/panel/text2/README.md
@@ -0,0 +1,5 @@
+# Text Panel - Native Plugin
+
+The Text Panel is **included** with Grafana.
+
+The Text Panel is a very simple panel that displays text. The source text is written in the Markdown syntax meaning you can format the text. Read [GitHub's Mastering Markdown](https://guides.github.com/features/mastering-markdown/) to learn more.
diff --git a/public/app/plugins/panel/text2/img/icn-graph-panel.svg b/public/app/plugins/panel/text2/img/icn-graph-panel.svg
new file mode 100644
index 00000000000..463b3d5770b
--- /dev/null
+++ b/public/app/plugins/panel/text2/img/icn-graph-panel.svg
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/app/plugins/panel/text2/module.tsx b/public/app/plugins/panel/text2/module.tsx
new file mode 100644
index 00000000000..b10dc8b545e
--- /dev/null
+++ b/public/app/plugins/panel/text2/module.tsx
@@ -0,0 +1,14 @@
+import React, { PureComponent } from 'react';
+import { PanelProps } from 'app/types';
+
+export class Text2 extends PureComponent {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ return Text Panel! ;
+ }
+}
+
+export { Text2 as PanelComponent };
diff --git a/public/app/plugins/panel/text2/plugin.json b/public/app/plugins/panel/text2/plugin.json
new file mode 100644
index 00000000000..b1133f65e36
--- /dev/null
+++ b/public/app/plugins/panel/text2/plugin.json
@@ -0,0 +1,19 @@
+{
+ "type": "panel",
+ "name": "Text v2",
+ "id": "text2",
+
+ "state": "alpha",
+
+ "info": {
+ "author": {
+ "name": "Grafana Project",
+ "url": "https://grafana.com"
+ },
+ "logos": {
+ "small": "img/icn-graph-panel.svg",
+ "large": "img/icn-graph-panel.svg"
+ }
+ }
+}
+
diff --git a/public/app/core/components/grafana_app.ts b/public/app/routes/GrafanaCtrl.ts
similarity index 92%
rename from public/app/core/components/grafana_app.ts
rename to public/app/routes/GrafanaCtrl.ts
index 4272c8a0b71..75a34ac01c0 100644
--- a/public/app/core/components/grafana_app.ts
+++ b/public/app/routes/GrafanaCtrl.ts
@@ -8,25 +8,31 @@ import appEvents from 'app/core/app_events';
import Drop from 'tether-drop';
import colors from 'app/core/utils/colors';
import { BackendSrv, setBackendSrv } from 'app/core/services/backend_srv';
-import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { TimeSrv, setTimeSrv } from 'app/features/dashboard/time_srv';
+import { DatasourceSrv, setDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { AngularLoader, setAngularLoader } from 'app/core/services/AngularLoader';
import { configureStore } from 'app/store/configureStore';
export class GrafanaCtrl {
/** @ngInject */
constructor(
$scope,
- alertSrv,
utilSrv,
$rootScope,
$controller,
contextSrv,
bridgeSrv,
backendSrv: BackendSrv,
- datasourceSrv: DatasourceSrv
+ timeSrv: TimeSrv,
+ datasourceSrv: DatasourceSrv,
+ angularLoader: AngularLoader
) {
- // sets singleston instances for angular services so react components can access them
- configureStore();
+ // make angular loader service available to react components
+ setAngularLoader(angularLoader);
setBackendSrv(backendSrv);
+ setDatasourceSrv(datasourceSrv);
+ setTimeSrv(timeSrv);
+ configureStore();
$scope.init = () => {
$scope.contextSrv = contextSrv;
@@ -34,11 +40,8 @@ export class GrafanaCtrl {
$scope._ = _;
profiler.init(config, $rootScope);
- alertSrv.init();
utilSrv.init();
bridgeSrv.init();
-
- $scope.dashAlerts = alertSrv;
};
$rootScope.colors = colors;
@@ -81,7 +84,7 @@ function setViewModeBodyClass(body, mode, sidemenuOpen: boolean) {
break;
}
// 1 & true for legacy states
- case 1:
+ case '1':
case true: {
body.removeClass('sidemenu-open');
body.addClass('view-mode--kiosk');
@@ -169,16 +172,16 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
const search = $location.search();
if (options && options.exit) {
- search.kiosk = 1;
+ search.kiosk = '1';
}
switch (search.kiosk) {
case 'tv': {
- search.kiosk = 1;
+ search.kiosk = true;
appEvents.emit('alert-success', ['Press ESC to exit Kiosk mode']);
break;
}
- case 1:
+ case '1':
case true: {
delete search.kiosk;
break;
diff --git a/public/app/routes/registry.ts b/public/app/routes/registry.ts
new file mode 100644
index 00000000000..19699ca738b
--- /dev/null
+++ b/public/app/routes/registry.ts
@@ -0,0 +1,15 @@
+interface RegisterRoutesHandler {
+ ($routeProvider): any;
+}
+
+const handlers: RegisterRoutesHandler[] = [];
+
+export function applyRouteRegistrationHandlers($routeProvider) {
+ for (const handler of handlers) {
+ handler($routeProvider);
+ }
+}
+
+export function addRouteRegistrationHandler(fn: RegisterRoutesHandler) {
+ handlers.push(fn);
+}
diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts
index 015b4ae0b51..6770011278f 100644
--- a/public/app/routes/routes.ts
+++ b/public/app/routes/routes.ts
@@ -1,12 +1,20 @@
import './dashboard_loaders';
import './ReactContainer';
+import { applyRouteRegistrationHandlers } from './registry';
import ServerStats from 'app/features/admin/ServerStats';
import AlertRuleList from 'app/features/alerting/AlertRuleList';
import TeamPages from 'app/features/teams/TeamPages';
import TeamList from 'app/features/teams/TeamList';
+import ApiKeys from 'app/features/api-keys/ApiKeysPage';
+import PluginListPage from 'app/features/plugins/PluginListPage';
import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
import FolderPermissions from 'app/features/folders/FolderPermissions';
+import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
+import NewDataSourcePage from '../features/datasources/NewDataSourcePage';
+import UsersListPage from 'app/features/users/UsersListPage';
+import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards';
+import OrgDetailsPage from '../features/org/OrgDetailsPage';
/** @ngInject */
export function setupAngularRoutes($routeProvider, $locationProvider) {
@@ -61,9 +69,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controllerAs: 'ctrl',
})
.when('/datasources', {
- templateUrl: 'public/app/features/plugins/partials/ds_list.html',
- controller: 'DataSourcesCtrl',
- controllerAs: 'ctrl',
+ template: ' ',
+ resolve: {
+ component: () => DataSourcesListPage,
+ },
})
.when('/datasources/edit/:id', {
templateUrl: 'public/app/features/plugins/partials/ds_edit.html',
@@ -71,14 +80,16 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controllerAs: 'ctrl',
})
.when('/datasources/edit/:id/dashboards', {
- templateUrl: 'public/app/features/plugins/partials/ds_dashboards.html',
- controller: 'DataSourceDashboardsCtrl',
- controllerAs: 'ctrl',
+ template: ' ',
+ resolve: {
+ component: () => DataSourceDashboards,
+ },
})
.when('/datasources/new', {
- templateUrl: 'public/app/features/plugins/partials/ds_edit.html',
- controller: 'DataSourceEditCtrl',
- controllerAs: 'ctrl',
+ template: ' ',
+ resolve: {
+ component: () => NewDataSourcePage,
+ },
})
.when('/dashboards', {
templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_list.html',
@@ -114,23 +125,27 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
})
.when('/explore', {
template: ' ',
+ reloadOnSearch: false,
resolve: {
roles: () => ['Editor', 'Admin'],
component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'),
},
})
.when('/org', {
- templateUrl: 'public/app/features/org/partials/orgDetails.html',
- controller: 'OrgDetailsCtrl',
+ template: ' ',
+ resolve: {
+ component: () => OrgDetailsPage,
+ },
})
.when('/org/new', {
templateUrl: 'public/app/features/org/partials/newOrg.html',
controller: 'NewOrgCtrl',
})
.when('/org/users', {
- templateUrl: 'public/app/features/org/partials/orgUsers.html',
- controller: 'OrgUsersCtrl',
- controllerAs: 'ctrl',
+ template: ' ',
+ resolve: {
+ component: () => UsersListPage,
+ },
})
.when('/org/users/invite', {
templateUrl: 'public/app/features/org/partials/invite.html',
@@ -138,8 +153,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controllerAs: 'ctrl',
})
.when('/org/apikeys', {
- templateUrl: 'public/app/features/org/partials/orgApiKeys.html',
- controller: 'OrgApiKeysCtrl',
+ template: ' ',
+ resolve: {
+ roles: () => ['Editor', 'Admin'],
+ component: () => ApiKeys,
+ },
})
.when('/org/teams', {
template: ' ',
@@ -149,7 +167,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
},
})
.when('/org/teams/new', {
- templateUrl: 'public/app/features/org/partials/create_team.html',
+ templateUrl: 'public/app/features/teams/partials/create_team.html',
controller: 'CreateTeamCtrl',
controllerAs: 'ctrl',
})
@@ -161,12 +179,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
},
})
.when('/profile', {
- templateUrl: 'public/app/features/org/partials/profile.html',
+ templateUrl: 'public/app/features/profile/partials/profile.html',
controller: 'ProfileCtrl',
controllerAs: 'ctrl',
})
.when('/profile/password', {
- templateUrl: 'public/app/features/org/partials/change_password.html',
+ templateUrl: 'public/app/features/profile/partials/change_password.html',
controller: 'ChangePasswordCtrl',
})
.when('/profile/select-org', {
@@ -245,9 +263,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controllerAs: 'ctrl',
})
.when('/plugins', {
- templateUrl: 'public/app/features/plugins/partials/plugin_list.html',
- controller: 'PluginListCtrl',
- controllerAs: 'ctrl',
+ template: ' ',
+ resolve: {
+ component: () => PluginListPage,
+ },
})
.when('/plugins/:pluginId/edit', {
templateUrl: 'public/app/features/plugins/partials/plugin_edit.html',
@@ -267,11 +286,6 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/alerting', {
redirectTo: '/alerting/list',
})
- .when('/alerting/list', {
- templateUrl: 'public/app/features/alerting/partials/alert_list.html',
- controller: 'AlertListCtrl',
- controllerAs: 'ctrl',
- })
.when('/alerting/list', {
template: ' ',
reloadOnSearch: false,
@@ -298,4 +312,6 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
templateUrl: 'public/app/partials/error.html',
controller: 'ErrorCtrl',
});
+
+ applyRouteRegistrationHandlers($routeProvider);
}
diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts
index 8f6cf25043d..e6d86eaa9c6 100644
--- a/public/app/store/configureStore.ts
+++ b/public/app/store/configureStore.ts
@@ -4,22 +4,38 @@ import { createLogger } from 'redux-logger';
import sharedReducers from 'app/core/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
import teamsReducers from 'app/features/teams/state/reducers';
+import apiKeysReducers from 'app/features/api-keys/state/reducers';
import foldersReducers from 'app/features/folders/state/reducers';
import dashboardReducers from 'app/features/dashboard/state/reducers';
+import pluginReducers from 'app/features/plugins/state/reducers';
+import dataSourcesReducers from 'app/features/datasources/state/reducers';
+import usersReducers from 'app/features/users/state/reducers';
+import organizationReducers from 'app/features/org/state/reducers';
-const rootReducer = combineReducers({
+const rootReducers = {
...sharedReducers,
...alertingReducers,
...teamsReducers,
+ ...apiKeysReducers,
...foldersReducers,
...dashboardReducers,
-});
+ ...pluginReducers,
+ ...dataSourcesReducers,
+ ...usersReducers,
+ ...organizationReducers,
+};
export let store;
+export function addRootReducer(reducers) {
+ Object.assign(rootReducers, ...reducers);
+}
+
export function configureStore() {
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+ const rootReducer = combineReducers(rootReducers);
+
if (process.env.NODE_ENV !== 'production') {
// DEV builds we had the logger middleware
store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger())));
diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts
index fa5ace388c4..21f7bdac2d4 100644
--- a/public/app/types/acl.ts
+++ b/public/app/types/acl.ts
@@ -61,6 +61,11 @@ export enum PermissionLevel {
Admin = 4,
}
+export enum DataSourcePermissionLevel {
+ Query = 1,
+ Admin = 2,
+}
+
export enum AclTarget {
Team = 'Team',
User = 'User',
@@ -73,6 +78,10 @@ export interface AclTargetInfo {
text: string;
}
+export const dataSourceAclLevels = [
+ { value: DataSourcePermissionLevel.Query, label: 'Query', description: 'Can query data source.' },
+];
+
export const dashboardAclTargets: AclTargetInfo[] = [
{ value: AclTarget.Team, text: 'Team' },
{ value: AclTarget.User, text: 'User' },
diff --git a/public/app/types/apiKeys.ts b/public/app/types/apiKeys.ts
new file mode 100644
index 00000000000..6cf92011c69
--- /dev/null
+++ b/public/app/types/apiKeys.ts
@@ -0,0 +1,18 @@
+import { OrgRole } from './acl';
+
+export interface ApiKey {
+ id: number;
+ name: string;
+ role: OrgRole;
+}
+
+export interface NewApiKey {
+ name: string;
+ role: OrgRole;
+}
+
+export interface ApiKeysState {
+ keys: ApiKey[];
+ searchQuery: string;
+ hasFetched: boolean;
+}
diff --git a/public/app/types/appNotifications.ts b/public/app/types/appNotifications.ts
new file mode 100644
index 00000000000..81e6cfd55e1
--- /dev/null
+++ b/public/app/types/appNotifications.ts
@@ -0,0 +1,25 @@
+export interface AppNotification {
+ id?: number;
+ severity: AppNotificationSeverity;
+ icon: string;
+ title: string;
+ text: string;
+ timeout: AppNotificationTimeout;
+}
+
+export enum AppNotificationSeverity {
+ Success = 'success',
+ Warning = 'warning',
+ Error = 'error',
+ Info = 'info',
+}
+
+export enum AppNotificationTimeout {
+ Warning = 5000,
+ Success = 3000,
+ Error = 7000,
+}
+
+export interface AppNotificationsState {
+ appNotifications: AppNotification[];
+}
diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts
index 78ff7b0724c..5522f3a11ce 100644
--- a/public/app/types/datasources.ts
+++ b/public/app/types/datasources.ts
@@ -1,7 +1,35 @@
+import { LayoutMode } from '../core/components/LayoutSelector/LayoutSelector';
+import { Plugin, PluginExports, PluginMeta } from './plugins';
+
export interface DataSource {
id: number;
orgId: number;
name: string;
typeLogoUrl: string;
type: string;
+ access: string;
+ url: string;
+ password: string;
+ user: string;
+ database: string;
+ basicAuth: boolean;
+ isDefault: boolean;
+ jsonData: { authType: string; defaultRegion: string };
+ readOnly: boolean;
+ meta?: PluginMeta;
+ pluginExports?: PluginExports;
+ init?: () => void;
+ testDatasource?: () => Promise;
+}
+
+export interface DataSourcesState {
+ dataSources: DataSource[];
+ searchQuery: string;
+ dataSourceTypeSearchQuery: string;
+ layoutMode: LayoutMode;
+ dataSourcesCount: number;
+ dataSourceTypes: Plugin[];
+ dataSource: DataSource;
+ dataSourceMeta: Plugin;
+ hasFetched: boolean;
}
diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts
new file mode 100644
index 00000000000..a96d6c084fb
--- /dev/null
+++ b/public/app/types/explore.ts
@@ -0,0 +1,187 @@
+import { Value } from 'slate';
+
+import { RawTimeRange } from './series';
+
+export interface CompletionItem {
+ /**
+ * The label of this completion item. By default
+ * this is also the text that is inserted when selecting
+ * this completion.
+ */
+ label: string;
+ /**
+ * The kind of this completion item. Based on the kind
+ * an icon is chosen by the editor.
+ */
+ kind?: string;
+ /**
+ * A human-readable string with additional information
+ * about this item, like type or symbol information.
+ */
+ detail?: string;
+ /**
+ * A human-readable string, can be Markdown, that represents a doc-comment.
+ */
+ documentation?: string;
+ /**
+ * A string that should be used when comparing this item
+ * with other items. When `falsy` the `label` is used.
+ */
+ sortText?: string;
+ /**
+ * A string that should be used when filtering a set of
+ * completion items. When `falsy` the `label` is used.
+ */
+ filterText?: string;
+ /**
+ * A string or snippet that should be inserted in a document when selecting
+ * this completion. When `falsy` the `label` is used.
+ */
+ insertText?: string;
+ /**
+ * Delete number of characters before the caret position,
+ * by default the letters from the beginning of the word.
+ */
+ deleteBackwards?: number;
+ /**
+ * Number of steps to move after the insertion, can be negative.
+ */
+ move?: number;
+}
+
+export interface CompletionItemGroup {
+ /**
+ * Label that will be displayed for all entries of this group.
+ */
+ label: string;
+ /**
+ * List of suggestions of this group.
+ */
+ items: CompletionItem[];
+ /**
+ * If true, match only by prefix (and not mid-word).
+ */
+ prefixMatch?: boolean;
+ /**
+ * If true, do not filter items in this group based on the search.
+ */
+ skipFilter?: boolean;
+ /**
+ * If true, do not sort items.
+ */
+ skipSort?: boolean;
+}
+
+interface ExploreDatasource {
+ value: string;
+ label: string;
+}
+
+export interface HistoryItem {
+ ts: number;
+ query: string;
+}
+
+export abstract class LanguageProvider {
+ datasource: any;
+ request: (url) => Promise;
+ /**
+ * Returns a promise that resolves with a task list when main syntax is loaded.
+ * Task list consists of secondary promises that load more detailed language features.
+ */
+ start: () => Promise;
+}
+
+export interface TypeaheadInput {
+ text: string;
+ prefix: string;
+ wrapperClasses: string[];
+ labelKey?: string;
+ value?: Value;
+}
+
+export interface TypeaheadOutput {
+ context?: string;
+ refresher?: Promise<{}>;
+ suggestions: CompletionItemGroup[];
+}
+
+export interface Query {
+ query: string;
+ key?: string;
+}
+
+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 QueryTransaction {
+ id: string;
+ done: boolean;
+ error?: string | JSX.Element;
+ hints?: QueryHint[];
+ latency: number;
+ options: any;
+ query: string;
+ result?: any; // Table model / Timeseries[] / Logs
+ resultType: ResultType;
+ rowIndex: number;
+}
+
+export interface TextMatch {
+ text: string;
+ start: number;
+ length: number;
+ end: number;
+}
+
+export interface ExploreState {
+ StartPage?: any;
+ datasource: any;
+ datasourceError: any;
+ datasourceLoading: boolean | null;
+ datasourceMissing: boolean;
+ datasourceName?: string;
+ exploreDatasources: ExploreDatasource[];
+ graphRange: RawTimeRange;
+ history: HistoryItem[];
+ /**
+ * Initial rows of queries to push down the tree.
+ * Modifications do not end up here, but in `this.queryExpressions`.
+ * The only way to reset a query is to change its `key`.
+ */
+ queries: Query[];
+ /**
+ * Hints gathered for the query row.
+ */
+ queryTransactions: QueryTransaction[];
+ range: RawTimeRange;
+ showingGraph: boolean;
+ showingLogs: boolean;
+ showingTable: boolean;
+ supportsGraph: boolean | null;
+ supportsLogs: boolean | null;
+ supportsTable: boolean | null;
+}
+
+export interface ExploreUrlState {
+ datasource: string;
+ queries: Query[];
+ range: RawTimeRange;
+}
+
+export type ResultType = 'Graph' | 'Logs' | 'Table';
diff --git a/public/app/types/index.ts b/public/app/types/index.ts
index 778a1b21b55..c51622682d4 100644
--- a/public/app/types/index.ts
+++ b/public/app/types/index.ts
@@ -5,8 +5,31 @@ import { NavModel, NavModelItem, NavIndex } from './navModel';
import { FolderDTO, FolderState, FolderInfo } from './folders';
import { DashboardState } from './dashboard';
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
-import { DataSource } from './datasources';
-import { PluginMeta } from './plugins';
+import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
+import { Invitee, OrgUser, User, UsersState, UserState } from './user';
+import { DataSource, DataSourcesState } from './datasources';
+import {
+ TimeRange,
+ LoadingState,
+ TimeSeries,
+ TimeSeriesVM,
+ TimeSeriesVMs,
+ TimeSeriesStats,
+ NullValueMode,
+ DataQuery,
+ DataQueryResponse,
+ DataQueryOptions,
+} from './series';
+import { PanelProps } from './panel';
+import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins';
+import { Organization, OrganizationPreferences, OrganizationState } from './organization';
+import {
+ AppNotification,
+ AppNotificationSeverity,
+ AppNotificationsState,
+ AppNotificationTimeout,
+} from './appNotifications';
+import { DashboardSearchHit } from './search';
export {
Team,
@@ -33,6 +56,37 @@ export {
PermissionLevel,
DataSource,
PluginMeta,
+ ApiKey,
+ ApiKeysState,
+ NewApiKey,
+ Plugin,
+ PluginsState,
+ DataSourcesState,
+ Invitee,
+ OrgUser,
+ User,
+ UsersState,
+ TimeRange,
+ LoadingState,
+ PanelProps,
+ TimeSeries,
+ TimeSeriesVM,
+ TimeSeriesVMs,
+ NullValueMode,
+ TimeSeriesStats,
+ DataQuery,
+ DataQueryResponse,
+ DataQueryOptions,
+ PluginDashboard,
+ Organization,
+ OrganizationState,
+ OrganizationPreferences,
+ AppNotification,
+ AppNotificationsState,
+ AppNotificationSeverity,
+ AppNotificationTimeout,
+ DashboardSearchHit,
+ UserState,
};
export interface StoreState {
@@ -43,4 +97,9 @@ export interface StoreState {
team: TeamState;
folder: FolderState;
dashboard: DashboardState;
+ dataSources: DataSourcesState;
+ users: UsersState;
+ organization: OrganizationState;
+ appNotifications: AppNotificationsState;
+ user: UserState;
}
diff --git a/public/app/types/location.ts b/public/app/types/location.ts
index 4a7f51523a7..7dcf57f7e02 100644
--- a/public/app/types/location.ts
+++ b/public/app/types/location.ts
@@ -2,6 +2,7 @@ export interface LocationUpdate {
path?: string;
query?: UrlQueryMap;
routeParams?: UrlQueryMap;
+ partial?: boolean;
}
export interface LocationState {
diff --git a/public/app/types/organization.ts b/public/app/types/organization.ts
new file mode 100644
index 00000000000..52cb130e082
--- /dev/null
+++ b/public/app/types/organization.ts
@@ -0,0 +1,15 @@
+export interface Organization {
+ name: string;
+ id: number;
+}
+
+export interface OrganizationPreferences {
+ homeDashboardId: number;
+ theme: string;
+ timezone: string;
+}
+
+export interface OrganizationState {
+ organization: Organization;
+ preferences: OrganizationPreferences;
+}
diff --git a/public/app/types/panel.ts b/public/app/types/panel.ts
new file mode 100644
index 00000000000..5ece77fc5aa
--- /dev/null
+++ b/public/app/types/panel.ts
@@ -0,0 +1,7 @@
+import { LoadingState, TimeSeries, TimeRange } from './series';
+
+export interface PanelProps {
+ timeSeries: TimeSeries[];
+ timeRange: TimeRange;
+ loading: LoadingState;
+}
diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts
index d26085f8e73..1b5499f88b7 100644
--- a/public/app/types/plugins.ts
+++ b/public/app/types/plugins.ts
@@ -1,8 +1,38 @@
+export interface PluginExports {
+ PanelCtrl?;
+ PanelComponent?: any;
+ Datasource?: any;
+ QueryCtrl?: any;
+ ConfigCtrl?: any;
+ AnnotationsQueryCtrl?: any;
+ PanelOptions?: any;
+ ExploreQueryField?: any;
+ ExploreStartPage?: any;
+}
+
+export interface PanelPlugin {
+ id: string;
+ name: string;
+ meta: any;
+ hideFromList: boolean;
+ module: string;
+ baseUrl: string;
+ info: any;
+ sort: number;
+ exports?: PluginExports;
+}
+
export interface PluginMeta {
id: string;
name: string;
info: PluginMetaInfo;
includes: PluginInclude[];
+
+ // Datasource-specific
+ metrics?: boolean;
+ logs?: boolean;
+ explore?: boolean;
+ annotations?: boolean;
}
export interface PluginInclude {
@@ -12,8 +42,54 @@ export interface PluginInclude {
}
export interface PluginMetaInfo {
+ author: {
+ name: string;
+ url: string;
+ };
+ description: string;
+ links: string[];
logos: {
large: string;
small: string;
};
+ screenshots: string;
+ updated: string;
+ version: string;
+}
+
+export interface Plugin {
+ defaultNavUrl: string;
+ enabled: boolean;
+ hasUpdate: boolean;
+ id: string;
+ info: PluginMetaInfo;
+ latestVersion: string;
+ name: string;
+ pinned: boolean;
+ state: string;
+ type: string;
+}
+
+export interface PluginDashboard {
+ dashboardId: number;
+ description: string;
+ folderId: number;
+ imported: boolean;
+ importedRevision: number;
+ importedUri: string;
+ importedUrl: string;
+ path: string;
+ pluginId: string;
+ removed: boolean;
+ revision: number;
+ slug: string;
+ title: string;
+}
+
+export interface PluginsState {
+ plugins: Plugin[];
+ searchQuery: string;
+ layoutMode: string;
+ hasFetched: boolean;
+ dashboards: PluginDashboard[];
}
diff --git a/public/app/types/search.ts b/public/app/types/search.ts
new file mode 100644
index 00000000000..e5e17288de1
--- /dev/null
+++ b/public/app/types/search.ts
@@ -0,0 +1,9 @@
+export interface DashboardSearchHit {
+ id: number;
+ tags: string[];
+ title: string;
+ type: string;
+ uid: string;
+ uri: string;
+ url: string;
+}
diff --git a/public/app/types/series.ts b/public/app/types/series.ts
new file mode 100644
index 00000000000..5396880611b
--- /dev/null
+++ b/public/app/types/series.ts
@@ -0,0 +1,91 @@
+import { Moment } from 'moment';
+
+export enum LoadingState {
+ NotStarted = 'NotStarted',
+ Loading = 'Loading',
+ Done = 'Done',
+ Error = 'Error',
+}
+
+export interface RawTimeRange {
+ from: Moment | string;
+ to: Moment | string;
+}
+
+export interface TimeRange {
+ from: Moment;
+ to: Moment;
+ raw: RawTimeRange;
+}
+
+export type TimeSeriesValue = string | number | null;
+
+export type TimeSeriesPoints = TimeSeriesValue[][];
+
+export interface TimeSeries {
+ target: string;
+ datapoints: TimeSeriesPoints;
+ unit?: string;
+}
+
+/** View model projection of a time series */
+export interface TimeSeriesVM {
+ label: string;
+ color: string;
+ data: TimeSeriesValue[][];
+ stats: TimeSeriesStats;
+}
+
+export interface TimeSeriesStats {
+ total: number;
+ max: number;
+ min: number;
+ logmin: number;
+ avg: number | null;
+ current: number | null;
+ first: number | null;
+ delta: number;
+ diff: number | null;
+ range: number | null;
+ timeStep: number;
+ count: number;
+ allIsNull: boolean;
+ allIsZero: boolean;
+}
+
+export enum NullValueMode {
+ Null = 'null',
+ Ignore = 'connected',
+ AsZero = 'null as zero',
+}
+
+/** View model projection of many time series */
+export interface TimeSeriesVMs {
+ [index: number]: TimeSeriesVM;
+}
+
+export interface DataQueryResponse {
+ data: TimeSeries[];
+}
+
+export interface DataQuery {
+ refId: string;
+}
+
+export interface DataQueryOptions {
+ timezone: string;
+ range: TimeRange;
+ rangeRaw: RawTimeRange;
+ targets: DataQuery[];
+ panelId: number;
+ dashboardId: number;
+ cacheTimeout?: string;
+ interval: string;
+ intervalMs: number;
+ maxDataPoints: number;
+ scopedVars: object;
+}
+
+export interface DataSourceApi {
+ query(options: DataQueryOptions): Promise;
+}
diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts
index b85ff3833d6..a524f0dcf87 100644
--- a/public/app/types/teams.ts
+++ b/public/app/types/teams.ts
@@ -23,6 +23,7 @@ export interface TeamGroup {
export interface TeamsState {
teams: Team[];
searchQuery: string;
+ hasFetched: boolean;
}
export interface TeamState {
diff --git a/public/app/types/user.ts b/public/app/types/user.ts
new file mode 100644
index 00000000000..37c80074dca
--- /dev/null
+++ b/public/app/types/user.ts
@@ -0,0 +1,51 @@
+import { DashboardSearchHit } from './search';
+
+export interface OrgUser {
+ avatarUrl: string;
+ email: string;
+ lastSeenAt: string;
+ lastSeenAtAge: string;
+ login: string;
+ orgId: number;
+ role: string;
+ userId: number;
+}
+
+export interface User {
+ id: number;
+ label: string;
+ avatarUrl: string;
+ login: string;
+}
+
+export interface Invitee {
+ code: string;
+ createdOn: string;
+ email: string;
+ emailSent: boolean;
+ emailSentOn: string;
+ id: number;
+ invitedByEmail: string;
+ invitedByLogin: string;
+ invitedByName: string;
+ name: string;
+ orgId: number;
+ role: string;
+ status: string;
+ url: string;
+}
+
+export interface UsersState {
+ users: OrgUser[];
+ invitees: Invitee[];
+ searchQuery: string;
+ canInvite: boolean;
+ externalUserMngLinkUrl: string;
+ externalUserMngLinkName: string;
+ externalUserMngInfo: string;
+ hasFetched: boolean;
+}
+
+export interface UserState {
+ starredDashboards: DashboardSearchHit[];
+}
diff --git a/public/app/viz/Graph.tsx b/public/app/viz/Graph.tsx
new file mode 100644
index 00000000000..fab65225715
--- /dev/null
+++ b/public/app/viz/Graph.tsx
@@ -0,0 +1,124 @@
+// Libraries
+import $ from 'jquery';
+import React, { PureComponent } from 'react';
+import { withSize } from 'react-sizeme';
+import 'vendor/flot/jquery.flot';
+import 'vendor/flot/jquery.flot.time';
+
+// Types
+import { TimeRange, TimeSeriesVMs } from 'app/types';
+
+// Copied from graph.ts
+function time_format(ticks, min, max) {
+ if (min && max && ticks) {
+ const range = max - min;
+ const secPerTick = range / ticks / 1000;
+ const oneDay = 86400000;
+ const oneYear = 31536000000;
+
+ if (secPerTick <= 45) {
+ return '%H:%M:%S';
+ }
+ if (secPerTick <= 7200 || range <= oneDay) {
+ return '%H:%M';
+ }
+ if (secPerTick <= 80000) {
+ return '%m/%d %H:%M';
+ }
+ if (secPerTick <= 2419200 || range <= oneYear) {
+ return '%m/%d';
+ }
+ return '%Y-%m';
+ }
+
+ return '%H:%M';
+}
+
+const FLOT_OPTIONS = {
+ legend: {
+ show: false,
+ },
+ series: {
+ lines: {
+ linewidth: 1,
+ zero: false,
+ },
+ shadowSize: 0,
+ },
+ grid: {
+ minBorderMargin: 0,
+ markings: [],
+ backgroundColor: null,
+ borderWidth: 0,
+ // hoverable: true,
+ clickable: true,
+ color: '#a1a1a1',
+ margin: { left: 0, right: 0 },
+ labelMarginX: 0,
+ },
+};
+
+interface GraphProps {
+ timeSeries: TimeSeriesVMs;
+ timeRange: TimeRange;
+ size?: { width: number; height: number };
+}
+
+export class Graph extends PureComponent {
+ element: any;
+
+ componentDidUpdate(prevProps: GraphProps) {
+ if (
+ prevProps.timeSeries !== this.props.timeSeries ||
+ prevProps.timeRange !== this.props.timeRange ||
+ prevProps.size !== this.props.size
+ ) {
+ this.draw();
+ }
+ }
+
+ componentDidMount() {
+ this.draw();
+ }
+
+ draw() {
+ const { size, timeSeries, timeRange } = this.props;
+
+ if (!size) {
+ return;
+ }
+
+ const ticks = (size.width || 0) / 100;
+ const min = timeRange.from.valueOf();
+ const max = timeRange.to.valueOf();
+
+ const dynamicOptions = {
+ xaxis: {
+ mode: 'time',
+ min: min,
+ max: max,
+ label: 'Datetime',
+ ticks: ticks,
+ timeformat: time_format(ticks, min, max),
+ },
+ };
+
+ const options = {
+ ...FLOT_OPTIONS,
+ ...dynamicOptions,
+ };
+
+ console.log('plot', timeSeries, options);
+ $.plot(this.element, timeSeries, options);
+ }
+
+ render() {
+ return (
+
+
(this.element = e)} />
+
+ );
+ }
+}
+
+export default withSize()(Graph);
diff --git a/public/app/viz/state/timeSeries.ts b/public/app/viz/state/timeSeries.ts
new file mode 100644
index 00000000000..e22cb4681b7
--- /dev/null
+++ b/public/app/viz/state/timeSeries.ts
@@ -0,0 +1,168 @@
+// Libraries
+import _ from 'lodash';
+
+// Utils
+import colors from 'app/core/utils/colors';
+
+// Types
+import { TimeSeries, TimeSeriesVMs, NullValueMode } from 'app/types';
+
+interface Options {
+ timeSeries: TimeSeries[];
+ nullValueMode: NullValueMode;
+}
+
+export function getTimeSeriesVMs({ timeSeries, nullValueMode }: Options): TimeSeriesVMs {
+ const vmSeries = timeSeries.map((item, index) => {
+ const colorIndex = index % colors.length;
+ const label = item.target;
+ const result = [];
+
+ // stat defaults
+ let total = 0;
+ let max = -Number.MAX_VALUE;
+ let min = Number.MAX_VALUE;
+ let logmin = Number.MAX_VALUE;
+ let avg = null;
+ let current = null;
+ let first = null;
+ let delta = 0;
+ let diff = null;
+ let range = null;
+ let timeStep = Number.MAX_VALUE;
+ let allIsNull = true;
+ let allIsZero = true;
+
+ const ignoreNulls = nullValueMode === NullValueMode.Ignore;
+ const nullAsZero = nullValueMode === NullValueMode.AsZero;
+
+ let currentTime;
+ let currentValue;
+ let nonNulls = 0;
+ let previousTime;
+ let previousValue = 0;
+ let previousDeltaUp = true;
+
+ for (let i = 0; i < item.datapoints.length; i++) {
+ currentValue = item.datapoints[i][0];
+ currentTime = item.datapoints[i][1];
+
+ // Due to missing values we could have different timeStep all along the series
+ // so we have to find the minimum one (could occur with aggregators such as ZimSum)
+ if (previousTime !== undefined) {
+ const currentStep = currentTime - previousTime;
+ if (currentStep < timeStep) {
+ timeStep = currentStep;
+ }
+ }
+
+ previousTime = currentTime;
+
+ if (currentValue === null) {
+ if (ignoreNulls) {
+ continue;
+ }
+ if (nullAsZero) {
+ currentValue = 0;
+ }
+ }
+
+ if (currentValue !== null) {
+ if (_.isNumber(currentValue)) {
+ total += currentValue;
+ allIsNull = false;
+ nonNulls++;
+ }
+
+ if (currentValue > max) {
+ max = currentValue;
+ }
+
+ if (currentValue < min) {
+ min = currentValue;
+ }
+
+ if (first === null) {
+ first = currentValue;
+ } else {
+ if (previousValue > currentValue) {
+ // counter reset
+ previousDeltaUp = false;
+ if (i === item.datapoints.length - 1) {
+ // reset on last
+ delta += currentValue;
+ }
+ } else {
+ if (previousDeltaUp) {
+ delta += currentValue - previousValue; // normal increment
+ } else {
+ delta += currentValue; // account for counter reset
+ }
+ previousDeltaUp = true;
+ }
+ }
+ previousValue = currentValue;
+
+ if (currentValue < logmin && currentValue > 0) {
+ logmin = currentValue;
+ }
+
+ if (currentValue !== 0) {
+ allIsZero = false;
+ }
+ }
+
+ result.push([currentTime, currentValue]);
+ }
+
+ if (max === -Number.MAX_VALUE) {
+ max = null;
+ }
+
+ if (min === Number.MAX_VALUE) {
+ min = null;
+ }
+
+ if (result.length && !allIsNull) {
+ avg = total / nonNulls;
+ current = result[result.length - 1][1];
+ if (current === null && result.length > 1) {
+ current = result[result.length - 2][1];
+ }
+ }
+
+ if (max !== null && min !== null) {
+ range = max - min;
+ }
+
+ if (current !== null && first !== null) {
+ diff = current - first;
+ }
+
+ const count = result.length;
+
+ return {
+ data: result,
+ label: label,
+ color: colors[colorIndex],
+ stats: {
+ total,
+ min,
+ max,
+ current,
+ logmin,
+ avg,
+ diff,
+ delta,
+ timeStep,
+ range,
+ count,
+ first,
+ allIsZero,
+ allIsNull,
+ },
+ };
+ });
+
+ return vmSeries;
+}
diff --git a/public/img/grafana_enterprise_typelogo.svg b/public/img/grafana_enterprise_typelogo.svg
new file mode 100644
index 00000000000..30ee925787e
--- /dev/null
+++ b/public/img/grafana_enterprise_typelogo.svg
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/img/grafana_typelogo.svg b/public/img/grafana_typelogo.svg
new file mode 100644
index 00000000000..bfceac7a78d
--- /dev/null
+++ b/public/img/grafana_typelogo.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss
index be3a3b90f78..e4c7a9c59e1 100644
--- a/public/sass/_grafana.scss
+++ b/public/sass/_grafana.scss
@@ -1,4 +1,7 @@
-// vendor
+// DEPENDENCIES
+@import '../../node_modules/react-table/react-table.css';
+
+// VENDOR
@import '../vendor/css/timepicker.css';
@import '../vendor/css/spectrum.css';
@import '../vendor/css/rc-cascader.scss';
@@ -94,7 +97,10 @@
@import 'components/form_select_box';
@import 'components/user-picker';
@import 'components/description-picker';
+@import 'components/viz_editor';
@import 'components/delete_button';
+@import 'components/add_data_source.scss';
+@import 'components/page_loader';
// PAGES
@import 'pages/login';
diff --git a/public/sass/_old_responsive.scss b/public/sass/_old_responsive.scss
index 991b0f30aa1..cfe74d03bb1 100644
--- a/public/sass/_old_responsive.scss
+++ b/public/sass/_old_responsive.scss
@@ -10,6 +10,7 @@
max-width: 120px;
}
+.navbar-buttons--tv,
.navbar-buttons--actions {
display: none;
}
@@ -35,6 +36,7 @@
}
@include media-breakpoint-up(md) {
+ .navbar-buttons--tv,
.navbar-buttons--actions {
display: flex;
}
diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss
index 01590ace585..1896e74d640 100644
--- a/public/sass/_variables.dark.scss
+++ b/public/sass/_variables.dark.scss
@@ -115,8 +115,8 @@ $tight-form-func-bg: #333334;
$tight-form-func-highlight-bg: #444445;
$modal-backdrop-bg: #353c42;
-$code-tag-bg: $gray-1;
-$code-tag-border: lighten($code-tag-bg, 2%);
+$code-tag-bg: $dark-1;
+$code-tag-border: $dark-4;
// cards
$card-background: linear-gradient(135deg, #2f2f32, #262628);
@@ -194,6 +194,7 @@ $input-box-shadow-focus: rgba(102, 175, 233, 0.6);
$input-color-placeholder: $gray-1 !default;
$input-label-bg: $gray-blue;
$input-label-border-color: $dark-3;
+$input-color-select-arrow: $white;
// Search
$search-shadow: 0 0 30px 0 $black;
@@ -304,8 +305,7 @@ $graph-tooltip-bg: $dark-1;
$checkboxImageUrl: '../img/checkbox.png';
// info box
-$info-box-background: linear-gradient(100deg, $blue-dark, darken($blue-dark, 5%));
-$info-box-color: $gray-4;
+$info-box-border-color: darken($blue, 12%);
// footer
$footer-link-color: $gray-2;
diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss
index b6248da6a00..45021a210fd 100644
--- a/public/sass/_variables.light.scss
+++ b/public/sass/_variables.light.scss
@@ -190,6 +190,7 @@ $input-box-shadow-focus: $blue !default;
$input-color-placeholder: $gray-4 !default;
$input-label-bg: $gray-5;
$input-label-border-color: $gray-5;
+$input-color-select-arrow: $gray-1;
// Sidemenu
// -------------------------
@@ -309,8 +310,7 @@ $graph-tooltip-bg: $gray-5;
$checkboxImageUrl: '../img/checkbox_white.png';
// info box
-$info-box-background: linear-gradient(100deg, $blue, darken($blue, 5%));
-$info-box-color: $gray-7;
+$info-box-border-color: lighten($blue, 20%);
// footer
$footer-link-color: $gray-3;
diff --git a/public/sass/components/_add_data_source.scss b/public/sass/components/_add_data_source.scss
new file mode 100644
index 00000000000..508f7f80d8e
--- /dev/null
+++ b/public/sass/components/_add_data_source.scss
@@ -0,0 +1,46 @@
+.add-data-source-header {
+ margin-bottom: $spacer * 2;
+ padding-top: $spacer;
+ text-align: center;
+}
+
+.add-data-source-search {
+ display: flex;
+ justify-content: center;
+ margin-bottom: $panel-margin * 2;
+}
+
+.add-data-source-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-row-gap: 10px;
+ grid-column-gap: 10px;
+
+ @include media-breakpoint-up(md) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.add-data-source-grid-item {
+ padding: 15px;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ background: $card-background;
+ box-shadow: $card-shadow;
+ color: $text-color;
+
+ &:hover {
+ background: $card-background-hover;
+ color: $text-color-strong;
+ }
+}
+
+.add-data-source-grid-item-text {
+ font-size: $font-size-h5;
+}
+
+.add-data-source-grid-item-logo {
+ margin: 0 15px;
+ width: 55px;
+}
diff --git a/public/sass/components/_alerts.scss b/public/sass/components/_alerts.scss
index 3420dcfdfaf..710c4d1ec0f 100644
--- a/public/sass/components/_alerts.scss
+++ b/public/sass/components/_alerts.scss
@@ -7,13 +7,13 @@
.alert {
padding: 1.25rem 2rem 1.25rem 1.5rem;
- margin-bottom: $line-height-base;
+ margin-bottom: $panel-margin / 2;
text-shadow: 0 2px 0 rgba(255, 255, 255, 0.5);
background: $alert-error-bg;
position: relative;
color: $white;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
- border-radius: 2px;
+ border-radius: $border-radius;
display: flex;
flex-direction: row;
}
diff --git a/public/sass/components/_buttons.scss b/public/sass/components/_buttons.scss
index b44f792fd7a..916c141a5ec 100644
--- a/public/sass/components/_buttons.scss
+++ b/public/sass/components/_buttons.scss
@@ -221,6 +221,31 @@ $btn-service-icon-width: 35px;
}
}
+//Toggle button
+
+.toggle-btn {
+ background: $input-label-bg;
+ color: $text-color-weak;
+ box-shadow: $card-shadow;
+
+ &:first-child {
+ border-radius: 2px 0 0 2px;
+ margin: 0;
+ }
+ &:last-child {
+ border-radius: 0 2px 2px 0;
+ margin-left: 0 !important;
+ }
+
+ &.active {
+ background-color: lighten($input-label-bg, 5%);
+ color: $link-color;
+ &:hover {
+ cursor: default;
+ }
+ }
+}
+
//Button animations
.btn-loading span {
diff --git a/public/sass/components/_cards.scss b/public/sass/components/_cards.scss
index 11a8abb7640..f39be84ec04 100644
--- a/public/sass/components/_cards.scss
+++ b/public/sass/components/_cards.scss
@@ -191,6 +191,7 @@
.card-item-wrapper {
padding: 0;
width: 100%;
+ margin-bottom: 3px;
}
.card-item-wrapper--clickable {
@@ -198,7 +199,6 @@
}
.card-item {
- border-bottom: 3px solid $page-bg;
border-radius: 2px;
}
diff --git a/public/sass/components/_dashboard_grid.scss b/public/sass/components/_dashboard_grid.scss
index f1908ca8786..da1f140d252 100644
--- a/public/sass/components/_dashboard_grid.scss
+++ b/public/sass/components/_dashboard_grid.scss
@@ -20,7 +20,6 @@
}
// Disable grid interaction indicators in fullscreen panels
-
.panel-header:hover {
background-color: inherit;
}
@@ -32,6 +31,11 @@
.react-resizable-handle {
display: none;
}
+
+ // the react-grid has a height transition
+ .react-grid-layout {
+ transition-property: none;
+ }
}
@include media-breakpoint-down(sm) {
diff --git a/public/sass/components/_footer.scss b/public/sass/components/_footer.scss
index 3c30f5a5798..9bc99c30d1d 100644
--- a/public/sass/components/_footer.scss
+++ b/public/sass/components/_footer.scss
@@ -40,15 +40,15 @@
.login-page {
.footer {
+ bottom: $spacer;
+ position: absolute;
padding: 1rem 0 1rem 0;
}
}
-@include media-breakpoint-up(md) {
+@include media-breakpoint-up(sm) {
.login-page {
.footer {
- bottom: $spacer;
- position: absolute;
padding: 5rem 0 1rem 0;
}
}
diff --git a/public/sass/components/_form_select_box.scss b/public/sass/components/_form_select_box.scss
index 0401f06eba8..166baeb2f8f 100644
--- a/public/sass/components/_form_select_box.scss
+++ b/public/sass/components/_form_select_box.scss
@@ -1,19 +1,5 @@
$select-input-height: 35px;
-$select-menu-max-height: 300px;
-$select-item-font-size: $font-size-base;
-$select-item-bg: $dropdownBackground;
-$select-item-fg: $input-color;
-$select-option-bg: $dropdownBackground;
-$select-option-color: $input-color;
-$select-noresults-color: $text-color;
-$select-input-bg: $input-bg;
-$select-input-border-color: $input-border-color;
-$select-menu-box-shadow: $menu-dropdown-shadow;
-$select-text-color: $text-color;
$select-input-bg-disabled: $input-bg-disabled;
-$select-option-selected-bg: $dropdownLinkBackgroundActive;
-
-@import '../../../node_modules/react-select/scss/default.scss';
@mixin select-control() {
width: 100%;
@@ -29,79 +15,113 @@ $select-option-selected-bg: $dropdownLinkBackgroundActive;
@include box-shadow($shadow);
}
-// react-select tweaks
-.gf-form-input--form-dropdown {
- padding: 0;
- border: 0;
- overflow: visible;
-
- .Select-placeholder {
- color: $input-color-placeholder;
- }
-
- > .Select-control {
- @include select-control();
- border-color: $dark-3;
-
- input {
- min-width: 1rem;
- }
-
- .Select-clear,
- .Select-arrow {
- margin-right: 8px;
- }
-
- .Select-value {
- display: inline-block;
- padding: $input-padding-y $input-padding-x;
- font-size: $font-size-md;
- line-height: $input-line-height;
- vertical-align: baseline;
- white-space: nowrap;
- }
- }
-
- &.is-open > .Select-control {
- background: transparent;
- border-color: $dark-3;
- }
-
- &.is-focused > .Select-control {
- background-color: $input-bg;
- @include select-control-focus();
- }
-
- &.is-focused:not(.is-open) > .Select-control {
- background-color: $input-bg;
- @include select-control-focus();
- }
-
- .Select-menu-outer {
- border: 0;
- width: auto;
- }
-
- .Select-option.is-focused {
- background-color: $dropdownLinkBackgroundHover;
- color: $dropdownLinkColorHover;
-
- &::before {
- position: absolute;
- left: 0;
- top: 0;
- height: 100%;
- width: 2px;
- display: block;
- content: '';
- background-image: linear-gradient(to bottom, #ffd500 0%, #ff4400 99%, #ff4400 100%);
- }
- }
+.gf-form-select-box__control {
+ @include select-control();
+ border: 1px solid $input-border-color;
+ color: $input-color;
+ cursor: default;
+ display: table;
+ border-spacing: 0;
+ border-collapse: separate;
+ height: $select-input-height;
+ outline: none;
+ overflow: hidden;
+ position: relative;
}
-.gf-form-input--form-dropdown-right {
- .Select-menu-outer {
+.gf-form-select-box__control--is-focused {
+ background-color: $input-bg;
+ @include select-control-focus();
+}
+
+.gf-form-select-box__control--is-disabled {
+ background-color: $select-input-bg-disabled;
+}
+
+.gf-form-select-box__control--menu-right {
+ .gf-form-select-box__menu {
right: 0;
left: unset;
}
}
+
+.gf-form-select-box__input {
+ padding-left: 5px;
+}
+
+.gf-form-select-box__menu {
+ background: $dropdownBackground;
+ position: absolute;
+ z-index: 2;
+}
+
+.tag-filter .gf-form-select-box__menu {
+ width: 100%;
+}
+
+.gf-form-select-box__multi-value {
+ display: inline;
+}
+
+.gf-form-select-box__option {
+ border-left: 2px solid transparent;
+
+ &.gf-form-select-box__option--is-focused {
+ color: $dropdownLinkColorHover;
+ background-color: $dropdownLinkBackgroundHover;
+ @include left-brand-border-gradient();
+ }
+
+ &.gf-form-select-box__option--is-selected {
+ .fa {
+ color: $input-color-select-arrow;
+ }
+ }
+}
+
+.gf-form-select-box__control--is-focused .gf-form-select-box__placeholder {
+ display: none;
+}
+
+.gf-form-select-box__value-container {
+ display: table-cell;
+ padding: 8px 10px;
+ > div {
+ display: inline-block;
+ }
+}
+
+.gf-form-select-box__indicators {
+ display: table-cell;
+ vertical-align: middle;
+ text-align: right;
+ padding-right: 10px;
+ width: 20px;
+}
+
+.gf-form-select-box__select-arrow {
+ border-color: $input-color-select-arrow transparent transparent;
+ border-style: solid;
+ border-width: 5px 5px 2.5px;
+ display: inline-block;
+ height: 0;
+ width: 0;
+ position: relative;
+
+ &.gf-form-select-box__select-arrow--reversed {
+ border-color: transparent transparent $input-color-select-arrow;
+ top: -2px;
+ border-width: 0 5px 5px;
+ }
+}
+.gf-form-input--form-dropdown {
+ padding: 0;
+ border: 0;
+ overflow: visible;
+}
+
+.gf-form--has-input-icon {
+ .gf-form-select-box__value-container {
+ padding-left: 30px;
+ }
+}
diff --git a/public/sass/components/_infobox.scss b/public/sass/components/_infobox.scss
index 9a6a2e78a4f..fb2222dd9db 100644
--- a/public/sass/components/_infobox.scss
+++ b/public/sass/components/_infobox.scss
@@ -1,16 +1,13 @@
.grafana-info-box {
position: relative;
- background: $info-box-background;
- box-shadow: $card-shadow;
- padding: 1rem;
- border-radius: 4px;
+ padding: 1.5rem;
+ background-color: $empty-list-cta-bg;
+ margin-bottom: 2rem;
+ border-top: 3px solid $info-box-border-color;
margin-bottom: $spacer;
margin-right: $gf-form-margin;
+ box-shadow: $card-shadow;
flex-grow: 1;
- color: $info-box-color;
- h5 {
- color: $info-box-color;
- }
h5 {
margin-bottom: $spacer;
@@ -19,6 +16,19 @@
padding-left: $spacer * 1.5;
}
+ code {
+ @include font-family-monospace();
+ font-size: $font-size-base - 2;
+ background-color: $code-tag-bg;
+ color: $text-color;
+ border: 1px solid $code-tag-border;
+ border-radius: 4px;
+ }
+
+ p:last-child {
+ margin-bottom: 0;
+ }
+
a {
@extend .external-link;
}
diff --git a/public/sass/components/_page_loader.scss b/public/sass/components/_page_loader.scss
new file mode 100644
index 00000000000..053a061600f
--- /dev/null
+++ b/public/sass/components/_page_loader.scss
@@ -0,0 +1,16 @@
+.page-loader-wrapper {
+ padding-top: 100px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+
+ &__spinner {
+ font-size: 32px;
+ margin-bottom: $panel-margin;
+ }
+
+ &__text {
+ font-size: 14px;
+ }
+}
diff --git a/public/sass/components/_panel_add_panel.scss b/public/sass/components/_panel_add_panel.scss
index 5bfff31a108..263b181262e 100644
--- a/public/sass/components/_panel_add_panel.scss
+++ b/public/sass/components/_panel_add_panel.scss
@@ -85,10 +85,6 @@
height: calc(100% - 15px);
}
-.add-panel__item-icon {
- padding: 2px;
-}
-
.add-panel__searchbar {
width: 100%;
margin-bottom: 10px;
diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss
index 01fcc5a3e64..9f7a9575b61 100644
--- a/public/sass/components/_panel_graph.scss
+++ b/public/sass/components/_panel_graph.scss
@@ -14,7 +14,7 @@
.graph-legend-series {
display: block;
- padding-left: 0px;
+ padding-left: 4px;
}
.graph-legend-table .graph-legend-series {
@@ -28,6 +28,7 @@
position: relative;
cursor: crosshair;
flex-grow: 1;
+ min-height: 65%;
}
.datapoints-warning {
@@ -46,15 +47,12 @@
.graph-legend {
display: flex;
flex: 0 1 auto;
- max-height: 30%;
+ max-height: 35%;
margin: 0;
text-align: center;
padding-top: 6px;
position: relative;
- // fix for Firefox (white stripe on the right of scrollbar)
- width: calc(100% - 1px);
-
.popover-content {
padding: 0;
}
@@ -62,15 +60,6 @@
.graph-legend-content {
position: relative;
-
- // fix for Firefox (white stripe on the right of scrollbar)
- width: calc(100% - 1px);
-}
-
-.graph-legend-scroll {
- position: relative;
- overflow: auto !important;
- padding: 1px;
}
.graph-legend-icon {
@@ -82,8 +71,8 @@
.graph-legend-icon,
.graph-legend-alias,
.graph-legend-value {
+ display: inline;
cursor: pointer;
- float: left;
white-space: nowrap;
font-size: 85%;
text-align: left;
@@ -120,6 +109,11 @@
}
}
+// Don't move series to the right if legend is on the right as well
+.graph-panel--legend-right .graph-legend-series--right-y {
+ float: left;
+}
+
.graph-legend-value {
padding-left: 6px;
}
@@ -128,7 +122,8 @@
.body--phantomjs {
.graph-panel--legend-right {
.graph-legend {
- display: inline-block;
+ display: block;
+ max-width: min-content;
}
.graph-panel__chart {
@@ -137,24 +132,15 @@
.graph-legend-table {
display: table;
-
- .graph-legend-scroll {
- display: table;
- }
+ width: auto;
}
}
}
.graph-legend-table {
- tbody {
- display: block;
- position: relative;
- overflow-y: auto;
- overflow-x: hidden;
- padding-bottom: 1px;
- padding-right: 5px;
- padding-left: 5px;
- }
+ padding-bottom: 1px;
+ padding-right: 5px;
+ padding-left: 5px;
.graph-legend-series {
display: table-row;
diff --git a/public/sass/components/_scrollbar.scss b/public/sass/components/_scrollbar.scss
index adb9e0c54c0..00bd5f7c94c 100644
--- a/public/sass/components/_scrollbar.scss
+++ b/public/sass/components/_scrollbar.scss
@@ -307,6 +307,7 @@
.view {
display: flex;
flex-grow: 1;
+ flex-direction: column;
}
.track-vertical {
@@ -337,3 +338,7 @@
border-radius: 6px;
}
}
+
+.scroll-margin-helper {
+ margin-right: 12px;
+}
diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss
index b1211bcbdee..daad8fd10da 100644
--- a/public/sass/components/_search.scss
+++ b/public/sass/components/_search.scss
@@ -210,21 +210,22 @@
.search-item__body-title {
color: $list-item-link-color;
- line-height: 14px;
}
.search-item__body-folder-title {
color: $text-color-weak;
font-size: $font-size-xs;
line-height: 11px;
+ position: relative;
+ top: -1px;
}
.search-item__icon {
- padding: 5px;
+ display: flex;
+ align-items: center;
flex: 0 0 auto;
font-size: 19px;
- line-height: 22px;
- padding: 5px 2px 5px 10px;
+ padding: 0 2px 0 10px;
}
.search-item__tags {
diff --git a/public/sass/components/_slate_editor.scss b/public/sass/components/_slate_editor.scss
index 10b2238f4b8..b70990d4618 100644
--- a/public/sass/components/_slate_editor.scss
+++ b/public/sass/components/_slate_editor.scss
@@ -2,6 +2,7 @@
font-size: $font-size-root;
font-family: $font-family-monospace;
height: auto;
+ word-break: break-word;
}
.slate-query-field-wrapper {
@@ -66,7 +67,6 @@
.typeahead-item__selected {
background-color: $typeahead-selected-bg;
- color: $typeahead-selected-color;
.typeahead-item-hint {
font-size: $font-size-xs;
@@ -74,6 +74,14 @@
white-space: normal;
}
}
+
+ .typeahead-match {
+ color: $typeahead-selected-color;
+ border-bottom: 1px solid $typeahead-selected-color;
+ // Undoing mark styling
+ padding: inherit;
+ background: inherit;
+ }
}
/* SYNTAX */
diff --git a/public/sass/components/_tabbed_view.scss b/public/sass/components/_tabbed_view.scss
index bf95d453504..87b43a31142 100644
--- a/public/sass/components/_tabbed_view.scss
+++ b/public/sass/components/_tabbed_view.scss
@@ -1,19 +1,15 @@
.tabbed-view {
- padding: $spacer*3;
- margin-bottom: $dashboard-padding;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
- &.tabbed-view--panel-edit {
- padding: 0;
-
- .tabbed-view-header {
- padding: 0px 25px;
- background: none;
- }
+ &.tabbed-view--new {
+ padding: 25px 0 0 0;
+ height: 100%;
}
}
.tabbed-view-header {
- background: $page-header-bg;
box-shadow: $page-header-shadow;
border-bottom: 1px solid $page-header-border-color;
@include clearfix();
@@ -48,7 +44,10 @@
}
.tabbed-view-body {
- padding: $spacer*2 $spacer;
+ padding: $spacer*2 $spacer $spacer $spacer;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
&--small {
min-height: 0px;
diff --git a/public/sass/components/_tagsinput.scss b/public/sass/components/_tagsinput.scss
index 915a0f6d480..f4f0ed4c84d 100644
--- a/public/sass/components/_tagsinput.scss
+++ b/public/sass/components/_tagsinput.scss
@@ -19,7 +19,7 @@
.tag {
margin-right: 2px;
- color: white;
+ color: $white;
[data-role='remove'] {
margin-left: 8px;
@@ -63,13 +63,6 @@
.tag-count-label {
margin-left: 3px;
}
-
- .gf-form-input--form-dropdown {
- .Select-menu-outer {
- border: 0;
- width: 100%;
- }
- }
}
.tag-filter-values {
diff --git a/public/sass/components/_viz_editor.scss b/public/sass/components/_viz_editor.scss
new file mode 100644
index 00000000000..048e513cfbb
--- /dev/null
+++ b/public/sass/components/_viz_editor.scss
@@ -0,0 +1,81 @@
+.viz-editor {
+ display: flex;
+ height: 100%;
+}
+
+.viz-editor-col1 {
+ width: 210px;
+ height: 100%;
+ margin-right: 40px;
+}
+
+.viz-editor-col2 {
+ flex-grow: 1;
+}
+
+.viz-picker {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.viz-picker__search {
+ flex-grow: 0;
+}
+
+.viz-picker__items {
+ flex-grow: 1;
+ height: calc(100% - 50px);
+}
+
+.viz-picker__item {
+ background: $card-background;
+ box-shadow: $card-shadow;
+
+ border-radius: 3px;
+ padding: $spacer;
+ width: 100%;
+ height: 60px;
+ text-align: center;
+ margin-bottom: 6px;
+ cursor: pointer;
+ display: flex;
+ flex-shrink: 0;
+ border: 1px solid transparent;
+ @include left-brand-border;
+
+ &:hover {
+ background: $card-background-hover;
+ }
+
+ &--selected {
+ // border: 1px solid $orange;
+ @include left-brand-border-gradient();
+
+ .viz-picker__item-name {
+ color: $text-color;
+ }
+
+ .viz-picker__item-img {
+ filter: saturate(100%);
+ }
+ }
+}
+
+.viz-picker__item-name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ font-size: $font-size-h5;
+ display: flex;
+ flex-direction: column;
+ align-self: center;
+ padding-left: $spacer;
+ font-size: $font-size-md;
+ color: $text-muted;
+}
+
+.viz-picker__item-img {
+ height: 100%;
+ filter: saturate(30%);
+}
diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss
index 6225f840973..795766a22de 100644
--- a/public/sass/pages/_dashboard.scss
+++ b/public/sass/pages/_dashboard.scss
@@ -1,7 +1,12 @@
.dashboard-container {
padding: $dashboard-padding $dashboard-padding 0 $dashboard-padding;
width: 100%;
- min-height: 100%;
+ height: 100%;
+ box-sizing: border-box;
+
+ &--has-submenu {
+ height: calc(100% - 50px);
+ }
}
.template-variable {
@@ -16,6 +21,9 @@ div.flot-text {
height: 100%;
&--solo {
+ position: fixed;
+ bottom: 0;
+ right: 0;
margin: 0;
.panel-container {
border: none;
@@ -29,16 +37,43 @@ div.flot-text {
height: 100%;
}
+.panel-editor-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.panel-editor-container__panel {
+ height: 35%;
+}
+
+.panel-editor-container__editor {
+ height: 65%;
+}
+
.panel-container {
background-color: $panel-bg;
border: $panel-border;
position: relative;
border-radius: 3px;
+ height: 100%;
&.panel-transparent {
background-color: transparent;
border: none;
}
+
+ &:hover {
+ .panel-menu-toggle {
+ visibility: visible;
+ transition: opacity 0.1s ease-in 0.2s;
+ opacity: 1;
+ }
+ }
+
+ &--is-editing {
+ height: auto;
+ }
}
.panel-content {
@@ -199,14 +234,6 @@ div.flot-text {
}
}
-.panel-hover-highlight {
- .panel-menu-toggle {
- visibility: visible;
- transition: opacity 0.1s ease-in 0.2s;
- opacity: 1;
- }
-}
-
.panel-time-info {
font-weight: bold;
float: right;
@@ -233,5 +260,5 @@ div.flot-text {
}
.panel-full-edit {
- margin: $dashboard-padding (-$dashboard-padding) 0 (-$dashboard-padding);
+ padding-top: $dashboard-padding;
}
diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss
index 903193d8b10..b70b058879c 100644
--- a/public/sass/pages/_explore.scss
+++ b/public/sass/pages/_explore.scss
@@ -52,10 +52,29 @@
}
.result-options {
- margin-top: 2 * $panel-margin;
+ margin: 2 * $panel-margin 0;
}
- .elapsed-time {
+ .time-series-disclaimer {
+ width: 300px;
+ margin: $panel-margin auto;
+ padding: 10px 0;
+ border-radius: $border-radius;
+ text-align: center;
+ background-color: $panel-bg;
+
+ .disclaimer-icon {
+ color: $yellow;
+ margin-right: $panel-margin/2;
+ }
+
+ .show-all-time-series {
+ cursor: pointer;
+ color: $external-link-color;
+ }
+ }
+
+ .navbar .elapsed-time {
position: absolute;
left: 0;
right: 0;
@@ -68,8 +87,39 @@
flex-wrap: wrap;
}
+ .explore-panel__loader {
+ height: 2px;
+ position: relative;
+ overflow: hidden;
+ background: $text-color-faint;
+ margin: $panel-margin / 2;
+ }
+
+ .explore-panel__loader:after {
+ content: ' ';
+ display: block;
+ width: 25%;
+ top: 0;
+ top: -50%;
+ height: 250%;
+ position: absolute;
+ animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67);
+ animation-iteration-count: 100;
+ z-index: 2;
+ background: $blue;
+ }
+
+ @keyframes loader {
+ from {
+ left: -25%;
+ }
+ to {
+ left: 100%;
+ }
+ }
+
.datasource-picker {
- min-width: 10rem;
+ min-width: 200px;
}
.timepicker {
@@ -100,6 +150,7 @@
.query-row {
display: flex;
+ position: relative;
& + & {
margin-top: 0.5rem;
@@ -107,7 +158,18 @@
}
.query-row-tools {
- width: 6rem;
+ white-space: nowrap;
+}
+
+.query-row-status {
+ position: absolute;
+ top: 0;
+ right: 90px;
+ z-index: 1024;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 34px;
}
.query-row-field {
@@ -115,6 +177,37 @@
width: 100%;
}
+.query-transactions {
+ display: table;
+}
+
+.query-transaction {
+ display: table-row;
+ color: $text-color-faint;
+ line-height: 1.44;
+}
+
+.query-transaction--loading {
+ animation: query-loading-color-change 1s alternate 100;
+}
+
+@keyframes query-loading-color-change {
+ from {
+ color: $text-color-faint;
+ }
+ to {
+ color: $blue;
+ }
+}
+
+.query-transaction__type,
+.query-transaction__duration {
+ display: table-cell;
+ font-size: $font-size-xs;
+ text-align: right;
+ padding-right: 0.25em;
+}
+
.explore {
.logs {
.logs-entries {
@@ -126,7 +219,13 @@
}
.logs-row-match-highlight {
- background-color: lighten($blue, 20%);
+ // Undoing mark styling
+ background: inherit;
+ padding: inherit;
+
+ color: $typeahead-selected-color;
+ border-bottom: 1px solid $typeahead-selected-color;
+ background-color: lighten($typeahead-selected-color, 60%);
}
.logs-row-level {
@@ -165,5 +264,93 @@
.prom-query-field-info {
margin: 0.25em 0.5em 0.5em;
+ display: flex;
+
+ details {
+ margin-left: 1em;
+ }
}
}
+
+// ReactTable basic overrides (does not include pivot/groups/filters)
+// When integrating ReactTable as new panel plugin, move to _panel_table.scss
+
+.ReactTable {
+ border: none;
+ // Allow some space for the no-data text
+ min-height: 120px;
+}
+
+.ReactTable .rt-thead.-header {
+ box-shadow: none;
+ background: $list-item-bg;
+ border-top: 2px solid $body-bg;
+ border-bottom: 2px solid $body-bg;
+ height: 2em;
+}
+.ReactTable .rt-thead.-header .rt-th {
+ text-align: left;
+ color: $blue;
+ font-weight: 500;
+}
+.ReactTable .rt-thead .rt-td,
+.ReactTable .rt-thead .rt-th {
+ padding: 0.45em 0 0.45em 1.1em;
+ border-right: none;
+ box-shadow: none;
+}
+.ReactTable .rt-tbody .rt-td {
+ padding: 0.45em 0 0.45em 1.1em;
+ border-bottom: 2px solid $body-bg;
+ border-right: 2px solid $body-bg;
+}
+.ReactTable .rt-tbody .rt-td:last-child {
+ border-right: none;
+}
+.ReactTable .-pagination .-btn {
+ color: $blue;
+ background: $list-item-bg;
+}
+.ReactTable .-pagination input,
+.ReactTable .-pagination select {
+ color: $input-color;
+ background-color: $input-bg;
+}
+.ReactTable .-loading {
+ background: $input-bg;
+}
+.ReactTable .-loading.-active {
+ opacity: 0.8;
+}
+.ReactTable .-loading > div {
+ color: $input-color;
+}
+.ReactTable .rt-tr .rt-td:last-child {
+ text-align: right;
+}
+
+// React-component cascade fix: show "loading" even though item can expand
+
+.rc-cascader-menu-item-loading:after {
+ position: absolute;
+ right: 12px;
+ content: 'loading';
+ color: #767980;
+ font-style: italic;
+}
+
+// TODO Experimental
+
+.cheat-sheet-item {
+ margin: 2*$panel-margin 0;
+ width: 50%;
+}
+
+.cheat-sheet-item__title {
+ font-size: $font-size-h3;
+}
+
+.cheat-sheet-item__expression {
+ margin: $panel-margin/2 0;
+ cursor: pointer;
+}
diff --git a/public/sass/pages/_login.scss b/public/sass/pages/_login.scss
index 8e5c8f33e37..4baff47b2a8 100644
--- a/public/sass/pages/_login.scss
+++ b/public/sass/pages/_login.scss
@@ -1,7 +1,7 @@
$login-border: #8daac5;
.login {
- min-height: 85vh;
+ min-height: 100vh;
background-position: center;
background-repeat: no-repeat;
min-width: 100%;
@@ -97,6 +97,7 @@ select:-webkit-autofill:focus {
.login-content {
max-width: 700px;
+ width: 100%;
display: flex;
align-items: stretch;
flex-direction: column;
@@ -120,18 +121,29 @@ select:-webkit-autofill:focus {
width: 70px;
margin-bottom: 15px;
}
+}
- .icon-gf-grafana_wordmark {
- color: darken($white, 11%);
- position: relative;
- font-size: 2rem;
- text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3);
+.app-grafana {
+ .logo-wordmark {
+ background: url('../img/grafana_typelogo.svg') top center no-repeat;
+ width: 100%;
+ height: 70px;
+ }
+}
+
+.app-enterprise {
+ .logo-wordmark {
+ background: url('../img/grafana_enterprise_typelogo.svg') top center no-repeat;
+ width: 100%;
+ height: 70px;
}
}
.login-outer-box {
display: flex;
overflow-y: hidden;
+ align-items: center;
+ justify-content: center;
}
.login-inner-box {
@@ -143,6 +155,7 @@ select:-webkit-autofill:focus {
justify-content: center;
flex-grow: 1;
max-width: 415px;
+ width: 100%;
transform: tranlate(0px, 0px);
transition: 0.25s ease;
@@ -324,23 +337,19 @@ select:-webkit-autofill:focus {
}
@include media-breakpoint-up(sm) {
- .login-content {
- flex-direction: row;
- }
-
.login-branding {
- width: 35%;
- padding: 4rem 2rem;
- border-right: 1px solid $login-border;
+ padding: 1rem;
.logo-icon {
width: 80px;
}
}
+}
- .login-inner-box {
- width: 65%;
- padding: 1rem 2rem;
+@include media-breakpoint-up(md) {
+ .login-content {
+ flex-direction: row;
+ flex: 1 0 100%;
}
.login-divider {
@@ -348,29 +357,16 @@ select:-webkit-autofill:focus {
width: 110px;
}
}
-}
-
-@include media-breakpoint-up(md) {
- .login {
- min-height: 100vh;
- }
-
- .login-content {
- flex: 1 0 100%;
- }
.login-branding {
width: 45%;
- padding: 2rem 4rem;
+ padding: 2rem;
flex-grow: 1;
+ border-right: 1px solid $login-border;
.logo-icon {
width: 130px;
}
-
- .icon-gf-grafana_wordmark {
- font-size: 3.2rem;
- }
}
.login-inner-box {
@@ -386,9 +382,7 @@ select:-webkit-autofill:focus {
padding-top: 0;
padding-left: 10px;
}
-}
-@include media-breakpoint-up(lg) {
.login-form-input {
min-width: 300px;
}
diff --git a/public/sass/pages/_playlist.scss b/public/sass/pages/_playlist.scss
index 5dd1c92cbd2..b8802940818 100644
--- a/public/sass/pages/_playlist.scss
+++ b/public/sass/pages/_playlist.scss
@@ -84,11 +84,11 @@
background-color: $list-item-bg;
margin-bottom: 4px;
.search-result-icon:before {
- content: "\f009";
+ content: '\f009';
}
&.search-item-dash-home .search-result-icon:before {
- content: "\f015";
+ content: '\f015';
}
}
@@ -105,7 +105,10 @@
.playlist-available-list {
td {
line-height: 2rem;
+ max-width: 335px;
white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
}
.add-dashboard {
diff --git a/public/vendor/flot/jquery.flot.js b/public/vendor/flot/jquery.flot.js
index 4a85b08c8d7..daa3ac6bb8e 100644
--- a/public/vendor/flot/jquery.flot.js
+++ b/public/vendor/flot/jquery.flot.js
@@ -2271,9 +2271,51 @@ Licensed under the MIT license.
});
}
+ function drawOrphanedPoints(series) {
+ /* Filters series data for points with no neighbors before or after
+ * and plots single 0.5 radius points for them so that they are displayed.
+ */
+ var abandonedPoints = [];
+ var beforeX = null;
+ var afterX = null;
+ var datapoints = series.datapoints;
+ // find any points with no neighbors before or after
+ var emptyPoints = [];
+ for (var j = 0; j < datapoints.pointsize - 2; j++) {
+ emptyPoints.push(0);
+ }
+ for (var i = 0; i < datapoints.points.length; i += datapoints.pointsize) {
+ var x = datapoints.points[i], y = datapoints.points[i + 1];
+ if (i === datapoints.points.length - datapoints.pointsize) {
+ afterX = null;
+ } else {
+ afterX = datapoints.points[i + datapoints.pointsize];
+ }
+ if (x !== null && y !== null && beforeX === null && afterX === null) {
+ abandonedPoints.push(x);
+ abandonedPoints.push(y);
+ abandonedPoints.push.apply(abandonedPoints, emptyPoints);
+ }
+ beforeX = x;
+
+ }
+ var olddatapoints = datapoints.points
+ datapoints.points = abandonedPoints;
+
+ series.points.radius = series.lines.lineWidth/2;
+ // plot the orphan points with a radius of lineWidth/2
+ drawSeriesPoints(series);
+ // reset old info
+ datapoints.points = olddatapoints;
+ }
+
function drawSeries(series) {
if (series.lines.show)
drawSeriesLines(series);
+ if (!series.points.show && !series.bars.show) {
+ // not necessary if user wants points displayed for everything
+ drawOrphanedPoints(series);
+ }
if (series.bars.show)
drawSeriesBars(series);
if (series.points.show)
diff --git a/public/views/error.html b/public/views/error-template.html
similarity index 98%
rename from public/views/error.html
rename to public/views/error-template.html
index 5c51e28eff4..af430fc6c3d 100644
--- a/public/views/error.html
+++ b/public/views/error-template.html
@@ -10,7 +10,7 @@
-
+
diff --git a/public/views/index.template.html b/public/views/index-template.html
similarity index 90%
rename from public/views/index.template.html
rename to public/views/index-template.html
index ec51a12d34f..096684d3e37 100644
--- a/public/views/index.template.html
+++ b/public/views/index-template.html
@@ -14,22 +14,18 @@
+
+
+
-
+