diff --git a/.circleci/config.yml b/.circleci/config.yml index e631e0a8d33..2225e0a16cf 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/...' diff --git a/.gitignore b/.gitignore index 78b8d075ef6..08525d92519 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ debug.test *.orig /devenv/bulk-dashboards/*.json +/devenv/bulk_alerting_dashboards/*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ace4348af99..beafeda9dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,30 @@ -# 5.4.0 (unreleased) +# 5.3.0 (unreleased) + +# 5.3.0-beta3 () + +* **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) @@ -505,7 +512,7 @@ See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4- # 4.6.2 (2017-11-16) ## Important -* **Prometheus**: Fixes bug with new prometheus alerts in Grafana. Make sure to download this version if your using Prometheus for alerting. More details in the issue. [#9777](https://github.com/grafana/grafana/issues/9777) +* **Prometheus**: Fixes bug with new prometheus alerts in Grafana. Make sure to download this version if you're using Prometheus for alerting. More details in the issue. [#9777](https://github.com/grafana/grafana/issues/9777) ## Fixes * **Color picker**: Bug after using textbox input field to change/paste color string [#9769](https://github.com/grafana/grafana/issues/9769) diff --git a/Gopkg.lock b/Gopkg.lock index bd247d691dd..041f784f770 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 = [ @@ -673,6 +679,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "81a37e747b875cf870c1b9486fa3147e704dea7db8ba86f7cb942d3ddc01d3e3" + inputs-digest = "6e9458f912a5f0eb3430b968f1b4dbc4e3b7671b282cf4fe1573419a6d9ba0d4" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 6c91ec37221..c5b4b31cb32 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -203,3 +203,7 @@ ignored = [ [[constraint]] name = "github.com/denisenkom/go-mssqldb" revision = "270bc3860bb94dd3a3ffd047377d746c5e276726" + +[[constraint]] + name = "github.com/VividCortex/mysqlerr" + branch = "master" diff --git a/build.go b/build.go index 9502f52be11..69fbf3bada8 100644 --- a/build.go +++ b/build.go @@ -22,6 +22,11 @@ import ( "time" ) +const ( + windows = "windows" + linux = "linux" +) + var ( //versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`) goarch string @@ -110,13 +115,13 @@ func main() { case "package": grunt(gruntBuildArg("build")...) grunt(gruntBuildArg("package")...) - if goos == "linux" { + if goos == linux { createLinuxPackages() } case "package-only": grunt(gruntBuildArg("package")...) - if goos == "linux" { + if goos == linux { createLinuxPackages() } @@ -378,7 +383,7 @@ func ensureGoPath() { } func grunt(params ...string) { - if runtime.GOOS == "windows" { + if runtime.GOOS == windows { runPrint(`.\node_modules\.bin\grunt`, params...) } else { runPrint("./node_modules/.bin/grunt", params...) @@ -420,7 +425,7 @@ func build(binaryName, pkg string, tags []string) { binary = fmt.Sprintf("./bin/%s", binaryName) } - if goos == "windows" { + if goos == windows { binary += ".exe" } @@ -484,11 +489,11 @@ func clean() { func setBuildEnv() { os.Setenv("GOOS", goos) - if goos == "windows" { + if goos == windows { // require windows >=7 os.Setenv("CGO_CFLAGS", "-D_WIN32_WINNT=0x0601") } - if goarch != "amd64" || goos != "linux" { + if goarch != "amd64" || goos != linux { // needed for all other archs cgo = true } diff --git a/conf/defaults.ini b/conf/defaults.ini index 15b8927e65a..eb8debc0094 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -474,6 +474,10 @@ error_or_timeout = alerting # Default setting for how Grafana handles nodata or null values in alerting. (alerting, no_data, keep_state, ok) nodata_or_nullvalues = no_data +# Alert notifications can include images, but rendering many images at the same time can overload the server +# This limit will protect the server from render overloading and make sure notifications are sent out quickly +concurrent_render_limit = 5 + #################################### Explore ############################# [explore] # Enable the Explore section diff --git a/conf/sample.ini b/conf/sample.ini index 2ef254f79b9..e6a03718d19 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -393,6 +393,10 @@ log_queries = # Default setting for how Grafana handles nodata or null values in alerting. (alerting, no_data, keep_state, ok) ;nodata_or_nullvalues = no_data +# Alert notifications can include images, but rendering many images at the same time can overload the server +# This limit will protect the server from render overloading and make sure notifications are sent out quickly +;concurrent_render_limit = 5 + #################################### Explore ############################# [explore] # Enable the Explore section @@ -431,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 diff --git a/devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml b/devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml new file mode 100644 index 00000000000..1ede5dcd30a --- /dev/null +++ b/devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: 'Bulk alerting dashboards' + folder: 'Bulk alerting dashboards' + type: file + options: + path: devenv/bulk_alerting_dashboards + diff --git a/devenv/bulk_alerting_dashboards/bulkdash_alerting.jsonnet b/devenv/bulk_alerting_dashboards/bulkdash_alerting.jsonnet new file mode 100644 index 00000000000..a7acd57745d --- /dev/null +++ b/devenv/bulk_alerting_dashboards/bulkdash_alerting.jsonnet @@ -0,0 +1,168 @@ +{ + "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": [] + }, + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-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 +} \ No newline at end of file diff --git a/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf index c9520124a2a..792bbfd6857 100644 --- a/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf +++ b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf @@ -8,7 +8,7 @@ # 'avg'. The name of the aggregate metric will be derived from # 'output_template' filling in any captured fields from 'input_pattern'. # -# For example, if you're metric naming scheme is: +# For example, if your metric naming scheme is: # # .applications... # 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/devenv/setup.sh b/devenv/setup.sh index cc71ecc71bf..c9cc0d47a6f 100755 --- a/devenv/setup.sh +++ b/devenv/setup.sh @@ -11,7 +11,21 @@ bulkDashboard() { let COUNTER=COUNTER+1 done - ln -s -f -r ./bulk-dashboards/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml + ln -s -f ../../../devenv/bulk-dashboards/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml +} + +bulkAlertingDashboard() { + + requiresJsonnet + + COUNTER=0 + MAX=100 + while [ $COUNTER -lt $MAX ]; do + jsonnet -o "bulk_alerting_dashboards/alerting_dashboard${COUNTER}.json" -e "local bulkDash = import 'bulk_alerting_dashboards/bulkdash_alerting.jsonnet'; bulkDash + { uid: 'bd-${COUNTER}', title: 'alerting-title-${COUNTER}' }" + let COUNTER=COUNTER+1 + done + + ln -s -f ../../../devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml ../conf/provisioning/dashboards/custom.yaml } requiresJsonnet() { @@ -36,8 +50,9 @@ devDatasources() { usage() { echo -e "\n" echo "Usage:" - echo " bulk-dashboards - create and provisioning 400 dashboards" - echo " no args - provisiong core datasources and dev dashboards" + echo " bulk-dashboards - create and provisioning 400 dashboards" + echo " bulk-alerting-dashboards - create and provisioning 400 dashboards with alerts" + echo " no args - provisiong core datasources and dev dashboards" } main() { @@ -48,7 +63,9 @@ main() { local cmd=$1 - if [[ $cmd == "bulk-dashboards" ]]; then + if [[ $cmd == "bulk-alerting-dashboards" ]]; then + bulkAlertingDashboard + elif [[ $cmd == "bulk-dashboards" ]]; then bulkDashboard else devDashboards diff --git a/docs/README.md b/docs/README.md index ff5ef6a4131..7310f184a60 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,7 +65,7 @@ make docs-build This will rebuild the docs docker container. -To be able to use the image your have to quit (CTRL-C) the `make watch` command (that you run in the same directory as this README). Then simply rerun `make watch`, it will restart the docs server but now with access to your image. +To be able to use the image you have to quit (CTRL-C) the `make watch` command (that you run in the same directory as this README). Then simply rerun `make watch`, it will restart the docs server but now with access to your image. ### Editing content diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 587e1411c26..0860531b528 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -203,7 +203,7 @@ providers: folder: '' type: file disableDeletion: false - updateIntervalSeconds: 3 #how often Grafana will scan for changed dashboards + updateIntervalSeconds: 10 #how often Grafana will scan for changed dashboards options: path: /var/lib/grafana/dashboards ``` 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/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md new file mode 100644 index 00000000000..96f3ba3382e --- /dev/null +++ b/docs/sources/features/datasources/stackdriver.md @@ -0,0 +1,171 @@ ++++ +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 + +### Service Account Credentials - Private 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: + +![Enable GCP APIs](/img/docs/v54/stackdriver_enable_api.png) + +#### 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. + + ![Create service account button](/img/docs/v54/stackdriver_create_service_account_button.png) +3. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option: + + ![Create service account key](/img/docs/v54/stackdriver_create_service_account_key.png) +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: + + ![Choose role](/img/docs/v54/stackdriver_service_account_choose_role.png) +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. + + ![Choose role](/img/docs/v54/stackdriver_grafana_upload_key.png) +7. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file! + + ![Choose role](/img/docs/v54/stackdriver_grafana_key_uploaded.png) + +## Metric Query Editor + +Choose a metric from the `Metric` dropdown. + +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` + +### 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` + +## 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: rate(http_requests_total{job=~"$job"}[5m]) +- `[[varname]]` Example: rate(http_requests_total{job=~"[[job]]"}[5m]) + +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 + +[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. + +## 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 for this datasource. + +```yaml +apiVersion: 1 + +datasources: + - name: Stackdriver + type: stackdriver + jsonData: + tokenUri: https://oauth2.googleapis.com/token + clientEmail: stackdriver@myproject.iam.gserviceaccount.com + secureJsonData: + privateKey: "" +``` diff --git a/docs/sources/guides/whats-new-in-v4-2.md b/docs/sources/guides/whats-new-in-v4-2.md index e976ed24700..e36e762bb76 100644 --- a/docs/sources/guides/whats-new-in-v4-2.md +++ b/docs/sources/guides/whats-new-in-v4-2.md @@ -67,7 +67,7 @@ Making it possible to have users in multiple groups and have detailed access con ## Upgrade & Breaking changes -If your using https in grafana we now force you to use tls 1.2 and the most secure ciphers. +If you're using https in grafana we now force you to use tls 1.2 and the most secure ciphers. We think its better to be secure by default rather then making it configurable. If you want to run https with lower versions of tls we suggest you put a reserve proxy in front of grafana. diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 2bf4789257d..5a838e8a321 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -566,3 +566,11 @@ Default setting for new alert rules. Defaults to categorize error and timeouts a > Available in 5.3 and above Default setting for how Grafana handles nodata or null values in alerting. (alerting, no_data, keep_state, ok) + +# concurrent_render_limit + +> Available in 5.3 and above + +Alert notifications can include images, but rendering many images at the same time can overload the server. +This limit will protect the server from render overloading and make sure notifications are sent out quickly. Default +value is `5`. diff --git a/docs/sources/tutorials/ha_setup.md b/docs/sources/tutorials/ha_setup.md index 0f138b20a17..5fdb091a348 100644 --- a/docs/sources/tutorials/ha_setup.md +++ b/docs/sources/tutorials/ha_setup.md @@ -22,7 +22,7 @@ Setting up Grafana for high availability is fairly simple. It comes down to two First, you need to do is to setup MySQL or Postgres on another server and configure Grafana to use that database. You can find the configuration for doing that in the [[database]]({{< relref "configuration.md" >}}#database) section in the grafana config. -Grafana will now persist all long term data in the database. How to configure the database for high availability is out of scope for this guide. We recommend finding an expert on for the database your using. +Grafana will now persist all long term data in the database. How to configure the database for high availability is out of scope for this guide. We recommend finding an expert on for the database you're using. ## User sessions diff --git a/docs/versions.json b/docs/versions.json index caefbe198d6..34e9c2150e1 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,4 +1,5 @@ [ + { "version": "v5.3", "path": "/v5.3", "archived": false, "current": false }, { "version": "v5.2", "path": "/", "archived": false, "current": true }, { "version": "v5.1", "path": "/v5.1", "archived": true }, { "version": "v5.0", "path": "/v5.0", "archived": true }, diff --git a/package.json b/package.json index 1e7ed02c87b..ba48304f0cd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "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", 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 c2ab6dd9a1a..d65598f6e5e 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -22,6 +22,10 @@ import ( "github.com/grafana/grafana/pkg/util" ) +const ( + anonString = "Anonymous" +) + func isDashboardStarredByUser(c *m.ReqContext, dashID int64) (bool, error) { if !c.IsSignedIn { return false, nil @@ -64,7 +68,7 @@ func GetDashboard(c *m.ReqContext) Response { } // Finding creator and last updater of the dashboard - updater, creator := "Anonymous", "Anonymous" + updater, creator := anonString, anonString if dash.UpdatedBy > 0 { updater = getUserLogin(dash.UpdatedBy) } @@ -128,7 +132,7 @@ func getUserLogin(userID int64) string { query := m.GetUserByIdQuery{Id: userID} err := bus.Dispatch(&query) if err != nil { - return "Anonymous" + return anonString } return query.Result.Login } @@ -403,7 +407,7 @@ func GetDashboardVersion(c *m.ReqContext) Response { return Error(500, fmt.Sprintf("Dashboard version %d not found for dashboardId %d", query.Version, dashID), err) } - creator := "Anonymous" + creator := anonString if query.Result.CreatedBy > 0 { creator = getUserLogin(query.Result.CreatedBy) } diff --git a/pkg/api/folder.go b/pkg/api/folder.go index f0cdff24d20..0e08343b556 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -95,7 +95,7 @@ func toFolderDto(g guardian.DashboardGuardian, folder *m.Folder) dtos.Folder { canAdmin, _ := g.CanAdmin() // Finding creator and last updater of the folder - updater, creator := "Anonymous", "Anonymous" + updater, creator := anonString, anonString if folder.CreatedBy > 0 { creator = getUserLogin(folder.CreatedBy) } 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/index.go b/pkg/api/index.go index b8101a01fc8..1b73acd8829 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -11,6 +11,12 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +const ( + // Themes + lightName = "light" + darkName = "dark" +) + func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { settings, err := getFrontendSettingsMap(c) if err != nil { @@ -60,7 +66,7 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { OrgRole: c.OrgRole, GravatarUrl: dtos.GetGravatarUrl(c.Email), IsGrafanaAdmin: c.IsGrafanaAdmin, - LightTheme: prefs.Theme == "light", + LightTheme: prefs.Theme == lightName, Timezone: prefs.Timezone, Locale: locale, HelpFlags1: c.HelpFlags1, @@ -88,12 +94,12 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { } themeURLParam := c.Query("theme") - if themeURLParam == "light" { + if themeURLParam == lightName { data.User.LightTheme = true - data.Theme = "light" - } else if themeURLParam == "dark" { + data.Theme = lightName + } else if themeURLParam == darkName { data.User.LightTheme = false - data.Theme = "dark" + data.Theme = darkName } if hasEditPermissionInFoldersQuery.Result { 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/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..c68da839d13 --- /dev/null +++ b/pkg/api/pluginproxy/ds_auth_provider.go @@ -0,0 +1,93 @@ +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" +) + +//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)) + } + } + + if route.JwtTokenAuth != nil { + 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)) + } + } + 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..7dcd187c368 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{} diff --git a/pkg/api/render.go b/pkg/api/render.go index b8ef6cc5cb6..cf672af9bea 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -41,15 +41,16 @@ func (hs *HTTPServer) RenderToPng(c *m.ReqContext) { } result, err := hs.RenderService.Render(c.Req.Context(), rendering.Opts{ - Width: width, - Height: height, - Timeout: time.Duration(timeout) * time.Second, - OrgId: c.OrgId, - UserId: c.UserId, - OrgRole: c.OrgRole, - Path: c.Params("*") + queryParams, - Timezone: queryReader.Get("tz", ""), - Encoding: queryReader.Get("encoding", ""), + Width: width, + Height: height, + Timeout: time.Duration(timeout) * time.Second, + OrgId: c.OrgId, + UserId: c.UserId, + OrgRole: c.OrgRole, + Path: c.Params("*") + queryParams, + Timezone: queryReader.Get("tz", ""), + Encoding: queryReader.Get("encoding", ""), + ConcurrentLimit: 30, }) if err != nil && err == rendering.ErrTimeout { 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-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index 5d4969e06af..f88bb9bbfff 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -112,7 +112,7 @@ func SelectVersion(plugin m.Plugin, version string) (m.Version, error) { } } - return m.Version{}, errors.New("Could not find the version your looking for") + return m.Version{}, errors.New("Could not find the version you're looking for") } func RemoveGitBuildFromName(pluginName, filename string) string { diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index f1e298671d7..84325bae808 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -29,6 +29,7 @@ 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" ) @@ -103,7 +104,7 @@ func listenToSystemSignals(server *GrafanaServerImpl) { for { select { - case _ = <-sighupChan: + case <-sighupChan: log.Reload() case sig := <-signalChan: server.Shutdown(fmt.Sprintf("System signal: %s", sig)) 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/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go index a1e4aed0f47..9c8af21e39e 100644 --- a/pkg/components/imguploader/s3uploader.go +++ b/pkg/components/imguploader/s3uploader.go @@ -2,12 +2,15 @@ package imguploader import ( "context" + "fmt" "os" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go/aws/credentials/endpointcreds" + "github.com/aws/aws-sdk-go/aws/defaults" "github.com/aws/aws-sdk-go/aws/ec2metadata" "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/aws/aws-sdk-go/aws/session" @@ -50,7 +53,7 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string, SecretAccessKey: u.secretKey, }}, &credentials.EnvProvider{}, - &ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(sess), ExpiryWindow: 5 * time.Minute}, + remoteCredProvider(sess), }) cfg := &aws.Config{ Region: aws.String(u.region), @@ -85,3 +88,27 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string, } return image_url, nil } + +func remoteCredProvider(sess *session.Session) credentials.Provider { + ecsCredURI := os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") + + if len(ecsCredURI) > 0 { + return ecsCredProvider(sess, ecsCredURI) + } + return ec2RoleProvider(sess) +} + +func ecsCredProvider(sess *session.Session, uri string) credentials.Provider { + const host = `169.254.170.2` + + d := defaults.Get() + return endpointcreds.NewProviderClient( + *d.Config, + d.Handlers, + fmt.Sprintf("http://%s%s", host, uri), + func(p *endpointcreds.Provider) { p.ExpiryWindow = 5 * time.Minute }) +} + +func ec2RoleProvider(sess *session.Session) credentials.Provider { + return &ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(sess), ExpiryWindow: 5 * time.Minute} +} diff --git a/pkg/components/null/float.go b/pkg/components/null/float.go index 4f783f2c584..9082c831084 100644 --- a/pkg/components/null/float.go +++ b/pkg/components/null/float.go @@ -8,6 +8,10 @@ import ( "strconv" ) +const ( + nullString = "null" +) + // Float is a nullable float64. // It does not consider zero values to be null. // It will decode to null, not zero, if null. @@ -68,7 +72,7 @@ func (f *Float) UnmarshalJSON(data []byte) error { // It will return an error if the input is not an integer, blank, or "null". func (f *Float) UnmarshalText(text []byte) error { str := string(text) - if str == "" || str == "null" { + if str == "" || str == nullString { f.Valid = false return nil } @@ -82,7 +86,7 @@ func (f *Float) UnmarshalText(text []byte) error { // It will encode null if this Float is null. func (f Float) MarshalJSON() ([]byte, error) { if !f.Valid { - return []byte("null"), nil + return []byte(nullString), nil } return []byte(strconv.FormatFloat(f.Float64, 'f', -1, 64)), nil } @@ -100,7 +104,7 @@ func (f Float) MarshalText() ([]byte, error) { // It will encode a blank string if this Float is null. func (f Float) String() string { if !f.Valid { - return "null" + return nullString } return fmt.Sprintf("%1.3f", f.Float64) @@ -109,7 +113,7 @@ func (f Float) String() string { // FullString returns float as string in full precision func (f Float) FullString() string { if !f.Valid { - return "null" + return nullString } return fmt.Sprintf("%f", f.Float64) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 87c23a7b49a..1830b3eb161 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -435,11 +435,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/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 42d33d5ed22..2128b469fa4 100644 --- a/pkg/models/alert_notifications.go +++ b/pkg/models/alert_notifications.go @@ -8,8 +8,18 @@ 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 { @@ -76,33 +86,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/datasource.go b/pkg/models/datasource.go index cbdd0136f4d..c730622512f 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 ( @@ -70,12 +71,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 +89,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 { 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/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 46f8b3c769c..96294f0624f 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,7 +22,7 @@ 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 @@ -28,11 +30,16 @@ type Notifier interface { 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 7fbd956f4f9..9ce50eadd6b 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -1,16 +1,15 @@ package alerting import ( - "context" "errors" "fmt" - "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/imguploader" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/setting" m "github.com/grafana/grafana/pkg/models" ) @@ -40,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(context.Background(), 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) } } @@ -108,11 +124,12 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) { } renderOpts := rendering.Opts{ - Width: 1000, - Height: 500, - Timeout: alertTimeout / 2, - OrgId: context.Rule.OrgId, - OrgRole: m.ROLE_ADMIN, + Width: 1000, + Height: 500, + Timeout: alertTimeout / 2, + OrgId: context.Rule.OrgId, + OrgRole: m.ROLE_ADMIN, + ConcurrentLimit: setting.AlertingRenderLimit, } ref, err := context.GetDashboardUID() @@ -140,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 ca011356247..fbade2eccac 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -4,13 +4,16 @@ 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" ) +const ( + triggMetrString = "Triggered metrics:\n\n" +) + type NotifierBase struct { Name string Type string @@ -42,55 +45,47 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase { } } -func defaultShouldNotify(context *alerting.EvalContext, sendReminder bool, frequency time.Duration, lastNotify time.Time) 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 } - // Do not notify if interval has not elapsed - if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) { - return false - } + 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 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 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 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 + } + } + 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 == models.ErrJournalingNotFound { - return true - } - - if err != nil { - n.log.Error("Could not determine last time alert notifier fired", "Alert name", c.Rule.Name, "Error", err) - return false - } - - if !cmd.Result.Success { - return true - } - - return defaultShouldNotify(c, n.SendReminder, n.Frequency, time.Unix(cmd.Result.SentAt, 0)) -} - func (n *NotifierBase) GetType() string { return n.Type } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 57b82f32466..5e46d3ad72e 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" @@ -15,100 +12,144 @@ import ( ) func TestShouldSendAlertNotification(t *testing.T) { + tnow := time.Now() + tcs := []struct { name string prevState m.AlertStateType newState m.AlertStateType - expected bool sendReminder bool + frequency time.Duration + state *m.AlertNotificationState + + expect bool }{ { - name: "pending -> ok should not trigger an notification", - newState: m.AlertStatePending, - prevState: m.AlertStateOK, - expected: false, + name: "pending -> ok should not trigger an notification", + newState: m.AlertStateOK, + prevState: m.AlertStatePending, + sendReminder: false, + state: &m.AlertNotificationState{}, + + expect: false, }, { - name: "ok -> alerting should trigger an notification", - newState: m.AlertStateOK, - prevState: m.AlertStateAlerting, - expected: true, + name: "ok -> alerting should trigger an notification", + newState: m.AlertStateAlerting, + prevState: m.AlertStateOK, + sendReminder: false, + state: &m.AlertNotificationState{}, + + expect: true, }, { - name: "ok -> pending should not trigger an notification", - newState: m.AlertStateOK, - prevState: m.AlertStatePending, - expected: false, + name: "ok -> pending should not trigger an notification", + newState: m.AlertStatePending, + prevState: m.AlertStateOK, + sendReminder: false, + state: &m.AlertNotificationState{}, + + expect: false, }, { name: "ok -> ok should not trigger an notification", newState: m.AlertStateOK, prevState: m.AlertStateOK, - expected: false, sendReminder: false, - }, - { - name: "ok -> alerting should not trigger an notification", - newState: m.AlertStateOK, - prevState: m.AlertStateAlerting, - expected: true, - sendReminder: true, + state: &m.AlertNotificationState{}, + + expect: false, }, { name: "ok -> ok with reminder should not trigger an notification", newState: m.AlertStateOK, prevState: m.AlertStateOK, - expected: false, sendReminder: true, + state: &m.AlertNotificationState{}, + + expect: false, + }, + { + name: "alerting -> ok should trigger an notification", + newState: m.AlertStateOK, + prevState: m.AlertStateAlerting, + sendReminder: false, + state: &m.AlertNotificationState{}, + + expect: true, + }, + { + 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, + 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 last notifciation sent 11 minutes ago should trigger", + newState: m.AlertStateAlerting, + prevState: m.AlertStateAlerting, + frequency: time.Minute * 10, + sendReminder: true, + 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, 0, time.Now()) != tc.expected { - t.Errorf("failed %s. expected %+v to return %v", tc.name, tc, tc.expected) + 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 notify if no journaling is found", func() { - bus.AddHandlerCtx("", func(ctx context.Context, q *m.GetLatestNotificationQuery) error { - return m.ErrJournalingNotFound - }) - - if !notifier.ShouldNotify(context.Background(), evalContext) { - t.Errorf("should send notifications when ErrJournalingNotFound is returned") - } - }) - - 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() diff --git a/pkg/services/alerting/notifiers/kafka.go b/pkg/services/alerting/notifiers/kafka.go index d8d19fc5dae..a8a424c87a7 100644 --- a/pkg/services/alerting/notifiers/kafka.go +++ b/pkg/services/alerting/notifiers/kafka.go @@ -61,7 +61,7 @@ func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error { state := evalContext.Rule.State - customData := "Triggered metrics:\n\n" + customData := triggMetrString for _, evt := range evalContext.EvalMatches { customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) } diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index 84148a0d99c..629968b5102 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -95,7 +95,7 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err return err } - customData := "Triggered metrics:\n\n" + customData := triggMetrString for _, evt := range evalContext.EvalMatches { customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) } diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go index bf85466388f..9f6ce3c2dc8 100644 --- a/pkg/services/alerting/notifiers/pagerduty.go +++ b/pkg/services/alerting/notifiers/pagerduty.go @@ -76,7 +76,7 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { if evalContext.Rule.State == m.AlertStateOK { eventType = "resolve" } - customData := "Triggered metrics:\n\n" + customData := triggMetrString for _, evt := range evalContext.EvalMatches { customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) } diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 363d06d1132..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..d13924c2a17 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 { @@ -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) 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/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/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index 7508550838f..bfef06b558e 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -83,7 +83,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) { } if dashboards[i].UpdateIntervalSeconds == 0 { - dashboards[i].UpdateIntervalSeconds = 3 + dashboards[i].UpdateIntervalSeconds = 10 } } diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index df0d2ae038e..d386e42349d 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -70,7 +70,7 @@ func validateDashboardAsConfig(t *testing.T, cfg []*DashboardsAsConfig) { So(len(ds.Options), ShouldEqual, 1) So(ds.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") So(ds.DisableDeletion, ShouldBeTrue) - So(ds.UpdateIntervalSeconds, ShouldEqual, 10) + So(ds.UpdateIntervalSeconds, ShouldEqual, 15) ds2 := cfg[1] So(ds2.Name, ShouldEqual, "default") @@ -81,5 +81,5 @@ func validateDashboardAsConfig(t *testing.T, cfg []*DashboardsAsConfig) { So(len(ds2.Options), ShouldEqual, 1) So(ds2.Options["path"], ShouldEqual, "/var/lib/grafana/dashboards") So(ds2.DisableDeletion, ShouldBeFalse) - So(ds2.UpdateIntervalSeconds, ShouldEqual, 3) + So(ds2.UpdateIntervalSeconds, ShouldEqual, 10) } diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index ef27ba97235..c7d400929dc 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 } @@ -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/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml b/pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml index e26c329f87c..c43c4a14c53 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml +++ b/pkg/services/provisioning/dashboards/testdata/test-configs/dashboards-from-disk/dev-dashboards.yaml @@ -6,7 +6,7 @@ providers: folder: 'developers' editable: true disableDeletion: true - updateIntervalSeconds: 10 + updateIntervalSeconds: 15 type: file options: path: /var/lib/grafana/dashboards diff --git a/pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml b/pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml index 69a317fb396..8b7b8991759 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml +++ b/pkg/services/provisioning/dashboards/testdata/test-configs/version-0/version-0.yaml @@ -3,7 +3,7 @@ folder: 'developers' editable: true disableDeletion: true - updateIntervalSeconds: 10 + updateIntervalSeconds: 15 type: file options: path: /var/lib/grafana/dashboards diff --git a/pkg/services/rendering/interface.go b/pkg/services/rendering/interface.go index 85c139cfc04..39cb1ada0f5 100644 --- a/pkg/services/rendering/interface.go +++ b/pkg/services/rendering/interface.go @@ -13,15 +13,16 @@ var ErrNoRenderer = errors.New("No renderer plugin found nor is an external rend var ErrPhantomJSNotInstalled = errors.New("PhantomJS executable not found") type Opts struct { - Width int - Height int - Timeout time.Duration - OrgId int64 - UserId int64 - OrgRole models.RoleType - Path string - Encoding string - Timezone string + Width int + Height int + Timeout time.Duration + OrgId int64 + UserId int64 + OrgRole models.RoleType + Path string + Encoding string + Timezone string + ConcurrentLimit int } type RenderResult struct { diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index ecef83d74d9..0b4f23e93b4 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -24,12 +24,13 @@ func init() { } type RenderingService struct { - log log.Logger - pluginClient *plugin.Client - grpcPlugin pluginModel.RendererPlugin - pluginInfo *plugins.RendererPlugin - renderAction renderFunc - domain string + log log.Logger + pluginClient *plugin.Client + grpcPlugin pluginModel.RendererPlugin + pluginInfo *plugins.RendererPlugin + renderAction renderFunc + domain string + inProgressCount int Cfg *setting.Cfg `inject:""` } @@ -90,6 +91,18 @@ func (rs *RenderingService) Run(ctx context.Context) error { } func (rs *RenderingService) Render(ctx context.Context, opts Opts) (*RenderResult, error) { + if rs.inProgressCount > opts.ConcurrentLimit { + return &RenderResult{ + FilePath: filepath.Join(setting.HomePath, "public/img/rendering_limit.png"), + }, nil + } + + defer func() { + rs.inProgressCount -= 1 + }() + + rs.inProgressCount += 1 + if rs.renderAction != nil { return rs.renderAction(ctx, opts) } else { 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 31867910ddb..daaef945b96 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 }) } @@ -229,46 +237,123 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { }) } -func RecordNotificationJournal(ctx context.Context, cmd *m.RecordNotificationJournalCommand) error { +func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error { return inTransactionCtx(ctx, func(sess *DBSession) error { - journalEntry := &m.AlertNotificationJournal{ - OrgId: cmd.OrgId, - AlertId: cmd.AlertId, - NotifierId: cmd.NotifierId, - SentAt: cmd.SentAt, - Success: cmd.Success, - } + 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 inTransactionCtx(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"). - Limit(1). - Where("alert_notification_journal.org_id = ? AND alert_notification_journal.alert_id = ? AND alert_notification_journal.notifier_id = ?", cmd.OrgId, cmd.AlertId, cmd.NotifierId).Get(nj) + _, err := sess.Exec(sql, m.AlertNotificationStateCompleted, newVersion, timeNow().Unix(), cmd.Id) if err != nil { return err } - if nj.AlertId == 0 && nj.Id == 0 && nj.NotifierId == 0 && nj.OrgId == 0 { - return m.ErrJournalingNotFound + 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) } - cmd.Result = nj 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 83fb42db9bb..ed682bae5c6 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,50 +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 = 5 - var orgId int64 = 5 - var notifierId int64 = 5 + 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} - err := GetLatestNotification(context.Background(), query) - So(err, ShouldEqual, m.ErrJournalingNotFound) + 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("shoulbe 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(query.Result.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 junaling should raise error", func() { - query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId} - err := GetLatestNotification(context.Background(), query) - So(err, ShouldEqual, m.ErrJournalingNotFound) - }) - }) + 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", } @@ -68,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, @@ -78,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() { @@ -90,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, @@ -101,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() { @@ -121,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, @@ -143,7 +226,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) { }) Convey("Can update alert notification", func() { - newCmd := &m.UpdateAlertNotificationCommand{ + newCmd := &models.UpdateAlertNotificationCommand{ Name: "NewName", Type: "webhook", OrgId: cmd.Result.OrgId, @@ -159,7 +242,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) { }) 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, @@ -174,12 +257,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) @@ -188,7 +271,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, } @@ -199,7 +282,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) { }) Convey("all", func() { - query := &m.GetAllAlertNotificationsQuery{ + query := &models.GetAllAlertNotificationsQuery{ OrgId: 1, } 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/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index e27e64c6124..cadcccf6c95 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -107,4 +107,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/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/setting/setting.go b/pkg/setting/setting.go index 1a253b9b238..27df73a9eed 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -166,6 +166,7 @@ var ( // Alerting AlertingEnabled bool ExecuteAlerts bool + AlertingRenderLimit int AlertingErrorOrTimeout string AlertingNoDataOrNullValues string @@ -196,10 +197,13 @@ type Cfg struct { Smtp SmtpSettings // Rendering - ImagesDir string - PhantomDir string - RendererUrl string - RendererCallbackUrl string + ImagesDir string + PhantomDir string + RendererUrl string + RendererCallbackUrl string + RendererLimit int + RendererLimitAlerting int + DisableBruteForceLoginProtection bool TempDataLifetime time.Duration @@ -677,6 +681,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { alerting := iniFile.Section("alerting") AlertingEnabled = alerting.Key("enabled").MustBool(true) ExecuteAlerts = alerting.Key("execute_alerts").MustBool(true) + AlertingRenderLimit = alerting.Key("concurrent_render_limit").MustInt(5) AlertingErrorOrTimeout = alerting.Key("error_or_timeout").MustString("alerting") AlertingNoDataOrNullValues = alerting.Key("nodata_or_nullvalues").MustString("no_data") diff --git a/pkg/social/social.go b/pkg/social/social.go index 721070ab789..8918507f3b9 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -46,10 +46,14 @@ func (e *Error) Error() string { return e.s } +const ( + grafanaCom = "grafana_com" +) + var ( SocialBaseUrl = "/login/" SocialMap = make(map[string]SocialConnector) - allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", "grafana_com"} + allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom} ) func NewOAuthService() { @@ -82,7 +86,7 @@ func NewOAuthService() { } if name == "grafananet" { - name = "grafana_com" + name = grafanaCom } setting.OAuthService.OAuthInfos[name] = info @@ -159,7 +163,7 @@ func NewOAuthService() { } } - if name == "grafana_com" { + if name == grafanaCom { config = oauth2.Config{ ClientID: info.ClientId, ClientSecret: info.ClientSecret, @@ -171,7 +175,7 @@ func NewOAuthService() { Scopes: info.Scopes, } - SocialMap["grafana_com"] = &SocialGrafanaCom{ + SocialMap[grafanaCom] = &SocialGrafanaCom{ SocialBase: &SocialBase{ Config: &config, log: logger, @@ -194,7 +198,7 @@ var GetOAuthProviders = func(cfg *setting.Cfg) map[string]bool { for _, name := range allOauthes { if name == "grafananet" { - name = "grafana_com" + name = grafanaCom } sec := cfg.Raw.Section("auth." + name) 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..ee9d9583c4e 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -235,7 +235,7 @@ func parseMultiSelectValue(input string) []string { 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", + "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", "us-isob-east-1", "us-iso-east-1", } result := make([]suggestData, 0) diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 0090754840a..0837c3dd9d5 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -13,6 +13,19 @@ import ( "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" ) +const ( + // Metric types + countType = "count" + percentilesType = "percentiles" + extendedStatsType = "extended_stats" + // Bucket types + dateHistType = "date_histogram" + histogramType = "histogram" + filtersType = "filters" + termsType = "terms" + geohashGridType = "geohash_grid" +) + type responseParser struct { Responses []*es.SearchResponse Targets []*Query @@ -81,7 +94,7 @@ func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Qu } if depth == maxDepth { - if aggDef.Type == "date_histogram" { + if aggDef.Type == dateHistType { err = rp.processMetrics(esAgg, target, series, props) } else { err = rp.processAggregationDocs(esAgg, aggDef, target, table, props) @@ -149,7 +162,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, } switch metric.Type { - case "count": + case countType: newSeries := tsdb.TimeSeries{ Tags: make(map[string]string), } @@ -164,10 +177,10 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, for k, v := range props { newSeries.Tags[k] = v } - newSeries.Tags["metric"] = "count" + newSeries.Tags["metric"] = countType *series = append(*series, &newSeries) - case "percentiles": + case percentilesType: buckets := esAgg.Get("buckets").MustArray() if len(buckets) == 0 { break @@ -198,7 +211,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, } *series = append(*series, &newSeries) } - case "extended_stats": + case extendedStatsType: buckets := esAgg.Get("buckets").MustArray() metaKeys := make([]string, 0) @@ -312,9 +325,9 @@ func (rp *responseParser) processAggregationDocs(esAgg *simplejson.Json, aggDef for _, metric := range target.Metrics { switch metric.Type { - case "count": + case countType: addMetricValue(&values, rp.getMetricName(metric.Type), castToNullFloat(bucket.Get("doc_count"))) - case "extended_stats": + case extendedStatsType: metaKeys := make([]string, 0) meta := metric.Meta.MustMap() for k := range meta { @@ -366,7 +379,7 @@ func (rp *responseParser) processAggregationDocs(esAgg *simplejson.Json, aggDef func (rp *responseParser) trimDatapoints(series *tsdb.TimeSeriesSlice, target *Query) { var histogram *BucketAgg for _, bucketAgg := range target.BucketAggs { - if bucketAgg.Type == "date_histogram" { + if bucketAgg.Type == dateHistType { histogram = bucketAgg break } diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go index c9bb05dd09a..fddcf3cb8b3 100644 --- a/pkg/tsdb/elasticsearch/time_series_query.go +++ b/pkg/tsdb/elasticsearch/time_series_query.go @@ -75,15 +75,15 @@ func (e *timeSeriesQuery) execute() (*tsdb.Response, error) { // iterate backwards to create aggregations bottom-down for _, bucketAgg := range q.BucketAggs { switch bucketAgg.Type { - case "date_histogram": + case dateHistType: aggBuilder = addDateHistogramAgg(aggBuilder, bucketAgg, from, to) - case "histogram": + case histogramType: aggBuilder = addHistogramAgg(aggBuilder, bucketAgg) - case "filters": + case filtersType: aggBuilder = addFiltersAgg(aggBuilder, bucketAgg) - case "terms": + case termsType: aggBuilder = addTermsAgg(aggBuilder, bucketAgg, q.Metrics) - case "geohash_grid": + case geohashGridType: aggBuilder = addGeoHashGridAgg(aggBuilder, bucketAgg) } } 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/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go new file mode 100644 index 00000000000..586e154cd5d --- /dev/null +++ b/pkg/tsdb/stackdriver/stackdriver.go @@ -0,0 +1,460 @@ +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" + + "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 +) + +// 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 "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), + } + + 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 buildFilterString(metricType string, filterParts []interface{}) string { + filterString := "" + for i, part := range filterParts { + mod := i % 4 + if part == "AND" { + filterString += " " + } else if mod == 2 { + 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) + + for _, series := range data.TimeSeries { + points := make([]tsdb.TimePoint, 0) + + // reverse the order to be ascending + 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)) + } + + defaultMetricName := series.Metric.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 + } + } + + metricName := formatLegendKeys(series.Metric.Type, defaultMetricName, series.Metric.Labels, series.Resource.Labels, query) + + queryRes.Series = append(queryRes.Series, &tsdb.TimeSeries{ + Name: metricName, + Points: points, + }) + } + + queryRes.Meta.Set("resourceLabels", resourceLabels) + queryRes.Meta.Set("metricLabels", metricLabels) + queryRes.Meta.Set("groupBys", query.GroupBys) + + 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, metricLabels map[string]string, resourceLabels 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) + } + + 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) + } + + 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 (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") + } + projectName := dsInfo.JsonData.Get("defaultProject").MustString() + proxyPass := fmt.Sprintf("stackdriver%s", "v3/projects/"+projectName+"/timeSeries") + + var stackdriverRoute *plugins.AppPluginRoute + for _, route := range plugin.Routes { + if route.Path == "stackdriver" { + stackdriverRoute = route + break + } + } + + pluginproxy.ApplyRoute(ctx, req, proxyPass, stackdriverRoute, dsInfo) + + return req, nil +} diff --git a/pkg/tsdb/stackdriver/stackdriver_test.go b/pkg/tsdb/stackdriver/stackdriver_test.go new file mode 100644 index 00000000000..da4d6890207 --- /dev/null +++ b/pkg/tsdb/stackdriver/stackdriver_test.go @@ -0,0 +1,357 @@ +package stackdriver + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "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") + }) + }) + }) + }) + }) +} + +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/types.go b/pkg/tsdb/stackdriver/types.go new file mode 100644 index 00000000000..c58ac2968f2 --- /dev/null +++ b/pkg/tsdb/stackdriver/types.go @@ -0,0 +1,43 @@ +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 +} + +// 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"` + } `json:"value"` + } `json:"points"` + } `json:"timeSeries"` +} 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 ( +
+ + +
+ ); +}; + +export default LayoutSelector; diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index 77ac6953b74..fc062ce63e4 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, diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index e50513c44e1..8f48ba8f66a 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -3,6 +3,7 @@ import Select from 'react-select'; import PickerOption from './PickerOption'; import { debounce } from 'lodash'; import { getBackendSrv } from 'app/core/services/backend_srv'; +import { User } from 'app/types'; export interface Props { onSelected: (user: User) => void; @@ -14,13 +15,6 @@ export interface State { isLoading: boolean; } -export interface User { - id: number; - label: string; - avatarUrl: string; - login: string; -} - export class UserPicker extends Component { debouncedSearch: any; diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index a0ea0279d30..4272c8a0b71 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -245,6 +245,9 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop return; } + // ensure dropdown menu doesn't impact on z-index + body.find('.dropdown-menu-open').removeClass('dropdown-menu-open'); + // for stuff that animates, slides out etc, clicking it needs to // hide it right away const clickAutoHide = target.closest('[data-click-hide]'); diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index 6a356c4ea5a..2089cfe9f59 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -1,6 +1,6 @@ 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'; export const initialState: LocationState = { url: '', @@ -9,13 +9,6 @@ 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': { diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index d05e9b0c21c..d8dfc958dd4 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(); @@ -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)); } } }); diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts new file mode 100644 index 00000000000..8b303ffafa3 --- /dev/null +++ b/public/app/core/utils/explore.test.ts @@ -0,0 +1,95 @@ +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: '', + graphResult: null, + history: [], + latency: 0, + loading: false, + logsResult: null, + queries: [], + queryErrors: [], + queryHints: [], + range: DEFAULT_RANGE, + requestOptions: null, + showingGraph: true, + showingLogs: true, + showingTable: true, + supportsGraph: null, + supportsLogs: null, + supportsTable: null, + tableResult: null, +}; + +describe('state functions', () => { + describe('parseUrlState', () => { + it('returns default state on empty string', () => { + expect(parseUrlState('')).toMatchObject({ + datasource: null, + queries: [], + range: DEFAULT_RANGE, + }); + }); + }); + 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"}}' + ); + }); + }); + 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..cca841a1725 --- /dev/null +++ b/public/app/core/utils/explore.ts @@ -0,0 +1,78 @@ +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 { + return JSON.parse(decodeURI(initial)); + } catch (e) { + console.error(e); + } + } + return { datasource: null, queries: [], range: DEFAULT_RANGE }; +} + +export function serializeStateToUrlParam(state: ExploreState): string { + const urlState: ExploreUrlState = { + datasource: state.datasourceName, + queries: state.queries.map(q => ({ query: q.query })), + range: state.range, + }; + return JSON.stringify(urlState); +} 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/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/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..518180fc424 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -0,0 +1,73 @@ +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: '', + loadApiKeys: jest.fn(), + deleteApiKey: jest.fn(), + setSearchQuery: jest.fn(), + addApiKey: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as ApiKeysPage; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render API keys table', () => { + const { wrapper } = setup({ + apiKeys: getMultipleMockKeys(5), + }); + + 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..2f19250e835 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -0,0 +1,222 @@ +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 } from './state/selectors'; +import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +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'; + +export interface Props { + navModel: NavModel; + apiKeys: ApiKey[]; + searchQuery: string; + loadApiKeys: typeof loadApiKeys; + deleteApiKey: typeof deleteApiKey; + setSearchQuery: typeof setSearchQuery; + addApiKey: typeof addApiKey; +} + +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, + }; + }); + }; + + onApiKeyStateUpdate = (evt, prop: string) => { + const value = evt.currentTarget.value; + this.setState((prevState: State) => { + const newApiKey = { + ...prevState.newApiKey, + }; + newApiKey[prop] = value; + + return { + ...prevState, + newApiKey: newApiKey, + }; + }); + }; + + render() { + const { newApiKey, isAdding } = this.state; + const { navModel, apiKeys, searchQuery } = this.props; + + return ( +
+ +
+
+
+ +
+ +
+ +
+ + +
+ +
Add API Key
+
+
+
+ Key name + this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)} + /> +
+
+ Role + + + +
+
+ +
+
+
+
+
+ +

Existing Keys

+ + + + + + + + {apiKeys.length > 0 ? ( + + {apiKeys.map(key => { + return ( + + + + + + ); + })} + + ) : null} +
NameRole +
{key.name}{key.role} + this.onDeleteApiKey(key)} className="btn btn-danger btn-mini"> + + +
+
+
+ ); + } +} + +function mapStateToProps(state) { + return { + navModel: getNavModel(state.navIndex, 'apikeys'), + apiKeys: getApiKeys(state.apiKeys), + searchQuery: state.apiKeys.searchQuery, + }; +} + +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..77c7f620173 --- /dev/null +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -0,0 +1,435 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render API keys table 1`] = ` +
+ +
+
+
+ +
+
+ +
+ +
+ +
+ Add API Key +
+
+
+
+ + Key name + + +
+
+ + Role + + + + +
+
+ +
+
+
+
+
+

+ Existing Keys +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Role + +
+ test-1 + + Viewer + + + + +
+ test-2 + + Viewer + + + + +
+ test-3 + + Viewer + + + + +
+ test-4 + + Viewer + + + + +
+ test-5 + + Viewer + + + + +
+
+
+`; + +exports[`Render should render component 1`] = ` +
+ +
+
+
+ +
+
+ +
+ +
+ +
+ Add API Key +
+
+
+
+ + Key name + + +
+
+ + Role + + + + +
+
+ +
+
+
+
+
+

+ Existing Keys +

+ + + + + + + +
+ Name + + Role + +
+
+
+`; 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..a21aa55dbf7 --- /dev/null +++ b/public/app/features/api-keys/state/reducers.ts @@ -0,0 +1,21 @@ +import { ApiKeysState } from 'app/types'; +import { Action, ActionTypes } from './actions'; + +export const initialApiKeysState: ApiKeysState = { + keys: [], + searchQuery: '', +}; + +export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => { + switch (action.type) { + case ActionTypes.LoadApiKeys: + return { ...state, 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..7d8f3122ce6 --- /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: '' }; + + const keys = getApiKeys(mockState); + + expect(keys).toEqual(mockKeys); + }); + + it('should filter keys if search query exists', () => { + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' }; + + 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..8065c252e85 --- /dev/null +++ b/public/app/features/api-keys/state/selectors.ts @@ -0,0 +1,9 @@ +import { ApiKeysState } from 'app/types'; + +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/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/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 @@ - + +
diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/time_srv.ts index dd5a0ba758f..a96bc89daa7 100644 --- a/public/app/features/dashboard/time_srv.ts +++ b/public/app/features/dashboard/time_srv.ts @@ -85,6 +85,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 +113,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) { diff --git a/public/app/features/dashboard/upload.ts b/public/app/features/dashboard/upload.ts index 974a0c35cd2..42871327eb6 100644 --- a/public/app/features/dashboard/upload.ts +++ b/public/app/features/dashboard/upload.ts @@ -1,10 +1,12 @@ import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; +import angular from 'angular'; const template = ` - + `; @@ -15,8 +17,11 @@ function uploadDashboardDirective(timer, alertSrv, $location) { 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,7 +57,7 @@ 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'); } 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/DataSourcesActionBar.test.tsx b/public/app/features/datasources/DataSourcesActionBar.test.tsx new file mode 100644 index 00000000000..8337271271e --- /dev/null +++ b/public/app/features/datasources/DataSourcesActionBar.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourcesActionBar, Props } from './DataSourcesActionBar'; +import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; + +const setup = (propOverrides?: object) => { + const props: Props = { + layoutMode: LayoutModes.Grid, + searchQuery: '', + setDataSourcesLayoutMode: jest.fn(), + setDataSourcesSearchQuery: jest.fn(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesActionBar.tsx b/public/app/features/datasources/DataSourcesActionBar.tsx new file mode 100644 index 00000000000..d28089b1f21 --- /dev/null +++ b/public/app/features/datasources/DataSourcesActionBar.tsx @@ -0,0 +1,62 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions'; +import { getDataSourcesLayoutMode, getDataSourcesSearchQuery } from './state/selectors'; + +export interface Props { + searchQuery: string; + layoutMode: LayoutMode; + setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode; + setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery; +} + +export class DataSourcesActionBar extends PureComponent { + onSearchQueryChange = event => { + this.props.setDataSourcesSearchQuery(event.target.value); + }; + + render() { + const { searchQuery, layoutMode, setDataSourcesLayoutMode } = this.props; + + return ( +
+
+ + setDataSourcesLayoutMode(mode)} + /> +
+ + ); + } +} + +function mapStateToProps(state) { + return { + searchQuery: getDataSourcesSearchQuery(state.dataSources), + layoutMode: getDataSourcesLayoutMode(state.dataSources), + }; +} + +const mapDispatchToProps = { + setDataSourcesLayoutMode, + setDataSourcesSearchQuery, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DataSourcesActionBar); 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.type}
    +
    +
    +
    + +
    +
    +
    + {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..fed7954d716 --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.test.tsx @@ -0,0 +1,37 @@ +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, + }; + + 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, + }); + + 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..c6db6ee7889 --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -0,0 +1,76 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import PageHeader from '../../core/components/PageHeader/PageHeader'; +import DataSourcesActionBar from './DataSourcesActionBar'; +import DataSourcesList from './DataSourcesList'; +import { loadDataSources } from './state/actions'; +import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors'; +import { getNavModel } from '../../core/selectors/navModel'; +import { DataSource, NavModel } from 'app/types'; +import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; +import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA'; + +export interface Props { + navModel: NavModel; + dataSources: DataSource[]; + dataSourcesCount: number; + layoutMode: LayoutMode; + loadDataSources: typeof loadDataSources; +} + +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 } = this.props; + + return ( +
    + +
    + {dataSourcesCount === 0 ? ( + + ) : ( + [ + , + , + ] + )} +
    +
    + ); + } +} + +function mapStateToProps(state) { + return { + navModel: getNavModel(state.navIndex, 'datasources'), + dataSources: getDataSources(state.dataSources), + layoutMode: getDataSourcesLayoutMode(state.dataSources), + dataSourcesCount: getDataSourcesCount(state.dataSources), + }; +} + +const mapDispatchToProps = { + loadDataSources, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage)); 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__/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__/DataSourcesActionBar.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap new file mode 100644 index 00000000000..24f9f2126d0 --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap @@ -0,0 +1,42 @@ +// 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`] = ` +
  • + +
    +
    + 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..c19ee641e1b --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap @@ -0,0 +1,164 @@ +// 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.ts b/public/app/features/datasources/state/actions.ts new file mode 100644 index 00000000000..297797f2e59 --- /dev/null +++ b/public/app/features/datasources/state/actions.ts @@ -0,0 +1,51 @@ +import { ThunkAction } from 'redux-thunk'; +import { DataSource, StoreState } from 'app/types'; +import { getBackendSrv } from '../../../core/services/backend_srv'; +import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; + +export enum ActionTypes { + LoadDataSources = 'LOAD_DATA_SOURCES', + SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY', + SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE', +} + +export interface LoadDataSourcesAction { + type: ActionTypes.LoadDataSources; + payload: DataSource[]; +} + +export interface SetDataSourcesSearchQueryAction { + type: ActionTypes.SetDataSourcesSearchQuery; + payload: string; +} + +export interface SetDataSourcesLayoutModeAction { + type: ActionTypes.SetDataSourcesLayoutMode; + payload: LayoutMode; +} + +const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({ + type: ActionTypes.LoadDataSources, + payload: dataSources, +}); + +export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({ + type: ActionTypes.SetDataSourcesSearchQuery, + payload: searchQuery, +}); + +export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSourcesLayoutModeAction => ({ + type: ActionTypes.SetDataSourcesLayoutMode, + payload: layoutMode, +}); + +export type Action = LoadDataSourcesAction | SetDataSourcesSearchQueryAction | SetDataSourcesLayoutModeAction; + +type ThunkResult = ThunkAction; + +export function loadDataSources(): ThunkResult { + return async dispatch => { + const response = await getBackendSrv().get('/api/datasources'); + dispatch(dataSourcesLoaded(response)); + }; +} diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts new file mode 100644 index 00000000000..d57b0ad523a --- /dev/null +++ b/public/app/features/datasources/state/reducers.ts @@ -0,0 +1,29 @@ +import { DataSource, DataSourcesState } from 'app/types'; +import { Action, ActionTypes } from './actions'; +import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector'; + +const initialState: DataSourcesState = { + dataSources: [] as DataSource[], + layoutMode: LayoutModes.Grid, + searchQuery: '', + dataSourcesCount: 0, +}; + +export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => { + switch (action.type) { + case ActionTypes.LoadDataSources: + return { ...state, dataSources: action.payload, dataSourcesCount: action.payload.length }; + + case ActionTypes.SetDataSourcesSearchQuery: + return { ...state, searchQuery: action.payload }; + + case ActionTypes.SetDataSourcesLayoutMode: + return { ...state, layoutMode: 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..6df08f68037 --- /dev/null +++ b/public/app/features/datasources/state/selectors.ts @@ -0,0 +1,11 @@ +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 getDataSourcesSearchQuery = state => state.searchQuery; +export const getDataSourcesLayoutMode = state => state.layoutMode; +export const getDataSourcesCount = state => state.dataSourcesCount; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 187d68133cd..502ad65b353 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -2,19 +2,20 @@ import React from 'react'; import { hot } from 'react-hot-loader'; import Select from 'react-select'; +import { ExploreState, ExploreUrlState } from 'app/types/explore'; 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 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 TimePicker from './TimePicker'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; const MAX_HISTORY_ITEMS = 100; @@ -47,61 +48,32 @@ 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); - } - } - return { datasource: null, queries: [], range: DEFAULT_RANGE }; +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; } -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; -} - -export class Explore extends React.Component { +export class Explore extends React.PureComponent { el: any; constructor(props) { super(props); - const initialState: ExploreState = props.initialState; - const { datasource, queries, range } = parseUrlState(props.routeParams.state); + // Split state overrides everything + const splitState: ExploreState = props.splitState; + const { datasource, queries, range } = props.urlState; this.state = { datasource: null, datasourceError: null, datasourceLoading: null, datasourceMissing: false, + datasourceName: datasource, graphResult: null, - initialDatasource: datasource, history: [], latency: 0, loading: false, @@ -118,13 +90,13 @@ export class Explore extends React.Component { supportsLogs: null, supportsTable: null, tableResult: null, - ...initialState, + ...splitState, }; } 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.'); } @@ -133,15 +105,15 @@ export class Explore extends React.Component { this.setState({ datasourceLoading: true }); // 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 }); } @@ -188,9 +160,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(); + } + } ); } @@ -220,7 +197,8 @@ export class Explore extends React.Component { queryHints: [], tableResult: null, }); - const datasource = await this.props.datasourceSrv.get(option.value); + const datasourceName = option.value; + const datasource = await this.props.datasourceSrv.get(datasourceName); this.setDatasource(datasource); }; @@ -259,21 +237,25 @@ export class Explore extends React.Component { }; onClickClear = () => { - this.setState({ - graphResult: null, - logsResult: null, - latency: 0, - queries: ensureQueries(), - queryErrors: [], - queryHints: [], - tableResult: null, - }); + this.setState( + { + graphResult: null, + logsResult: null, + latency: 0, + queries: ensureQueries(), + queryErrors: [], + queryHints: [], + tableResult: null, + }, + this.saveState + ); }; onClickCloseSplit = () => { const { onChangeSplit } = this.props; if (onChangeSplit) { onChangeSplit(false); + this.saveState(); } }; @@ -291,6 +273,7 @@ export class Explore extends React.Component { state.queries = state.queries.map(({ edited, ...rest }) => rest); if (onChangeSplit) { onChangeSplit(true, state); + this.saveState(); } }; @@ -349,6 +332,7 @@ export class Explore extends React.Component { if (showingLogs && supportsLogs) { this.runLogsQuery(); } + this.saveState(); }; onQuerySuccess(datasourceId: string, queries: any[]): void { @@ -471,6 +455,11 @@ export class Explore extends React.Component { return datasource.metadataRequest(url); }; + saveState = () => { + const { stateKey, onSaveState } = this.props; + onSaveState(stateKey, this.state); + }; + render() { const { datasourceSrv, position, split } = this.props; const { @@ -528,10 +517,11 @@ export class Explore extends React.Component { {!datasourceMissing ? (
    -
    -
    - Role - - - -
    -
    - -
    -
    - - -

    Existing Keys

    - - - - - - - - - - - - - - - -
    NameRole
    {{t.name}}{{t.role}} - - - -
    -
    - - - diff --git a/public/app/features/org/partials/orgUsers.html b/public/app/features/org/partials/orgUsers.html index 697879c6ac2..8f75625b506 100644 --- a/public/app/features/org/partials/orgUsers.html +++ b/public/app/features/org/partials/orgUsers.html @@ -2,16 +2,25 @@
    -
    +
    +
    Text options
    +
    + Default value + +
    +
    +
    Query Options
    diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 34771618095..e2b99d69df9 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; diff --git a/public/app/plugins/datasource/cloudwatch/partials/config.html b/public/app/plugins/datasource/cloudwatch/partials/config.html index a4df0c14807..e5ab0910cba 100644 --- a/public/app/plugins/datasource/cloudwatch/partials/config.html +++ b/public/app/plugins/datasource/cloudwatch/partials/config.html @@ -39,7 +39,7 @@
    - + Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region. 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/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/meta_query.ts b/public/app/plugins/datasource/postgres/meta_query.ts index 7339a3e3882..fd13f3b4482 100644 --- a/public/app/plugins/datasource/postgres/meta_query.ts +++ b/public/app/plugins/datasource/postgres/meta_query.ts @@ -144,6 +144,7 @@ table_schema IN ( let query = 'SELECT DISTINCT quote_literal(' + column + ')'; query += ' FROM ' + this.target.table; query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')'; + query += ' AND ' + column + ' IS NOT NULL'; query += ' ORDER BY 1 LIMIT 100'; return query; } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index b53b9eb34c1..17a4b6b0f95 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -149,8 +149,6 @@ export class PrometheusDatasource { editorSrc: string; name: string; ruleMappings: { [index: string]: string }; - supportsExplore: boolean; - supportMetrics: boolean; url: string; directUrl: string; basicAuth: any; @@ -166,8 +164,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; @@ -522,10 +518,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, })); 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..d3847562156 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/config_ctrl.ts @@ -0,0 +1,74 @@ +export class StackdriverConfigCtrl { + static templateUrl = 'public/app/plugins/datasource/stackdriver/partials/config.html'; + datasourceSrv: any; + current: any; + jsonText: string; + validationErrors: string[] = []; + inputDataValid: boolean; + + /** @ngInject */ + constructor(datasourceSrv) { + this.datasourceSrv = datasourceSrv; + this.current.jsonData = this.current.jsonData || {}; + this.current.secureJsonData = this.current.secureJsonData || {}; + this.current.secureJsonFields = this.current.secureJsonFields || {}; + } + + 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 (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 = {}; + 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..628e480c3db --- /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], + 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..8ff81f3160a --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -0,0 +1,264 @@ +import { stackdriverUnitMappings } from './constants'; +import appEvents from 'app/core/app_events'; + +export default class StackdriverDatasource { + id: number; + url: string; + baseUrl: string; + projectName: string; + + /** @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 || ''; + } + + 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', + }; + }); + + 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; + } + + 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 = 'none'; + 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 => { + result.push({ + target: series.name, + datapoints: series.points, + refId: queryRes.refId, + meta: queryRes.meta, + unit, + }); + }); + }); + } + + return { data: result }; + } + + 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'); + } + + testDatasource() { + const path = `v3/projects/${this.projectName}/metricDescriptors`; + return this.doRequest(`${this.baseUrl}${path}`) + .then(response => { + if (response.status === 200) { + return { + status: 'success', + message: 'Successfully queried the Stackdriver API.', + title: 'Success', + }; + } + + return { + status: 'error', + message: 'Returned http status code ' + response.status, + }; + }) + .catch(error => { + let message = 'Stackdriver: '; + message += error.statusText ? error.statusText + ': ' : ''; + + if (error.data && error.data.error && error.data.error.code) { + // 400, 401 + message += error.data.error.code + '. ' + error.data.error.message; + } else { + message += 'Cannot connect to Stackdriver API'; + } + return { + status: 'error', + message: message, + }; + }); + } + + async getProjects() { + const response = await this.doRequest(`/cloudresourcemanager/v1/projects`); + return response.data.projects.map(p => ({ id: p.projectId, name: p.name })); + } + + async getDefaultProject() { + try { + const projects = await this.getProjects(); + if (projects && projects.length > 0) { + const test = projects.filter(p => p.id === this.projectName)[0]; + return test; + } else { + throw new Error('No projects found'); + } + } catch (error) { + let message = 'Projects cannot be fetched: '; + message += error.statusText ? error.statusText + ': ' : ''; + if (error && error.data && error.data.error && error.data.error.message) { + if (error.data.error.code === 403) { + message += ` + A list of projects could not be fetched from the Google Cloud Resource Manager API. + You might need to enable it first: + https://console.developers.google.com/apis/library/cloudresourcemanager.googleapis.com`; + } else { + message += error.data.error.code + '. ' + error.data.error.message; + } + } else { + message += 'Cannot connect to Stackdriver API'; + } + appEvents.emit('ds-request-error', message); + } + } + + async getMetricTypes(projectId: string) { + try { + const metricsApiPath = `v3/projects/${projectId}/metricDescriptors`; + const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`); + return data.metricDescriptors; + } catch (error) { + console.log(error); + } + } + + 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..8d193f663c2 --- /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(); + } + } + + 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[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 @@ + + +
    +
    + Title + +
    +
    + Text + +
    +
    +
    +
    +
    + +
    +
    Annotation Query Format
    +An annotation is an event that is overlaid on top of graphs. Annotation rendering is expensive so it is important to limit the number of rows returned. + +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 + + +{{metric.value}} = value of the metric/point +{{metric.type}} = metric type e.g. compute.googleapis.com/instance/cpu/usage_time +{{metric.name}} = name part of metric e.g. instance/cpu/usage_time +{{metric.service}} = service part of metric e.g. compute + +{{metric.label.label_name}} = Metric label metadata e.g. metric.label.instance_name +{{resource.label.label_name}} = Resource label metadata e.g. resource.label.zone +
    +
    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..d8029abc39f --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/partials/config.html @@ -0,0 +1,84 @@ +
    +
    +
    GCP Service Account
    +

    + 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. +

    +

    + The Monitoring Viewer role provides all the permissions that Grafana needs. +

    +

    + The following APIs need to be enabled on GCP for the datasource to work: +

    +

    +

    Detailed instructions on how to create a Service Account can be found in + the documentation.

    +
    +
    + +
    +
    +

    Service Account Authentication

    + Upload your Service Account key file or paste in the contents of the file. The file + contents will be encrypted and saved in the Grafana database. +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    Or paste Service Account key JSON
    +
    + +
    +
    + + {{valError}} +
    +
    +
    +
    + +
    +
    Uploaded Key Details
    + +
    + Project + +
    +
    + Client Email + +
    +
    + Token URI + +
    +
    + Private Key + +
    + +
    + Reset Service + Account Key + + Reset to clear the uploaded key and upload a new file. + +
    +
    + +

    Do not forget to save your changes after uploading a file.

    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..95793b6e7b1 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/partials/query.editor.html @@ -0,0 +1,62 @@ + + + +
    +
    + Alias By + +
    +
    +
    +
    +
    +
    +
    + Project + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    +
    {{ctrl.lastQueryMeta.rawQueryString}}
    +
    +
    +
    Alias Patterns
    Format the legend keys any way you want by using alias patterns. + +{{metric.name}} - {{metric.label.instance_name}} + +cpu/usage_time - server1-europe-west-1 + + +{{metric.type}} = metric type e.g. compute.googleapis.com/instance/cpu/usage_time +{{metric.name}} = name part of metric e.g. instance/cpu/usage_time +{{metric.service}} = service part of metric e.g. compute + +{{metric.label.label_name}} = Metric label metadata e.g. metric.label.instance_name +{{resource.label.label_name}} = Resource label metadata e.g. resource.label.zone +
    +
    +
    +
    {{ctrl.lastQueryError}}
    +
    +
    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..9ec59005a0b --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/partials/query.filter.html @@ -0,0 +1,37 @@ +
    +
    + Service + +
    +
    + Metric + +
    +
    +
    +
    +
    +
    +
    + Filter +
    + +
    +
    +
    +
    +
    +
    +
    +
    + Group By +
    + +
    +
    +
    +
    +
    +
    diff --git a/public/app/plugins/datasource/stackdriver/plugin.json b/public/app/plugins/datasource/stackdriver/plugin.json new file mode 100644 index 00000000000..06a6880002b --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/plugin.json @@ -0,0 +1,56 @@ +{ + "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", + "https://www.googleapis.com/auth/cloudplatformprojects.readonly" + ], + "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..de144071b93 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/query_aggregation_ctrl.ts @@ -0,0 +1,86 @@ +import angular from 'angular'; +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; + + 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 : ''; + } +} + +angular.module('grafana.controllers').directive('stackdriverAggregation', StackdriverAggregation); +angular.module('grafana.controllers').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..8e1f24edeb7 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/query_ctrl.ts @@ -0,0 +1,106 @@ +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: { + project: { + id: string; + name: 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 = { + project: { + id: 'default', + name: '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; + } + } + console.error(err); + } +} 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..37625b559f0 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/query_filter_ctrl.ts @@ -0,0 +1,288 @@ +import angular from 'angular'; +import _ from 'lodash'; +import { FilterSegments, DefaultRemoveFilterValue } from './filter_segments'; +import appEvents from 'app/core/app_events'; + +export class StackdriverFilter { + 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[] }; + + defaultRemoveGroupByValue = '-- remove group by --'; + 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.getGroupBys.bind(this, null, null, DefaultRemoveFilterValue, false), + this.getFilterValues.bind(this) + ); + this.filterSegments.buildSegmentModel(); + } + + async getCurrentProject() { + this.target.project = await this.datasource.getDefaultProject(); + } + + async loadMetricDescriptors() { + if (this.target.project.id !== 'default') { + this.metricDescriptors = await this.datasource.getMetricTypes(this.target.project.id); + 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 => { + const [service] = m.type.split('/'); + const [serviceShortName] = service.split('.'); + return { + value: service, + text: 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 => { + const [service] = m.type.split('/'); + const [serviceShortName] = service.split('.'); + return { + service, + value: m.type, + 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; + 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 getGroupBys(segment, index, removeText?: string, removeUsed = true) { + await this.loadLabelsPromise; + + const metricLabels = Object.keys(this.metricLabels || {}) + .filter(ml => { + if (!removeUsed) { + return true; + } + return this.target.aggregation.groupBys.indexOf('metric.label.' + ml) === -1; + }) + .map(l => { + return this.uiSegmentSrv.newSegment({ + value: `metric.label.${l}`, + expandable: false, + }); + }); + + const resourceLabels = Object.keys(this.resourceLabels || {}) + .filter(ml => { + if (!removeUsed) { + return true; + } + + return this.target.aggregation.groupBys.indexOf('resource.label.' + ml) === -1; + }) + .map(l => { + return this.uiSegmentSrv.newSegment({ + value: `resource.label.${l}`, + expandable: false, + }); + }); + + const noValueOrPlusButton = !segment || segment.type === 'plus-button'; + if (noValueOrPlusButton && metricLabels.length === 0 && resourceLabels.length === 0) { + return Promise.resolve([]); + } + + this.removeSegment.value = removeText || this.defaultRemoveGroupByValue; + return Promise.resolve([...metricLabels, ...resourceLabels, 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]; + } + + 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()); + } + } +} + +angular.module('grafana.controllers').directive('stackdriverFilter', StackdriverFilter); +angular.module('grafana.controllers').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..80830fd4d68 --- /dev/null +++ b/public/app/plugins/datasource/stackdriver/specs/datasource.test.ts @@ -0,0 +1,274 @@ +import StackdriverDataSource from '../datasource'; +import { metricDescriptors } from './testData'; +import moment from 'moment'; +import { TemplateSrvStub } from 'test/specs/helpers'; + +describe('StackdriverDataSource', () => { + const instanceSettings = { + jsonData: { + projectName: '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 getProjects', () => { + describe('and call to resource manager api succeeds', () => { + let ds; + let result; + beforeEach(async () => { + const response = { + projects: [ + { + projectNumber: '853996325002', + projectId: 'test-project', + lifecycleState: 'ACTIVE', + name: 'Test Project', + createTime: '2015-06-02T14:16:08.520Z', + parent: { + type: 'organization', + id: '853996325002', + }, + }, + ], + }; + const backendSrv = { + async datasourceRequest() { + return Promise.resolve({ status: 200, data: response }); + }, + }; + ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv); + result = await ds.getProjects(); + }); + + it('should return successfully', () => { + expect(result.length).toBe(1); + expect(result[0].id).toBe('test-project'); + expect(result[0].name).toBe('Test Project'); + }); + }); + }); + + 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: 'test metric type 1', + }, + { + displayName: 'test metric name 2', + type: 'test metric type 2', + }, + ], + }, + }); + }, + }; + ds = new StackdriverDataSource(instanceSettings, backendSrv, templateSrv, timeSrv); + result = await ds.getMetricTypes(); + }); + it('should return successfully', () => { + expect(result.length).toBe(2); + expect(result[0].type).toBe('test metric type 1'); + expect(result[0].displayName).toBe('test metric name 1'); + }); + }); + + 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 none', () => { + expect(res).toEqual('none'); + }); + }); + 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 - none', () => { + expect(res).toEqual('none'); + }); + }); + describe('and all target units are not the same', () => { + beforeEach(() => { + res = ds.resolvePanelUnitFromTargets([{ unit: 'bit' }, { unit: 'min' }]); + }); + it('should return the default value - none', () => { + expect(res).toEqual('none'); + }); + }); + }); + }); +}); 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/routes/routes.ts b/public/app/routes/routes.ts index 015b4ae0b51..203824e6c17 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -5,8 +5,11 @@ 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'; /** @ngInject */ export function setupAngularRoutes($routeProvider, $locationProvider) { @@ -61,9 +64,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', @@ -114,6 +118,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { }) .when('/explore', { template: '', + reloadOnSearch: false, resolve: { roles: () => ['Editor', 'Admin'], component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'), @@ -138,8 +143,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: '', @@ -245,9 +253,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', diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 8f6cf25043d..4bae6cf86d0 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -4,15 +4,21 @@ 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'; const rootReducer = combineReducers({ ...sharedReducers, ...alertingReducers, ...teamsReducers, + ...apiKeysReducers, ...foldersReducers, ...dashboardReducers, + ...pluginReducers, + ...dataSourcesReducers, }); export let store; diff --git a/public/app/types/apiKeys.ts b/public/app/types/apiKeys.ts new file mode 100644 index 00000000000..6288f5165ad --- /dev/null +++ b/public/app/types/apiKeys.ts @@ -0,0 +1,17 @@ +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; +} diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index 78ff7b0724c..b9936e7c01b 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -1,7 +1,25 @@ +import { LayoutMode } from '../core/components/LayoutSelector/LayoutSelector'; + export interface DataSource { id: number; orgId: number; name: string; typeLogoUrl: string; type: string; + access: string; + url: string; + password: string; + user: string; + database: string; + basicAuth: false; + isDefault: false; + jsonData: { authType: string; defaultRegion: string }; + readOnly: false; +} + +export interface DataSourcesState { + dataSources: DataSource[]; + searchQuery: string; + layoutMode: LayoutMode; + dataSourcesCount: number; } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts new file mode 100644 index 00000000000..d6ee828e3d3 --- /dev/null +++ b/public/app/types/explore.ts @@ -0,0 +1,41 @@ +export interface Range { + from: string; + to: string; +} + +export interface Query { + query: string; + edited?: boolean; + key?: string; +} + +export interface ExploreState { + datasource: any; + datasourceError: any; + datasourceLoading: boolean | null; + datasourceMissing: boolean; + datasourceName?: string; + graphResult: any; + history: any[]; + latency: number; + loading: any; + logsResult: any; + queries: Query[]; + queryErrors: any[]; + queryHints: any[]; + range: Range; + requestOptions: any; + showingGraph: boolean; + showingLogs: boolean; + showingTable: boolean; + supportsGraph: boolean | null; + supportsLogs: boolean | null; + supportsTable: boolean | null; + tableResult: any; +} + +export interface ExploreUrlState { + datasource: string; + queries: Query[]; + range: Range; +} diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 778a1b21b55..a1b5b53260d 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -5,8 +5,10 @@ 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 { User } from './user'; +import { DataSource, DataSourcesState } from './datasources'; +import { PluginMeta, Plugin, PluginsState } from './plugins'; export { Team, @@ -33,6 +35,13 @@ export { PermissionLevel, DataSource, PluginMeta, + ApiKey, + ApiKeysState, + NewApiKey, + User, + Plugin, + PluginsState, + DataSourcesState, }; export interface StoreState { diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts index d26085f8e73..92bebfef8d4 100644 --- a/public/app/types/plugins.ts +++ b/public/app/types/plugins.ts @@ -12,8 +12,36 @@ 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 PluginsState { + plugins: Plugin[]; + searchQuery: string; + layoutMode: string; } diff --git a/public/app/types/user.ts b/public/app/types/user.ts new file mode 100644 index 00000000000..9c13e6b027b --- /dev/null +++ b/public/app/types/user.ts @@ -0,0 +1,6 @@ +export interface User { + id: number; + label: string; + avatarUrl: string; + login: string; +} diff --git a/public/img/rendering_error.png b/public/img/rendering_error.png new file mode 100644 index 00000000000..cc327c267be Binary files /dev/null and b/public/img/rendering_error.png differ diff --git a/public/img/rendering_limit.png b/public/img/rendering_limit.png new file mode 100644 index 00000000000..f2ba9aad0ba Binary files /dev/null and b/public/img/rendering_limit.png differ diff --git a/public/img/rendering_plugin_not_installed.png b/public/img/rendering_plugin_not_installed.png new file mode 100644 index 00000000000..f135ff7cc9f Binary files /dev/null and b/public/img/rendering_plugin_not_installed.png differ diff --git a/public/img/rendering_timeout.png b/public/img/rendering_timeout.png new file mode 100644 index 00000000000..07a87eb5de3 Binary files /dev/null and b/public/img/rendering_timeout.png differ 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/_form_select_box.scss b/public/sass/components/_form_select_box.scss index 0401f06eba8..4db294317f7 100644 --- a/public/sass/components/_form_select_box.scss +++ b/public/sass/components/_form_select_box.scss @@ -3,7 +3,7 @@ $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-bg: $menu-dropdown-bg; $select-option-color: $input-color; $select-noresults-color: $text-color; $select-input-bg: $input-bg; @@ -82,20 +82,14 @@ $select-option-selected-bg: $dropdownLinkBackgroundActive; width: auto; } + .Select-option { + border-left: 2px solid transparent; + } + .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%); - } + @include left-brand-border-gradient(); } } diff --git a/public/sass/components/_infobox.scss b/public/sass/components/_infobox.scss index 9a6a2e78a4f..52be4b4737c 100644 --- a/public/sass/components/_infobox.scss +++ b/public/sass/components/_infobox.scss @@ -19,6 +19,15 @@ 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; + } + a { @extend .external-link; } diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index 72f3ca3dbbe..01fcc5a3e64 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -17,10 +17,6 @@ padding-left: 0px; } - .graph-legend-table { - width: auto; - } - .graph-legend-table .graph-legend-series { display: table-row; } @@ -35,7 +31,6 @@ } .datapoints-warning { - pointer: none; position: absolute; top: 50%; left: 50%; diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index 903193d8b10..f8bd1108fb1 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -55,6 +55,25 @@ margin-top: 2 * $panel-margin; } + .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; + } + } + .elapsed-time { position: absolute; left: 0; @@ -69,7 +88,7 @@ } .datasource-picker { - min-width: 10rem; + min-width: 200px; } .timepicker { diff --git a/public/views/index.template.html b/public/views/index.template.html index 606db2c769e..c39d5e08321 100644 --- a/public/views/index.template.html +++ b/public/views/index.template.html @@ -184,7 +184,7 @@
    Loading Grafana

    - If your seeing this Grafana has failed to load its application files + If you're seeing this Grafana has failed to load its application files

    @@ -275,7 +275,10 @@ document.head.insertBefore(myCSS, document.head.childNodes[document.head.childNodes.length - 1].nextSibling); // switch loader to show all has loaded window.onload = function() { - document.getElementsByClassName("preloader")[0].className = "preloader preloader--done"; + var preloader = document.getElementsByClassName("preloader"); + if (preloader.length) { + preloader[0].className = "preloader preloader--done"; + } }; diff --git a/scripts/grunt/default_task.js b/scripts/grunt/default_task.js index 07519cdd6c8..bfa4080da4c 100644 --- a/scripts/grunt/default_task.js +++ b/scripts/grunt/default_task.js @@ -1,5 +1,5 @@ // Lint and build CSS -module.exports = function(grunt) { +module.exports = function (grunt) { 'use strict'; grunt.registerTask('default', [ @@ -18,15 +18,16 @@ module.exports = function(grunt) { grunt.registerTask('precommit', [ 'sasslint', 'exec:tslint', + 'exec:tsc', 'no-only-tests' ]); - grunt.registerTask('no-only-tests', function() { + grunt.registerTask('no-only-tests', function () { var files = grunt.file.expand('public/**/*_specs\.ts', 'public/**/*_specs\.js'); - files.forEach(function(spec) { + files.forEach(function (spec) { var rows = grunt.file.read(spec).split('\n'); - rows.forEach(function(row) { + rows.forEach(function (row) { if (row.indexOf('.only(') > 0) { grunt.log.errorlns(row); grunt.fail.warn('found only statement in test: ' + spec) diff --git a/scripts/grunt/options/exec.js b/scripts/grunt/options/exec.js index 92e530cd5fd..d01a993be67 100644 --- a/scripts/grunt/options/exec.js +++ b/scripts/grunt/options/exec.js @@ -1,8 +1,9 @@ -module.exports = function(config, grunt) { +module.exports = function (config, grunt) { 'use strict'; return { tslint: 'node ./node_modules/tslint/lib/tslintCli.js -c tslint.json --project ./tsconfig.json', + tsc: 'yarn tsc --noEmit', jest: 'node ./node_modules/jest-cli/bin/jest.js --maxWorkers 2', webpack: 'node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js', }; diff --git a/vendor/github.com/VividCortex/mysqlerr/LICENSE b/vendor/github.com/VividCortex/mysqlerr/LICENSE new file mode 100644 index 00000000000..92bb301a02f --- /dev/null +++ b/vendor/github.com/VividCortex/mysqlerr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 VividCortex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/VividCortex/mysqlerr/mysqlerr.go b/vendor/github.com/VividCortex/mysqlerr/mysqlerr.go new file mode 100644 index 00000000000..ce62e7d5041 --- /dev/null +++ b/vendor/github.com/VividCortex/mysqlerr/mysqlerr.go @@ -0,0 +1,1080 @@ +package mysqlerr + +const ( + ER_HASHCHK = 1000 + ER_NISAMCHK = 1001 + ER_NO = 1002 + ER_YES = 1003 + ER_CANT_CREATE_FILE = 1004 + ER_CANT_CREATE_TABLE = 1005 + ER_CANT_CREATE_DB = 1006 + ER_DB_CREATE_EXISTS = 1007 + ER_DB_DROP_EXISTS = 1008 + ER_DB_DROP_DELETE = 1009 + ER_DB_DROP_RMDIR = 1010 + ER_CANT_DELETE_FILE = 1011 + ER_CANT_FIND_SYSTEM_REC = 1012 + ER_CANT_GET_STAT = 1013 + ER_CANT_GET_WD = 1014 + ER_CANT_LOCK = 1015 + ER_CANT_OPEN_FILE = 1016 + ER_FILE_NOT_FOUND = 1017 + ER_CANT_READ_DIR = 1018 + ER_CANT_SET_WD = 1019 + ER_CHECKREAD = 1020 + ER_DISK_FULL = 1021 + ER_DUP_KEY = 1022 + ER_ERROR_ON_CLOSE = 1023 + ER_ERROR_ON_READ = 1024 + ER_ERROR_ON_RENAME = 1025 + ER_ERROR_ON_WRITE = 1026 + ER_FILE_USED = 1027 + ER_FILSORT_ABORT = 1028 + ER_FORM_NOT_FOUND = 1029 + ER_GET_ERRNO = 1030 + ER_ILLEGAL_HA = 1031 + ER_KEY_NOT_FOUND = 1032 + ER_NOT_FORM_FILE = 1033 + ER_NOT_KEYFILE = 1034 + ER_OLD_KEYFILE = 1035 + ER_OPEN_AS_READONLY = 1036 + ER_OUTOFMEMORY = 1037 + ER_OUT_OF_SORTMEMORY = 1038 + ER_UNEXPECTED_EOF = 1039 + ER_CON_COUNT_ERROR = 1040 + ER_OUT_OF_RESOURCES = 1041 + ER_BAD_HOST_ERROR = 1042 + ER_HANDSHAKE_ERROR = 1043 + ER_DBACCESS_DENIED_ERROR = 1044 + ER_ACCESS_DENIED_ERROR = 1045 + ER_NO_DB_ERROR = 1046 + ER_UNKNOWN_COM_ERROR = 1047 + ER_BAD_NULL_ERROR = 1048 + ER_BAD_DB_ERROR = 1049 + ER_TABLE_EXISTS_ERROR = 1050 + ER_BAD_TABLE_ERROR = 1051 + ER_NON_UNIQ_ERROR = 1052 + ER_SERVER_SHUTDOWN = 1053 + ER_BAD_FIELD_ERROR = 1054 + ER_WRONG_FIELD_WITH_GROUP = 1055 + ER_WRONG_GROUP_FIELD = 1056 + ER_WRONG_SUM_SELECT = 1057 + ER_WRONG_VALUE_COUNT = 1058 + ER_TOO_LONG_IDENT = 1059 + ER_DUP_FIELDNAME = 1060 + ER_DUP_KEYNAME = 1061 + ER_DUP_ENTRY = 1062 + ER_WRONG_FIELD_SPEC = 1063 + ER_PARSE_ERROR = 1064 + ER_EMPTY_QUERY = 1065 + ER_NONUNIQ_TABLE = 1066 + ER_INVALID_DEFAULT = 1067 + ER_MULTIPLE_PRI_KEY = 1068 + ER_TOO_MANY_KEYS = 1069 + ER_TOO_MANY_KEY_PARTS = 1070 + ER_TOO_LONG_KEY = 1071 + ER_KEY_COLUMN_DOES_NOT_EXITS = 1072 + ER_BLOB_USED_AS_KEY = 1073 + ER_TOO_BIG_FIELDLENGTH = 1074 + ER_WRONG_AUTO_KEY = 1075 + ER_READY = 1076 + ER_NORMAL_SHUTDOWN = 1077 + ER_GOT_SIGNAL = 1078 + ER_SHUTDOWN_COMPLETE = 1079 + ER_FORCING_CLOSE = 1080 + ER_IPSOCK_ERROR = 1081 + ER_NO_SUCH_INDEX = 1082 + ER_WRONG_FIELD_TERMINATORS = 1083 + ER_BLOBS_AND_NO_TERMINATED = 1084 + ER_TEXTFILE_NOT_READABLE = 1085 + ER_FILE_EXISTS_ERROR = 1086 + ER_LOAD_INFO = 1087 + ER_ALTER_INFO = 1088 + ER_WRONG_SUB_KEY = 1089 + ER_CANT_REMOVE_ALL_FIELDS = 1090 + ER_CANT_DROP_FIELD_OR_KEY = 1091 + ER_INSERT_INFO = 1092 + ER_UPDATE_TABLE_USED = 1093 + ER_NO_SUCH_THREAD = 1094 + ER_KILL_DENIED_ERROR = 1095 + ER_NO_TABLES_USED = 1096 + ER_TOO_BIG_SET = 1097 + ER_NO_UNIQUE_LOGFILE = 1098 + ER_TABLE_NOT_LOCKED_FOR_WRITE = 1099 + ER_TABLE_NOT_LOCKED = 1100 + ER_BLOB_CANT_HAVE_DEFAULT = 1101 + ER_WRONG_DB_NAME = 1102 + ER_WRONG_TABLE_NAME = 1103 + ER_TOO_BIG_SELECT = 1104 + ER_UNKNOWN_ERROR = 1105 + ER_UNKNOWN_PROCEDURE = 1106 + ER_WRONG_PARAMCOUNT_TO_PROCEDURE = 1107 + ER_WRONG_PARAMETERS_TO_PROCEDURE = 1108 + ER_UNKNOWN_TABLE = 1109 + ER_FIELD_SPECIFIED_TWICE = 1110 + ER_INVALID_GROUP_FUNC_USE = 1111 + ER_UNSUPPORTED_EXTENSION = 1112 + ER_TABLE_MUST_HAVE_COLUMNS = 1113 + ER_RECORD_FILE_FULL = 1114 + ER_UNKNOWN_CHARACTER_SET = 1115 + ER_TOO_MANY_TABLES = 1116 + ER_TOO_MANY_FIELDS = 1117 + ER_TOO_BIG_ROWSIZE = 1118 + ER_STACK_OVERRUN = 1119 + ER_WRONG_OUTER_JOIN = 1120 + ER_NULL_COLUMN_IN_INDEX = 1121 + ER_CANT_FIND_UDF = 1122 + ER_CANT_INITIALIZE_UDF = 1123 + ER_UDF_NO_PATHS = 1124 + ER_UDF_EXISTS = 1125 + ER_CANT_OPEN_LIBRARY = 1126 + ER_CANT_FIND_DL_ENTRY = 1127 + ER_FUNCTION_NOT_DEFINED = 1128 + ER_HOST_IS_BLOCKED = 1129 + ER_HOST_NOT_PRIVILEGED = 1130 + ER_PASSWORD_ANONYMOUS_USER = 1131 + ER_PASSWORD_NOT_ALLOWED = 1132 + ER_PASSWORD_NO_MATCH = 1133 + ER_UPDATE_INFO = 1134 + ER_CANT_CREATE_THREAD = 1135 + ER_WRONG_VALUE_COUNT_ON_ROW = 1136 + ER_CANT_REOPEN_TABLE = 1137 + ER_INVALID_USE_OF_NULL = 1138 + ER_REGEXP_ERROR = 1139 + ER_MIX_OF_GROUP_FUNC_AND_FIELDS = 1140 + ER_NONEXISTING_GRANT = 1141 + ER_TABLEACCESS_DENIED_ERROR = 1142 + ER_COLUMNACCESS_DENIED_ERROR = 1143 + ER_ILLEGAL_GRANT_FOR_TABLE = 1144 + ER_GRANT_WRONG_HOST_OR_USER = 1145 + ER_NO_SUCH_TABLE = 1146 + ER_NONEXISTING_TABLE_GRANT = 1147 + ER_NOT_ALLOWED_COMMAND = 1148 + ER_SYNTAX_ERROR = 1149 + ER_DELAYED_CANT_CHANGE_LOCK = 1150 + ER_TOO_MANY_DELAYED_THREADS = 1151 + ER_ABORTING_CONNECTION = 1152 + ER_NET_PACKET_TOO_LARGE = 1153 + ER_NET_READ_ERROR_FROM_PIPE = 1154 + ER_NET_FCNTL_ERROR = 1155 + ER_NET_PACKETS_OUT_OF_ORDER = 1156 + ER_NET_UNCOMPRESS_ERROR = 1157 + ER_NET_READ_ERROR = 1158 + ER_NET_READ_INTERRUPTED = 1159 + ER_NET_ERROR_ON_WRITE = 1160 + ER_NET_WRITE_INTERRUPTED = 1161 + ER_TOO_LONG_STRING = 1162 + ER_TABLE_CANT_HANDLE_BLOB = 1163 + ER_TABLE_CANT_HANDLE_AUTO_INCREMENT = 1164 + ER_DELAYED_INSERT_TABLE_LOCKED = 1165 + ER_WRONG_COLUMN_NAME = 1166 + ER_WRONG_KEY_COLUMN = 1167 + ER_WRONG_MRG_TABLE = 1168 + ER_DUP_UNIQUE = 1169 + ER_BLOB_KEY_WITHOUT_LENGTH = 1170 + ER_PRIMARY_CANT_HAVE_NULL = 1171 + ER_TOO_MANY_ROWS = 1172 + ER_REQUIRES_PRIMARY_KEY = 1173 + ER_NO_RAID_COMPILED = 1174 + ER_UPDATE_WITHOUT_KEY_IN_SAFE_MODE = 1175 + ER_KEY_DOES_NOT_EXITS = 1176 + ER_CHECK_NO_SUCH_TABLE = 1177 + ER_CHECK_NOT_IMPLEMENTED = 1178 + ER_CANT_DO_THIS_DURING_AN_TRANSACTION = 1179 + ER_ERROR_DURING_COMMIT = 1180 + ER_ERROR_DURING_ROLLBACK = 1181 + ER_ERROR_DURING_FLUSH_LOGS = 1182 + ER_ERROR_DURING_CHECKPOINT = 1183 + ER_NEW_ABORTING_CONNECTION = 1184 + ER_DUMP_NOT_IMPLEMENTED = 1185 + ER_FLUSH_MASTER_BINLOG_CLOSED = 1186 + ER_INDEX_REBUILD = 1187 + ER_MASTER = 1188 + ER_MASTER_NET_READ = 1189 + ER_MASTER_NET_WRITE = 1190 + ER_FT_MATCHING_KEY_NOT_FOUND = 1191 + ER_LOCK_OR_ACTIVE_TRANSACTION = 1192 + ER_UNKNOWN_SYSTEM_VARIABLE = 1193 + ER_CRASHED_ON_USAGE = 1194 + ER_CRASHED_ON_REPAIR = 1195 + ER_WARNING_NOT_COMPLETE_ROLLBACK = 1196 + ER_TRANS_CACHE_FULL = 1197 + ER_SLAVE_MUST_STOP = 1198 + ER_SLAVE_NOT_RUNNING = 1199 + ER_BAD_SLAVE = 1200 + ER_MASTER_INFO = 1201 + ER_SLAVE_THREAD = 1202 + ER_TOO_MANY_USER_CONNECTIONS = 1203 + ER_SET_CONSTANTS_ONLY = 1204 + ER_LOCK_WAIT_TIMEOUT = 1205 + ER_LOCK_TABLE_FULL = 1206 + ER_READ_ONLY_TRANSACTION = 1207 + ER_DROP_DB_WITH_READ_LOCK = 1208 + ER_CREATE_DB_WITH_READ_LOCK = 1209 + ER_WRONG_ARGUMENTS = 1210 + ER_NO_PERMISSION_TO_CREATE_USER = 1211 + ER_UNION_TABLES_IN_DIFFERENT_DIR = 1212 + ER_LOCK_DEADLOCK = 1213 + ER_TABLE_CANT_HANDLE_FT = 1214 + ER_CANNOT_ADD_FOREIGN = 1215 + ER_NO_REFERENCED_ROW = 1216 + ER_ROW_IS_REFERENCED = 1217 + ER_CONNECT_TO_MASTER = 1218 + ER_QUERY_ON_MASTER = 1219 + ER_ERROR_WHEN_EXECUTING_COMMAND = 1220 + ER_WRONG_USAGE = 1221 + ER_WRONG_NUMBER_OF_COLUMNS_IN_SELECT = 1222 + ER_CANT_UPDATE_WITH_READLOCK = 1223 + ER_MIXING_NOT_ALLOWED = 1224 + ER_DUP_ARGUMENT = 1225 + ER_USER_LIMIT_REACHED = 1226 + ER_SPECIFIC_ACCESS_DENIED_ERROR = 1227 + ER_LOCAL_VARIABLE = 1228 + ER_GLOBAL_VARIABLE = 1229 + ER_NO_DEFAULT = 1230 + ER_WRONG_VALUE_FOR_VAR = 1231 + ER_WRONG_TYPE_FOR_VAR = 1232 + ER_VAR_CANT_BE_READ = 1233 + ER_CANT_USE_OPTION_HERE = 1234 + ER_NOT_SUPPORTED_YET = 1235 + ER_MASTER_FATAL_ERROR_READING_BINLOG = 1236 + ER_SLAVE_IGNORED_TABLE = 1237 + ER_INCORRECT_GLOBAL_LOCAL_VAR = 1238 + ER_WRONG_FK_DEF = 1239 + ER_KEY_REF_DO_NOT_MATCH_TABLE_REF = 1240 + ER_OPERAND_COLUMNS = 1241 + ER_SUBQUERY_NO_1_ROW = 1242 + ER_UNKNOWN_STMT_HANDLER = 1243 + ER_CORRUPT_HELP_DB = 1244 + ER_CYCLIC_REFERENCE = 1245 + ER_AUTO_CONVERT = 1246 + ER_ILLEGAL_REFERENCE = 1247 + ER_DERIVED_MUST_HAVE_ALIAS = 1248 + ER_SELECT_REDUCED = 1249 + ER_TABLENAME_NOT_ALLOWED_HERE = 1250 + ER_NOT_SUPPORTED_AUTH_MODE = 1251 + ER_SPATIAL_CANT_HAVE_NULL = 1252 + ER_COLLATION_CHARSET_MISMATCH = 1253 + ER_SLAVE_WAS_RUNNING = 1254 + ER_SLAVE_WAS_NOT_RUNNING = 1255 + ER_TOO_BIG_FOR_UNCOMPRESS = 1256 + ER_ZLIB_Z_MEM_ERROR = 1257 + ER_ZLIB_Z_BUF_ERROR = 1258 + ER_ZLIB_Z_DATA_ERROR = 1259 + ER_CUT_VALUE_GROUP_CONCAT = 1260 + ER_WARN_TOO_FEW_RECORDS = 1261 + ER_WARN_TOO_MANY_RECORDS = 1262 + ER_WARN_NULL_TO_NOTNULL = 1263 + ER_WARN_DATA_OUT_OF_RANGE = 1264 + WARN_DATA_TRUNCATED = 1265 + ER_WARN_USING_OTHER_HANDLER = 1266 + ER_CANT_AGGREGATE_2COLLATIONS = 1267 + ER_DROP_USER = 1268 + ER_REVOKE_GRANTS = 1269 + ER_CANT_AGGREGATE_3COLLATIONS = 1270 + ER_CANT_AGGREGATE_NCOLLATIONS = 1271 + ER_VARIABLE_IS_NOT_STRUCT = 1272 + ER_UNKNOWN_COLLATION = 1273 + ER_SLAVE_IGNORED_SSL_PARAMS = 1274 + ER_SERVER_IS_IN_SECURE_AUTH_MODE = 1275 + ER_WARN_FIELD_RESOLVED = 1276 + ER_BAD_SLAVE_UNTIL_COND = 1277 + ER_MISSING_SKIP_SLAVE = 1278 + ER_UNTIL_COND_IGNORED = 1279 + ER_WRONG_NAME_FOR_INDEX = 1280 + ER_WRONG_NAME_FOR_CATALOG = 1281 + ER_WARN_QC_RESIZE = 1282 + ER_BAD_FT_COLUMN = 1283 + ER_UNKNOWN_KEY_CACHE = 1284 + ER_WARN_HOSTNAME_WONT_WORK = 1285 + ER_UNKNOWN_STORAGE_ENGINE = 1286 + ER_WARN_DEPRECATED_SYNTAX = 1287 + ER_NON_UPDATABLE_TABLE = 1288 + ER_FEATURE_DISABLED = 1289 + ER_OPTION_PREVENTS_STATEMENT = 1290 + ER_DUPLICATED_VALUE_IN_TYPE = 1291 + ER_TRUNCATED_WRONG_VALUE = 1292 + ER_TOO_MUCH_AUTO_TIMESTAMP_COLS = 1293 + ER_INVALID_ON_UPDATE = 1294 + ER_UNSUPPORTED_PS = 1295 + ER_GET_ERRMSG = 1296 + ER_GET_TEMPORARY_ERRMSG = 1297 + ER_UNKNOWN_TIME_ZONE = 1298 + ER_WARN_INVALID_TIMESTAMP = 1299 + ER_INVALID_CHARACTER_STRING = 1300 + ER_WARN_ALLOWED_PACKET_OVERFLOWED = 1301 + ER_CONFLICTING_DECLARATIONS = 1302 + ER_SP_NO_RECURSIVE_CREATE = 1303 + ER_SP_ALREADY_EXISTS = 1304 + ER_SP_DOES_NOT_EXIST = 1305 + ER_SP_DROP_FAILED = 1306 + ER_SP_STORE_FAILED = 1307 + ER_SP_LILABEL_MISMATCH = 1308 + ER_SP_LABEL_REDEFINE = 1309 + ER_SP_LABEL_MISMATCH = 1310 + ER_SP_UNINIT_VAR = 1311 + ER_SP_BADSELECT = 1312 + ER_SP_BADRETURN = 1313 + ER_SP_BADSTATEMENT = 1314 + ER_UPDATE_LOG_DEPRECATED_IGNORED = 1315 + ER_UPDATE_LOG_DEPRECATED_TRANSLATED = 1316 + ER_QUERY_INTERRUPTED = 1317 + ER_SP_WRONG_NO_OF_ARGS = 1318 + ER_SP_COND_MISMATCH = 1319 + ER_SP_NORETURN = 1320 + ER_SP_NORETURNEND = 1321 + ER_SP_BAD_CURSOR_QUERY = 1322 + ER_SP_BAD_CURSOR_SELECT = 1323 + ER_SP_CURSOR_MISMATCH = 1324 + ER_SP_CURSOR_ALREADY_OPEN = 1325 + ER_SP_CURSOR_NOT_OPEN = 1326 + ER_SP_UNDECLARED_VAR = 1327 + ER_SP_WRONG_NO_OF_FETCH_ARGS = 1328 + ER_SP_FETCH_NO_DATA = 1329 + ER_SP_DUP_PARAM = 1330 + ER_SP_DUP_VAR = 1331 + ER_SP_DUP_COND = 1332 + ER_SP_DUP_CURS = 1333 + ER_SP_CANT_ALTER = 1334 + ER_SP_SUBSELECT_NYI = 1335 + ER_STMT_NOT_ALLOWED_IN_SF_OR_TRG = 1336 + ER_SP_VARCOND_AFTER_CURSHNDLR = 1337 + ER_SP_CURSOR_AFTER_HANDLER = 1338 + ER_SP_CASE_NOT_FOUND = 1339 + ER_FPARSER_TOO_BIG_FILE = 1340 + ER_FPARSER_BAD_HEADER = 1341 + ER_FPARSER_EOF_IN_COMMENT = 1342 + ER_FPARSER_ERROR_IN_PARAMETER = 1343 + ER_FPARSER_EOF_IN_UNKNOWN_PARAMETER = 1344 + ER_VIEW_NO_EXPLAIN = 1345 + ER_FRM_UNKNOWN_TYPE = 1346 + ER_WRONG_OBJECT = 1347 + ER_NONUPDATEABLE_COLUMN = 1348 + ER_VIEW_SELECT_DERIVED = 1349 + ER_VIEW_SELECT_CLAUSE = 1350 + ER_VIEW_SELECT_VARIABLE = 1351 + ER_VIEW_SELECT_TMPTABLE = 1352 + ER_VIEW_WRONG_LIST = 1353 + ER_WARN_VIEW_MERGE = 1354 + ER_WARN_VIEW_WITHOUT_KEY = 1355 + ER_VIEW_INVALID = 1356 + ER_SP_NO_DROP_SP = 1357 + ER_SP_GOTO_IN_HNDLR = 1358 + ER_TRG_ALREADY_EXISTS = 1359 + ER_TRG_DOES_NOT_EXIST = 1360 + ER_TRG_ON_VIEW_OR_TEMP_TABLE = 1361 + ER_TRG_CANT_CHANGE_ROW = 1362 + ER_TRG_NO_SUCH_ROW_IN_TRG = 1363 + ER_NO_DEFAULT_FOR_FIELD = 1364 + ER_DIVISION_BY_ZERO = 1365 + ER_TRUNCATED_WRONG_VALUE_FOR_FIELD = 1366 + ER_ILLEGAL_VALUE_FOR_TYPE = 1367 + ER_VIEW_NONUPD_CHECK = 1368 + ER_VIEW_CHECK_FAILED = 1369 + ER_PROCACCESS_DENIED_ERROR = 1370 + ER_RELAY_LOG_FAIL = 1371 + ER_PASSWD_LENGTH = 1372 + ER_UNKNOWN_TARGET_BINLOG = 1373 + ER_IO_ERR_LOG_INDEX_READ = 1374 + ER_BINLOG_PURGE_PROHIBITED = 1375 + ER_FSEEK_FAIL = 1376 + ER_BINLOG_PURGE_FATAL_ERR = 1377 + ER_LOG_IN_USE = 1378 + ER_LOG_PURGE_UNKNOWN_ERR = 1379 + ER_RELAY_LOG_INIT = 1380 + ER_NO_BINARY_LOGGING = 1381 + ER_RESERVED_SYNTAX = 1382 + ER_WSAS_FAILED = 1383 + ER_DIFF_GROUPS_PROC = 1384 + ER_NO_GROUP_FOR_PROC = 1385 + ER_ORDER_WITH_PROC = 1386 + ER_LOGGING_PROHIBIT_CHANGING_OF = 1387 + ER_NO_FILE_MAPPING = 1388 + ER_WRONG_MAGIC = 1389 + ER_PS_MANY_PARAM = 1390 + ER_KEY_PART_0 = 1391 + ER_VIEW_CHECKSUM = 1392 + ER_VIEW_MULTIUPDATE = 1393 + ER_VIEW_NO_INSERT_FIELD_LIST = 1394 + ER_VIEW_DELETE_MERGE_VIEW = 1395 + ER_CANNOT_USER = 1396 + ER_XAER_NOTA = 1397 + ER_XAER_INVAL = 1398 + ER_XAER_RMFAIL = 1399 + ER_XAER_OUTSIDE = 1400 + ER_XAER_RMERR = 1401 + ER_XA_RBROLLBACK = 1402 + ER_NONEXISTING_PROC_GRANT = 1403 + ER_PROC_AUTO_GRANT_FAIL = 1404 + ER_PROC_AUTO_REVOKE_FAIL = 1405 + ER_DATA_TOO_LONG = 1406 + ER_SP_BAD_SQLSTATE = 1407 + ER_STARTUP = 1408 + ER_LOAD_FROM_FIXED_SIZE_ROWS_TO_VAR = 1409 + ER_CANT_CREATE_USER_WITH_GRANT = 1410 + ER_WRONG_VALUE_FOR_TYPE = 1411 + ER_TABLE_DEF_CHANGED = 1412 + ER_SP_DUP_HANDLER = 1413 + ER_SP_NOT_VAR_ARG = 1414 + ER_SP_NO_RETSET = 1415 + ER_CANT_CREATE_GEOMETRY_OBJECT = 1416 + ER_FAILED_ROUTINE_BREAK_BINLOG = 1417 + ER_BINLOG_UNSAFE_ROUTINE = 1418 + ER_BINLOG_CREATE_ROUTINE_NEED_SUPER = 1419 + ER_EXEC_STMT_WITH_OPEN_CURSOR = 1420 + ER_STMT_HAS_NO_OPEN_CURSOR = 1421 + ER_COMMIT_NOT_ALLOWED_IN_SF_OR_TRG = 1422 + ER_NO_DEFAULT_FOR_VIEW_FIELD = 1423 + ER_SP_NO_RECURSION = 1424 + ER_TOO_BIG_SCALE = 1425 + ER_TOO_BIG_PRECISION = 1426 + ER_M_BIGGER_THAN_D = 1427 + ER_WRONG_LOCK_OF_SYSTEM_TABLE = 1428 + ER_CONNECT_TO_FOREIGN_DATA_SOURCE = 1429 + ER_QUERY_ON_FOREIGN_DATA_SOURCE = 1430 + ER_FOREIGN_DATA_SOURCE_DOESNT_EXIST = 1431 + ER_FOREIGN_DATA_STRING_INVALID_CANT_CREATE = 1432 + ER_FOREIGN_DATA_STRING_INVALID = 1433 + ER_CANT_CREATE_FEDERATED_TABLE = 1434 + ER_TRG_IN_WRONG_SCHEMA = 1435 + ER_STACK_OVERRUN_NEED_MORE = 1436 + ER_TOO_LONG_BODY = 1437 + ER_WARN_CANT_DROP_DEFAULT_KEYCACHE = 1438 + ER_TOO_BIG_DISPLAYWIDTH = 1439 + ER_XAER_DUPID = 1440 + ER_DATETIME_FUNCTION_OVERFLOW = 1441 + ER_CANT_UPDATE_USED_TABLE_IN_SF_OR_TRG = 1442 + ER_VIEW_PREVENT_UPDATE = 1443 + ER_PS_NO_RECURSION = 1444 + ER_SP_CANT_SET_AUTOCOMMIT = 1445 + ER_MALFORMED_DEFINER = 1446 + ER_VIEW_FRM_NO_USER = 1447 + ER_VIEW_OTHER_USER = 1448 + ER_NO_SUCH_USER = 1449 + ER_FORBID_SCHEMA_CHANGE = 1450 + ER_ROW_IS_REFERENCED_2 = 1451 + ER_NO_REFERENCED_ROW_2 = 1452 + ER_SP_BAD_VAR_SHADOW = 1453 + ER_TRG_NO_DEFINER = 1454 + ER_OLD_FILE_FORMAT = 1455 + ER_SP_RECURSION_LIMIT = 1456 + ER_SP_PROC_TABLE_CORRUPT = 1457 + ER_SP_WRONG_NAME = 1458 + ER_TABLE_NEEDS_UPGRADE = 1459 + ER_SP_NO_AGGREGATE = 1460 + ER_MAX_PREPARED_STMT_COUNT_REACHED = 1461 + ER_VIEW_RECURSIVE = 1462 + ER_NON_GROUPING_FIELD_USED = 1463 + ER_TABLE_CANT_HANDLE_SPKEYS = 1464 + ER_NO_TRIGGERS_ON_SYSTEM_SCHEMA = 1465 + ER_REMOVED_SPACES = 1466 + ER_AUTOINC_READ_FAILED = 1467 + ER_USERNAME = 1468 + ER_HOSTNAME = 1469 + ER_WRONG_STRING_LENGTH = 1470 + ER_NON_INSERTABLE_TABLE = 1471 + ER_ADMIN_WRONG_MRG_TABLE = 1472 + ER_TOO_HIGH_LEVEL_OF_NESTING_FOR_SELECT = 1473 + ER_NAME_BECOMES_EMPTY = 1474 + ER_AMBIGUOUS_FIELD_TERM = 1475 + ER_FOREIGN_SERVER_EXISTS = 1476 + ER_FOREIGN_SERVER_DOESNT_EXIST = 1477 + ER_ILLEGAL_HA_CREATE_OPTION = 1478 + ER_PARTITION_REQUIRES_VALUES_ERROR = 1479 + ER_PARTITION_WRONG_VALUES_ERROR = 1480 + ER_PARTITION_MAXVALUE_ERROR = 1481 + ER_PARTITION_SUBPARTITION_ERROR = 1482 + ER_PARTITION_SUBPART_MIX_ERROR = 1483 + ER_PARTITION_WRONG_NO_PART_ERROR = 1484 + ER_PARTITION_WRONG_NO_SUBPART_ERROR = 1485 + ER_WRONG_EXPR_IN_PARTITION_FUNC_ERROR = 1486 + ER_NO_CONST_EXPR_IN_RANGE_OR_LIST_ERROR = 1487 + ER_FIELD_NOT_FOUND_PART_ERROR = 1488 + ER_LIST_OF_FIELDS_ONLY_IN_HASH_ERROR = 1489 + ER_INCONSISTENT_PARTITION_INFO_ERROR = 1490 + ER_PARTITION_FUNC_NOT_ALLOWED_ERROR = 1491 + ER_PARTITIONS_MUST_BE_DEFINED_ERROR = 1492 + ER_RANGE_NOT_INCREASING_ERROR = 1493 + ER_INCONSISTENT_TYPE_OF_FUNCTIONS_ERROR = 1494 + ER_MULTIPLE_DEF_CONST_IN_LIST_PART_ERROR = 1495 + ER_PARTITION_ENTRY_ERROR = 1496 + ER_MIX_HANDLER_ERROR = 1497 + ER_PARTITION_NOT_DEFINED_ERROR = 1498 + ER_TOO_MANY_PARTITIONS_ERROR = 1499 + ER_SUBPARTITION_ERROR = 1500 + ER_CANT_CREATE_HANDLER_FILE = 1501 + ER_BLOB_FIELD_IN_PART_FUNC_ERROR = 1502 + ER_UNIQUE_KEY_NEED_ALL_FIELDS_IN_PF = 1503 + ER_NO_PARTS_ERROR = 1504 + ER_PARTITION_MGMT_ON_NONPARTITIONED = 1505 + ER_FOREIGN_KEY_ON_PARTITIONED = 1506 + ER_DROP_PARTITION_NON_EXISTENT = 1507 + ER_DROP_LAST_PARTITION = 1508 + ER_COALESCE_ONLY_ON_HASH_PARTITION = 1509 + ER_REORG_HASH_ONLY_ON_SAME_NO = 1510 + ER_REORG_NO_PARAM_ERROR = 1511 + ER_ONLY_ON_RANGE_LIST_PARTITION = 1512 + ER_ADD_PARTITION_SUBPART_ERROR = 1513 + ER_ADD_PARTITION_NO_NEW_PARTITION = 1514 + ER_COALESCE_PARTITION_NO_PARTITION = 1515 + ER_REORG_PARTITION_NOT_EXIST = 1516 + ER_SAME_NAME_PARTITION = 1517 + ER_NO_BINLOG_ERROR = 1518 + ER_CONSECUTIVE_REORG_PARTITIONS = 1519 + ER_REORG_OUTSIDE_RANGE = 1520 + ER_PARTITION_FUNCTION_FAILURE = 1521 + ER_PART_STATE_ERROR = 1522 + ER_LIMITED_PART_RANGE = 1523 + ER_PLUGIN_IS_NOT_LOADED = 1524 + ER_WRONG_VALUE = 1525 + ER_NO_PARTITION_FOR_GIVEN_VALUE = 1526 + ER_FILEGROUP_OPTION_ONLY_ONCE = 1527 + ER_CREATE_FILEGROUP_FAILED = 1528 + ER_DROP_FILEGROUP_FAILED = 1529 + ER_TABLESPACE_AUTO_EXTEND_ERROR = 1530 + ER_WRONG_SIZE_NUMBER = 1531 + ER_SIZE_OVERFLOW_ERROR = 1532 + ER_ALTER_FILEGROUP_FAILED = 1533 + ER_BINLOG_ROW_LOGGING_FAILED = 1534 + ER_BINLOG_ROW_WRONG_TABLE_DEF = 1535 + ER_BINLOG_ROW_RBR_TO_SBR = 1536 + ER_EVENT_ALREADY_EXISTS = 1537 + ER_EVENT_STORE_FAILED = 1538 + ER_EVENT_DOES_NOT_EXIST = 1539 + ER_EVENT_CANT_ALTER = 1540 + ER_EVENT_DROP_FAILED = 1541 + ER_EVENT_INTERVAL_NOT_POSITIVE_OR_TOO_BIG = 1542 + ER_EVENT_ENDS_BEFORE_STARTS = 1543 + ER_EVENT_EXEC_TIME_IN_THE_PAST = 1544 + ER_EVENT_OPEN_TABLE_FAILED = 1545 + ER_EVENT_NEITHER_M_EXPR_NOR_M_AT = 1546 + ER_OBSOLETE_COL_COUNT_DOESNT_MATCH_CORRUPTED = 1547 + ER_OBSOLETE_CANNOT_LOAD_FROM_TABLE = 1548 + ER_EVENT_CANNOT_DELETE = 1549 + ER_EVENT_COMPILE_ERROR = 1550 + ER_EVENT_SAME_NAME = 1551 + ER_EVENT_DATA_TOO_LONG = 1552 + ER_DROP_INDEX_FK = 1553 + ER_WARN_DEPRECATED_SYNTAX_WITH_VER = 1554 + ER_CANT_WRITE_LOCK_LOG_TABLE = 1555 + ER_CANT_LOCK_LOG_TABLE = 1556 + ER_FOREIGN_DUPLICATE_KEY_OLD_UNUSED = 1557 + ER_COL_COUNT_DOESNT_MATCH_PLEASE_UPDATE = 1558 + ER_TEMP_TABLE_PREVENTS_SWITCH_OUT_OF_RBR = 1559 + ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_FORMAT = 1560 + ER_NDB_CANT_SWITCH_BINLOG_FORMAT = 1561 + ER_PARTITION_NO_TEMPORARY = 1562 + ER_PARTITION_CONST_DOMAIN_ERROR = 1563 + ER_PARTITION_FUNCTION_IS_NOT_ALLOWED = 1564 + ER_DDL_LOG_ERROR = 1565 + ER_NULL_IN_VALUES_LESS_THAN = 1566 + ER_WRONG_PARTITION_NAME = 1567 + ER_CANT_CHANGE_TX_CHARACTERISTICS = 1568 + ER_DUP_ENTRY_AUTOINCREMENT_CASE = 1569 + ER_EVENT_MODIFY_QUEUE_ERROR = 1570 + ER_EVENT_SET_VAR_ERROR = 1571 + ER_PARTITION_MERGE_ERROR = 1572 + ER_CANT_ACTIVATE_LOG = 1573 + ER_RBR_NOT_AVAILABLE = 1574 + ER_BASE64_DECODE_ERROR = 1575 + ER_EVENT_RECURSION_FORBIDDEN = 1576 + ER_EVENTS_DB_ERROR = 1577 + ER_ONLY_INTEGERS_ALLOWED = 1578 + ER_UNSUPORTED_LOG_ENGINE = 1579 + ER_BAD_LOG_STATEMENT = 1580 + ER_CANT_RENAME_LOG_TABLE = 1581 + ER_WRONG_PARAMCOUNT_TO_NATIVE_FCT = 1582 + ER_WRONG_PARAMETERS_TO_NATIVE_FCT = 1583 + ER_WRONG_PARAMETERS_TO_STORED_FCT = 1584 + ER_NATIVE_FCT_NAME_COLLISION = 1585 + ER_DUP_ENTRY_WITH_KEY_NAME = 1586 + ER_BINLOG_PURGE_EMFILE = 1587 + ER_EVENT_CANNOT_CREATE_IN_THE_PAST = 1588 + ER_EVENT_CANNOT_ALTER_IN_THE_PAST = 1589 + ER_SLAVE_INCIDENT = 1590 + ER_NO_PARTITION_FOR_GIVEN_VALUE_SILENT = 1591 + ER_BINLOG_UNSAFE_STATEMENT = 1592 + ER_SLAVE_FATAL_ERROR = 1593 + ER_SLAVE_RELAY_LOG_READ_FAILURE = 1594 + ER_SLAVE_RELAY_LOG_WRITE_FAILURE = 1595 + ER_SLAVE_CREATE_EVENT_FAILURE = 1596 + ER_SLAVE_MASTER_COM_FAILURE = 1597 + ER_BINLOG_LOGGING_IMPOSSIBLE = 1598 + ER_VIEW_NO_CREATION_CTX = 1599 + ER_VIEW_INVALID_CREATION_CTX = 1600 + ER_SR_INVALID_CREATION_CTX = 1601 + ER_TRG_CORRUPTED_FILE = 1602 + ER_TRG_NO_CREATION_CTX = 1603 + ER_TRG_INVALID_CREATION_CTX = 1604 + ER_EVENT_INVALID_CREATION_CTX = 1605 + ER_TRG_CANT_OPEN_TABLE = 1606 + ER_CANT_CREATE_SROUTINE = 1607 + ER_NEVER_USED = 1608 + ER_NO_FORMAT_DESCRIPTION_EVENT_BEFORE_BINLOG_STATEMENT = 1609 + ER_SLAVE_CORRUPT_EVENT = 1610 + ER_LOAD_DATA_INVALID_COLUMN_UNUSED = 1611 + ER_LOG_PURGE_NO_FILE = 1612 + ER_XA_RBTIMEOUT = 1613 + ER_XA_RBDEADLOCK = 1614 + ER_NEED_REPREPARE = 1615 + ER_DELAYED_NOT_SUPPORTED = 1616 + WARN_NO_MASTER_INFO = 1617 + WARN_OPTION_IGNORED = 1618 + ER_PLUGIN_DELETE_BUILTIN = 1619 + WARN_PLUGIN_BUSY = 1620 + ER_VARIABLE_IS_READONLY = 1621 + ER_WARN_ENGINE_TRANSACTION_ROLLBACK = 1622 + ER_SLAVE_HEARTBEAT_FAILURE = 1623 + ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE = 1624 + ER_NDB_REPLICATION_SCHEMA_ERROR = 1625 + ER_CONFLICT_FN_PARSE_ERROR = 1626 + ER_EXCEPTIONS_WRITE_ERROR = 1627 + ER_TOO_LONG_TABLE_COMMENT = 1628 + ER_TOO_LONG_FIELD_COMMENT = 1629 + ER_FUNC_INEXISTENT_NAME_COLLISION = 1630 + ER_DATABASE_NAME = 1631 + ER_TABLE_NAME = 1632 + ER_PARTITION_NAME = 1633 + ER_SUBPARTITION_NAME = 1634 + ER_TEMPORARY_NAME = 1635 + ER_RENAMED_NAME = 1636 + ER_TOO_MANY_CONCURRENT_TRXS = 1637 + WARN_NON_ASCII_SEPARATOR_NOT_IMPLEMENTED = 1638 + ER_DEBUG_SYNC_TIMEOUT = 1639 + ER_DEBUG_SYNC_HIT_LIMIT = 1640 + ER_DUP_SIGNAL_SET = 1641 + ER_SIGNAL_WARN = 1642 + ER_SIGNAL_NOT_FOUND = 1643 + ER_SIGNAL_EXCEPTION = 1644 + ER_RESIGNAL_WITHOUT_ACTIVE_HANDLER = 1645 + ER_SIGNAL_BAD_CONDITION_TYPE = 1646 + WARN_COND_ITEM_TRUNCATED = 1647 + ER_COND_ITEM_TOO_LONG = 1648 + ER_UNKNOWN_LOCALE = 1649 + ER_SLAVE_IGNORE_SERVER_IDS = 1650 + ER_QUERY_CACHE_DISABLED = 1651 + ER_SAME_NAME_PARTITION_FIELD = 1652 + ER_PARTITION_COLUMN_LIST_ERROR = 1653 + ER_WRONG_TYPE_COLUMN_VALUE_ERROR = 1654 + ER_TOO_MANY_PARTITION_FUNC_FIELDS_ERROR = 1655 + ER_MAXVALUE_IN_VALUES_IN = 1656 + ER_TOO_MANY_VALUES_ERROR = 1657 + ER_ROW_SINGLE_PARTITION_FIELD_ERROR = 1658 + ER_FIELD_TYPE_NOT_ALLOWED_AS_PARTITION_FIELD = 1659 + ER_PARTITION_FIELDS_TOO_LONG = 1660 + ER_BINLOG_ROW_ENGINE_AND_STMT_ENGINE = 1661 + ER_BINLOG_ROW_MODE_AND_STMT_ENGINE = 1662 + ER_BINLOG_UNSAFE_AND_STMT_ENGINE = 1663 + ER_BINLOG_ROW_INJECTION_AND_STMT_ENGINE = 1664 + ER_BINLOG_STMT_MODE_AND_ROW_ENGINE = 1665 + ER_BINLOG_ROW_INJECTION_AND_STMT_MODE = 1666 + ER_BINLOG_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE = 1667 + ER_BINLOG_UNSAFE_LIMIT = 1668 + ER_UNUSED4 = 1669 + ER_BINLOG_UNSAFE_SYSTEM_TABLE = 1670 + ER_BINLOG_UNSAFE_AUTOINC_COLUMNS = 1671 + ER_BINLOG_UNSAFE_UDF = 1672 + ER_BINLOG_UNSAFE_SYSTEM_VARIABLE = 1673 + ER_BINLOG_UNSAFE_SYSTEM_FUNCTION = 1674 + ER_BINLOG_UNSAFE_NONTRANS_AFTER_TRANS = 1675 + ER_MESSAGE_AND_STATEMENT = 1676 + ER_SLAVE_CONVERSION_FAILED = 1677 + ER_SLAVE_CANT_CREATE_CONVERSION = 1678 + ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_FORMAT = 1679 + ER_PATH_LENGTH = 1680 + ER_WARN_DEPRECATED_SYNTAX_NO_REPLACEMENT = 1681 + ER_WRONG_NATIVE_TABLE_STRUCTURE = 1682 + ER_WRONG_PERFSCHEMA_USAGE = 1683 + ER_WARN_I_S_SKIPPED_TABLE = 1684 + ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_DIRECT = 1685 + ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_DIRECT = 1686 + ER_SPATIAL_MUST_HAVE_GEOM_COL = 1687 + ER_TOO_LONG_INDEX_COMMENT = 1688 + ER_LOCK_ABORTED = 1689 + ER_DATA_OUT_OF_RANGE = 1690 + ER_WRONG_SPVAR_TYPE_IN_LIMIT = 1691 + ER_BINLOG_UNSAFE_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE = 1692 + ER_BINLOG_UNSAFE_MIXED_STATEMENT = 1693 + ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_SQL_LOG_BIN = 1694 + ER_STORED_FUNCTION_PREVENTS_SWITCH_SQL_LOG_BIN = 1695 + ER_FAILED_READ_FROM_PAR_FILE = 1696 + ER_VALUES_IS_NOT_INT_TYPE_ERROR = 1697 + ER_ACCESS_DENIED_NO_PASSWORD_ERROR = 1698 + ER_SET_PASSWORD_AUTH_PLUGIN = 1699 + ER_GRANT_PLUGIN_USER_EXISTS = 1700 + ER_TRUNCATE_ILLEGAL_FK = 1701 + ER_PLUGIN_IS_PERMANENT = 1702 + ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MIN = 1703 + ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MAX = 1704 + ER_STMT_CACHE_FULL = 1705 + ER_MULTI_UPDATE_KEY_CONFLICT = 1706 + ER_TABLE_NEEDS_REBUILD = 1707 + WARN_OPTION_BELOW_LIMIT = 1708 + ER_INDEX_COLUMN_TOO_LONG = 1709 + ER_ERROR_IN_TRIGGER_BODY = 1710 + ER_ERROR_IN_UNKNOWN_TRIGGER_BODY = 1711 + ER_INDEX_CORRUPT = 1712 + ER_UNDO_RECORD_TOO_BIG = 1713 + ER_BINLOG_UNSAFE_INSERT_IGNORE_SELECT = 1714 + ER_BINLOG_UNSAFE_INSERT_SELECT_UPDATE = 1715 + ER_BINLOG_UNSAFE_REPLACE_SELECT = 1716 + ER_BINLOG_UNSAFE_CREATE_IGNORE_SELECT = 1717 + ER_BINLOG_UNSAFE_CREATE_REPLACE_SELECT = 1718 + ER_BINLOG_UNSAFE_UPDATE_IGNORE = 1719 + ER_PLUGIN_NO_UNINSTALL = 1720 + ER_PLUGIN_NO_INSTALL = 1721 + ER_BINLOG_UNSAFE_WRITE_AUTOINC_SELECT = 1722 + ER_BINLOG_UNSAFE_CREATE_SELECT_AUTOINC = 1723 + ER_BINLOG_UNSAFE_INSERT_TWO_KEYS = 1724 + ER_TABLE_IN_FK_CHECK = 1725 + ER_UNSUPPORTED_ENGINE = 1726 + ER_BINLOG_UNSAFE_AUTOINC_NOT_FIRST = 1727 + ER_CANNOT_LOAD_FROM_TABLE_V2 = 1728 + ER_MASTER_DELAY_VALUE_OUT_OF_RANGE = 1729 + ER_ONLY_FD_AND_RBR_EVENTS_ALLOWED_IN_BINLOG_STATEMENT = 1730 + ER_PARTITION_EXCHANGE_DIFFERENT_OPTION = 1731 + ER_PARTITION_EXCHANGE_PART_TABLE = 1732 + ER_PARTITION_EXCHANGE_TEMP_TABLE = 1733 + ER_PARTITION_INSTEAD_OF_SUBPARTITION = 1734 + ER_UNKNOWN_PARTITION = 1735 + ER_TABLES_DIFFERENT_METADATA = 1736 + ER_ROW_DOES_NOT_MATCH_PARTITION = 1737 + ER_BINLOG_CACHE_SIZE_GREATER_THAN_MAX = 1738 + ER_WARN_INDEX_NOT_APPLICABLE = 1739 + ER_PARTITION_EXCHANGE_FOREIGN_KEY = 1740 + ER_NO_SUCH_KEY_VALUE = 1741 + ER_RPL_INFO_DATA_TOO_LONG = 1742 + ER_NETWORK_READ_EVENT_CHECKSUM_FAILURE = 1743 + ER_BINLOG_READ_EVENT_CHECKSUM_FAILURE = 1744 + ER_BINLOG_STMT_CACHE_SIZE_GREATER_THAN_MAX = 1745 + ER_CANT_UPDATE_TABLE_IN_CREATE_TABLE_SELECT = 1746 + ER_PARTITION_CLAUSE_ON_NONPARTITIONED = 1747 + ER_ROW_DOES_NOT_MATCH_GIVEN_PARTITION_SET = 1748 + ER_NO_SUCH_PARTITION__UNUSED = 1749 + ER_CHANGE_RPL_INFO_REPOSITORY_FAILURE = 1750 + ER_WARNING_NOT_COMPLETE_ROLLBACK_WITH_CREATED_TEMP_TABLE = 1751 + ER_WARNING_NOT_COMPLETE_ROLLBACK_WITH_DROPPED_TEMP_TABLE = 1752 + ER_MTS_FEATURE_IS_NOT_SUPPORTED = 1753 + ER_MTS_UPDATED_DBS_GREATER_MAX = 1754 + ER_MTS_CANT_PARALLEL = 1755 + ER_MTS_INCONSISTENT_DATA = 1756 + ER_FULLTEXT_NOT_SUPPORTED_WITH_PARTITIONING = 1757 + ER_DA_INVALID_CONDITION_NUMBER = 1758 + ER_INSECURE_PLAIN_TEXT = 1759 + ER_INSECURE_CHANGE_MASTER = 1760 + ER_FOREIGN_DUPLICATE_KEY_WITH_CHILD_INFO = 1761 + ER_FOREIGN_DUPLICATE_KEY_WITHOUT_CHILD_INFO = 1762 + ER_SQLTHREAD_WITH_SECURE_SLAVE = 1763 + ER_TABLE_HAS_NO_FT = 1764 + ER_VARIABLE_NOT_SETTABLE_IN_SF_OR_TRIGGER = 1765 + ER_VARIABLE_NOT_SETTABLE_IN_TRANSACTION = 1766 + ER_GTID_NEXT_IS_NOT_IN_GTID_NEXT_LIST = 1767 + ER_CANT_CHANGE_GTID_NEXT_IN_TRANSACTION = 1768 + ER_SET_STATEMENT_CANNOT_INVOKE_FUNCTION = 1769 + ER_GTID_NEXT_CANT_BE_AUTOMATIC_IF_GTID_NEXT_LIST_IS_NON_NULL = 1770 + ER_SKIPPING_LOGGED_TRANSACTION = 1771 + ER_MALFORMED_GTID_SET_SPECIFICATION = 1772 + ER_MALFORMED_GTID_SET_ENCODING = 1773 + ER_MALFORMED_GTID_SPECIFICATION = 1774 + ER_GNO_EXHAUSTED = 1775 + ER_BAD_SLAVE_AUTO_POSITION = 1776 + ER_AUTO_POSITION_REQUIRES_GTID_MODE_NOT_OFF = 1777 + ER_CANT_DO_IMPLICIT_COMMIT_IN_TRX_WHEN_GTID_NEXT_IS_SET = 1778 + ER_GTID_MODE_ON_REQUIRES_ENFORCE_GTID_CONSISTENCY_ON = 1779 + ER_GTID_MODE_REQUIRES_BINLOG = 1780 + ER_CANT_SET_GTID_NEXT_TO_GTID_WHEN_GTID_MODE_IS_OFF = 1781 + ER_CANT_SET_GTID_NEXT_TO_ANONYMOUS_WHEN_GTID_MODE_IS_ON = 1782 + ER_CANT_SET_GTID_NEXT_LIST_TO_NON_NULL_WHEN_GTID_MODE_IS_OFF = 1783 + ER_FOUND_GTID_EVENT_WHEN_GTID_MODE_IS_OFF__UNUSED = 1784 + ER_GTID_UNSAFE_NON_TRANSACTIONAL_TABLE = 1785 + ER_GTID_UNSAFE_CREATE_SELECT = 1786 + ER_GTID_UNSAFE_CREATE_DROP_TEMPORARY_TABLE_IN_TRANSACTION = 1787 + ER_GTID_MODE_CAN_ONLY_CHANGE_ONE_STEP_AT_A_TIME = 1788 + ER_MASTER_HAS_PURGED_REQUIRED_GTIDS = 1789 + ER_CANT_SET_GTID_NEXT_WHEN_OWNING_GTID = 1790 + ER_UNKNOWN_EXPLAIN_FORMAT = 1791 + ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION = 1792 + ER_TOO_LONG_TABLE_PARTITION_COMMENT = 1793 + ER_SLAVE_CONFIGURATION = 1794 + ER_INNODB_FT_LIMIT = 1795 + ER_INNODB_NO_FT_TEMP_TABLE = 1796 + ER_INNODB_FT_WRONG_DOCID_COLUMN = 1797 + ER_INNODB_FT_WRONG_DOCID_INDEX = 1798 + ER_INNODB_ONLINE_LOG_TOO_BIG = 1799 + ER_UNKNOWN_ALTER_ALGORITHM = 1800 + ER_UNKNOWN_ALTER_LOCK = 1801 + ER_MTS_CHANGE_MASTER_CANT_RUN_WITH_GAPS = 1802 + ER_MTS_RECOVERY_FAILURE = 1803 + ER_MTS_RESET_WORKERS = 1804 + ER_COL_COUNT_DOESNT_MATCH_CORRUPTED_V2 = 1805 + ER_SLAVE_SILENT_RETRY_TRANSACTION = 1806 + ER_DISCARD_FK_CHECKS_RUNNING = 1807 + ER_TABLE_SCHEMA_MISMATCH = 1808 + ER_TABLE_IN_SYSTEM_TABLESPACE = 1809 + ER_IO_READ_ERROR = 1810 + ER_IO_WRITE_ERROR = 1811 + ER_TABLESPACE_MISSING = 1812 + ER_TABLESPACE_EXISTS = 1813 + ER_TABLESPACE_DISCARDED = 1814 + ER_INTERNAL_ERROR = 1815 + ER_INNODB_IMPORT_ERROR = 1816 + ER_INNODB_INDEX_CORRUPT = 1817 + ER_INVALID_YEAR_COLUMN_LENGTH = 1818 + ER_NOT_VALID_PASSWORD = 1819 + ER_MUST_CHANGE_PASSWORD = 1820 + ER_FK_NO_INDEX_CHILD = 1821 + ER_FK_NO_INDEX_PARENT = 1822 + ER_FK_FAIL_ADD_SYSTEM = 1823 + ER_FK_CANNOT_OPEN_PARENT = 1824 + ER_FK_INCORRECT_OPTION = 1825 + ER_FK_DUP_NAME = 1826 + ER_PASSWORD_FORMAT = 1827 + ER_FK_COLUMN_CANNOT_DROP = 1828 + ER_FK_COLUMN_CANNOT_DROP_CHILD = 1829 + ER_FK_COLUMN_NOT_NULL = 1830 + ER_DUP_INDEX = 1831 + ER_FK_COLUMN_CANNOT_CHANGE = 1832 + ER_FK_COLUMN_CANNOT_CHANGE_CHILD = 1833 + ER_UNUSED5 = 1834 + ER_MALFORMED_PACKET = 1835 + ER_READ_ONLY_MODE = 1836 + ER_GTID_NEXT_TYPE_UNDEFINED_GROUP = 1837 + ER_VARIABLE_NOT_SETTABLE_IN_SP = 1838 + ER_CANT_SET_GTID_PURGED_WHEN_GTID_MODE_IS_OFF = 1839 + ER_CANT_SET_GTID_PURGED_WHEN_GTID_EXECUTED_IS_NOT_EMPTY = 1840 + ER_CANT_SET_GTID_PURGED_WHEN_OWNED_GTIDS_IS_NOT_EMPTY = 1841 + ER_GTID_PURGED_WAS_CHANGED = 1842 + ER_GTID_EXECUTED_WAS_CHANGED = 1843 + ER_BINLOG_STMT_MODE_AND_NO_REPL_TABLES = 1844 + ER_ALTER_OPERATION_NOT_SUPPORTED = 1845 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON = 1846 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_COPY = 1847 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_PARTITION = 1848 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_FK_RENAME = 1849 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_COLUMN_TYPE = 1850 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_FK_CHECK = 1851 + ER_UNUSED6 = 1852 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_NOPK = 1853 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_AUTOINC = 1854 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_HIDDEN_FTS = 1855 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_CHANGE_FTS = 1856 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_FTS = 1857 + ER_SQL_SLAVE_SKIP_COUNTER_NOT_SETTABLE_IN_GTID_MODE = 1858 + ER_DUP_UNKNOWN_IN_INDEX = 1859 + ER_IDENT_CAUSES_TOO_LONG_PATH = 1860 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_NOT_NULL = 1861 + ER_MUST_CHANGE_PASSWORD_LOGIN = 1862 + ER_ROW_IN_WRONG_PARTITION = 1863 + ER_MTS_EVENT_BIGGER_PENDING_JOBS_SIZE_MAX = 1864 + ER_INNODB_NO_FT_USES_PARSER = 1865 + ER_BINLOG_LOGICAL_CORRUPTION = 1866 + ER_WARN_PURGE_LOG_IN_USE = 1867 + ER_WARN_PURGE_LOG_IS_ACTIVE = 1868 + ER_AUTO_INCREMENT_CONFLICT = 1869 + WARN_ON_BLOCKHOLE_IN_RBR = 1870 + ER_SLAVE_MI_INIT_REPOSITORY = 1871 + ER_SLAVE_RLI_INIT_REPOSITORY = 1872 + ER_ACCESS_DENIED_CHANGE_USER_ERROR = 1873 + ER_INNODB_READ_ONLY = 1874 + ER_STOP_SLAVE_SQL_THREAD_TIMEOUT = 1875 + ER_STOP_SLAVE_IO_THREAD_TIMEOUT = 1876 + ER_TABLE_CORRUPT = 1877 + ER_TEMP_FILE_WRITE_FAILURE = 1878 + ER_INNODB_FT_AUX_NOT_HEX_ID = 1879 + ER_OLD_TEMPORALS_UPGRADED = 1880 + ER_INNODB_FORCED_RECOVERY = 1881 + ER_AES_INVALID_IV = 1882 + ER_PLUGIN_CANNOT_BE_UNINSTALLED = 1883 + ER_GTID_UNSAFE_BINLOG_SPLITTABLE_STATEMENT_AND_GTID_GROUP = 1884 + ER_SLAVE_HAS_MORE_GTIDS_THAN_MASTER = 1885 + ER_FILE_CORRUPT = 3000 + ER_ERROR_ON_MASTER = 3001 + ER_INCONSISTENT_ERROR = 3002 + ER_STORAGE_ENGINE_NOT_LOADED = 3003 + ER_GET_STACKED_DA_WITHOUT_ACTIVE_HANDLER = 3004 + ER_WARN_LEGACY_SYNTAX_CONVERTED = 3005 + ER_BINLOG_UNSAFE_FULLTEXT_PLUGIN = 3006 + ER_CANNOT_DISCARD_TEMPORARY_TABLE = 3007 + ER_FK_DEPTH_EXCEEDED = 3008 + ER_COL_COUNT_DOESNT_MATCH_PLEASE_UPDATE_V2 = 3009 + ER_WARN_TRIGGER_DOESNT_HAVE_CREATED = 3010 + ER_REFERENCED_TRG_DOES_NOT_EXIST = 3011 + ER_EXPLAIN_NOT_SUPPORTED = 3012 + ER_INVALID_FIELD_SIZE = 3013 + ER_MISSING_HA_CREATE_OPTION = 3014 + ER_ENGINE_OUT_OF_MEMORY = 3015 + ER_PASSWORD_EXPIRE_ANONYMOUS_USER = 3016 + ER_SLAVE_SQL_THREAD_MUST_STOP = 3017 + ER_NO_FT_MATERIALIZED_SUBQUERY = 3018 + ER_INNODB_UNDO_LOG_FULL = 3019 + ER_INVALID_ARGUMENT_FOR_LOGARITHM = 3020 + ER_SLAVE_CHANNEL_IO_THREAD_MUST_STOP = 3021 + ER_WARN_OPEN_TEMP_TABLES_MUST_BE_ZERO = 3022 + ER_WARN_ONLY_MASTER_LOG_FILE_NO_POS = 3023 + ER_QUERY_TIMEOUT = 3024 + ER_NON_RO_SELECT_DISABLE_TIMER = 3025 + ER_DUP_LIST_ENTRY = 3026 + ER_SQL_MODE_NO_EFFECT = 3027 + ER_AGGREGATE_ORDER_FOR_UNION = 3028 + ER_AGGREGATE_ORDER_NON_AGG_QUERY = 3029 + ER_SLAVE_WORKER_STOPPED_PREVIOUS_THD_ERROR = 3030 + ER_DONT_SUPPORT_SLAVE_PRESERVE_COMMIT_ORDER = 3031 + ER_SERVER_OFFLINE_MODE = 3032 + ER_GIS_DIFFERENT_SRIDS = 3033 + ER_GIS_UNSUPPORTED_ARGUMENT = 3034 + ER_GIS_UNKNOWN_ERROR = 3035 + ER_GIS_UNKNOWN_EXCEPTION = 3036 + ER_GIS_INVALID_DATA = 3037 + ER_BOOST_GEOMETRY_EMPTY_INPUT_EXCEPTION = 3038 + ER_BOOST_GEOMETRY_CENTROID_EXCEPTION = 3039 + ER_BOOST_GEOMETRY_OVERLAY_INVALID_INPUT_EXCEPTION = 3040 + ER_BOOST_GEOMETRY_TURN_INFO_EXCEPTION = 3041 + ER_BOOST_GEOMETRY_SELF_INTERSECTION_POINT_EXCEPTION = 3042 + ER_BOOST_GEOMETRY_UNKNOWN_EXCEPTION = 3043 + ER_STD_BAD_ALLOC_ERROR = 3044 + ER_STD_DOMAIN_ERROR = 3045 + ER_STD_LENGTH_ERROR = 3046 + ER_STD_INVALID_ARGUMENT = 3047 + ER_STD_OUT_OF_RANGE_ERROR = 3048 + ER_STD_OVERFLOW_ERROR = 3049 + ER_STD_RANGE_ERROR = 3050 + ER_STD_UNDERFLOW_ERROR = 3051 + ER_STD_LOGIC_ERROR = 3052 + ER_STD_RUNTIME_ERROR = 3053 + ER_STD_UNKNOWN_EXCEPTION = 3054 + ER_GIS_DATA_WRONG_ENDIANESS = 3055 + ER_CHANGE_MASTER_PASSWORD_LENGTH = 3056 + ER_USER_LOCK_WRONG_NAME = 3057 + ER_USER_LOCK_DEADLOCK = 3058 + ER_REPLACE_INACCESSIBLE_ROWS = 3059 + ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_GIS = 3060 + ER_ILLEGAL_USER_VAR = 3061 + ER_GTID_MODE_OFF = 3062 + ER_UNSUPPORTED_BY_REPLICATION_THREAD = 3063 + ER_INCORRECT_TYPE = 3064 + ER_FIELD_IN_ORDER_NOT_SELECT = 3065 + ER_AGGREGATE_IN_ORDER_NOT_SELECT = 3066 + ER_INVALID_RPL_WILD_TABLE_FILTER_PATTERN = 3067 + ER_NET_OK_PACKET_TOO_LARGE = 3068 + ER_INVALID_JSON_DATA = 3069 + ER_INVALID_GEOJSON_MISSING_MEMBER = 3070 + ER_INVALID_GEOJSON_WRONG_TYPE = 3071 + ER_INVALID_GEOJSON_UNSPECIFIED = 3072 + ER_DIMENSION_UNSUPPORTED = 3073 + ER_SLAVE_CHANNEL_DOES_NOT_EXIST = 3074 + ER_SLAVE_MULTIPLE_CHANNELS_HOST_PORT = 3075 + ER_SLAVE_CHANNEL_NAME_INVALID_OR_TOO_LONG = 3076 + ER_SLAVE_NEW_CHANNEL_WRONG_REPOSITORY = 3077 + ER_SLAVE_CHANNEL_DELETE = 3078 + ER_SLAVE_MULTIPLE_CHANNELS_CMD = 3079 + ER_SLAVE_MAX_CHANNELS_EXCEEDED = 3080 + ER_SLAVE_CHANNEL_MUST_STOP = 3081 + ER_SLAVE_CHANNEL_NOT_RUNNING = 3082 + ER_SLAVE_CHANNEL_WAS_RUNNING = 3083 + ER_SLAVE_CHANNEL_WAS_NOT_RUNNING = 3084 + ER_SLAVE_CHANNEL_SQL_THREAD_MUST_STOP = 3085 + ER_SLAVE_CHANNEL_SQL_SKIP_COUNTER = 3086 + ER_WRONG_FIELD_WITH_GROUP_V2 = 3087 + ER_MIX_OF_GROUP_FUNC_AND_FIELDS_V2 = 3088 + ER_WARN_DEPRECATED_SYSVAR_UPDATE = 3089 + ER_WARN_DEPRECATED_SQLMODE = 3090 + ER_CANNOT_LOG_PARTIAL_DROP_DATABASE_WITH_GTID = 3091 + ER_GROUP_REPLICATION_CONFIGURATION = 3092 + ER_GROUP_REPLICATION_RUNNING = 3093 + ER_GROUP_REPLICATION_APPLIER_INIT_ERROR = 3094 + ER_GROUP_REPLICATION_STOP_APPLIER_THREAD_TIMEOUT = 3095 + ER_GROUP_REPLICATION_COMMUNICATION_LAYER_SESSION_ERROR = 3096 + ER_GROUP_REPLICATION_COMMUNICATION_LAYER_JOIN_ERROR = 3097 + ER_BEFORE_DML_VALIDATION_ERROR = 3098 + ER_PREVENTS_VARIABLE_WITHOUT_RBR = 3099 + ER_RUN_HOOK_ERROR = 3100 + ER_TRANSACTION_ROLLBACK_DURING_COMMIT = 3101 + ER_GENERATED_COLUMN_FUNCTION_IS_NOT_ALLOWED = 3102 + ER_UNSUPPORTED_ALTER_INPLACE_ON_VIRTUAL_COLUMN = 3103 + ER_WRONG_FK_OPTION_FOR_GENERATED_COLUMN = 3104 + ER_NON_DEFAULT_VALUE_FOR_GENERATED_COLUMN = 3105 + ER_UNSUPPORTED_ACTION_ON_GENERATED_COLUMN = 3106 + ER_GENERATED_COLUMN_NON_PRIOR = 3107 + ER_DEPENDENT_BY_GENERATED_COLUMN = 3108 + ER_GENERATED_COLUMN_REF_AUTO_INC = 3109 + ER_FEATURE_NOT_AVAILABLE = 3110 + ER_CANT_SET_GTID_MODE = 3111 + ER_CANT_USE_AUTO_POSITION_WITH_GTID_MODE_OFF = 3112 + ER_CANT_REPLICATE_ANONYMOUS_WITH_AUTO_POSITION = 3113 + ER_CANT_REPLICATE_ANONYMOUS_WITH_GTID_MODE_ON = 3114 + ER_CANT_REPLICATE_GTID_WITH_GTID_MODE_OFF = 3115 + ER_CANT_SET_ENFORCE_GTID_CONSISTENCY_ON_WITH_ONGOING_GTID_VIOLATING_TRANSACTIONS = 3116 + ER_SET_ENFORCE_GTID_CONSISTENCY_WARN_WITH_ONGOING_GTID_VIOLATING_TRANSACTIONS = 3117 + ER_ACCOUNT_HAS_BEEN_LOCKED = 3118 + ER_WRONG_TABLESPACE_NAME = 3119 + ER_TABLESPACE_IS_NOT_EMPTY = 3120 + ER_WRONG_FILE_NAME = 3121 + ER_BOOST_GEOMETRY_INCONSISTENT_TURNS_EXCEPTION = 3122 + ER_WARN_OPTIMIZER_HINT_SYNTAX_ERROR = 3123 + ER_WARN_BAD_MAX_EXECUTION_TIME = 3124 + ER_WARN_UNSUPPORTED_MAX_EXECUTION_TIME = 3125 + ER_WARN_CONFLICTING_HINT = 3126 + ER_WARN_UNKNOWN_QB_NAME = 3127 + ER_UNRESOLVED_HINT_NAME = 3128 + ER_WARN_ON_MODIFYING_GTID_EXECUTED_TABLE = 3129 + ER_PLUGGABLE_PROTOCOL_COMMAND_NOT_SUPPORTED = 3130 + ER_LOCKING_SERVICE_WRONG_NAME = 3131 + ER_LOCKING_SERVICE_DEADLOCK = 3132 + ER_LOCKING_SERVICE_TIMEOUT = 3133 + ER_GIS_MAX_POINTS_IN_GEOMETRY_OVERFLOWED = 3134 + ER_SQL_MODE_MERGED = 3135 + ER_VTOKEN_PLUGIN_TOKEN_MISMATCH = 3136 + ER_VTOKEN_PLUGIN_TOKEN_NOT_FOUND = 3137 + ER_CANT_SET_VARIABLE_WHEN_OWNING_GTID = 3138 + ER_SLAVE_CHANNEL_OPERATION_NOT_ALLOWED = 3139 + ER_INVALID_JSON_TEXT = 3140 + ER_INVALID_JSON_TEXT_IN_PARAM = 3141 + ER_INVALID_JSON_BINARY_DATA = 3142 + ER_INVALID_JSON_PATH = 3143 + ER_INVALID_JSON_CHARSET = 3144 + ER_INVALID_JSON_CHARSET_IN_FUNCTION = 3145 + ER_INVALID_TYPE_FOR_JSON = 3146 + ER_INVALID_CAST_TO_JSON = 3147 + ER_INVALID_JSON_PATH_CHARSET = 3148 + ER_INVALID_JSON_PATH_WILDCARD = 3149 + ER_JSON_VALUE_TOO_BIG = 3150 + ER_JSON_KEY_TOO_BIG = 3151 + ER_JSON_USED_AS_KEY = 3152 + ER_JSON_VACUOUS_PATH = 3153 + ER_JSON_BAD_ONE_OR_ALL_ARG = 3154 + ER_NUMERIC_JSON_VALUE_OUT_OF_RANGE = 3155 + ER_INVALID_JSON_VALUE_FOR_CAST = 3156 + ER_JSON_DOCUMENT_TOO_DEEP = 3157 + ER_JSON_DOCUMENT_NULL_KEY = 3158 + ER_SECURE_TRANSPORT_REQUIRED = 3159 + ER_NO_SECURE_TRANSPORTS_CONFIGURED = 3160 + ER_DISABLED_STORAGE_ENGINE = 3161 + ER_USER_DOES_NOT_EXIST = 3162 + ER_USER_ALREADY_EXISTS = 3163 + ER_AUDIT_API_ABORT = 3164 + ER_INVALID_JSON_PATH_ARRAY_CELL = 3165 + ER_BUFPOOL_RESIZE_INPROGRESS = 3166 + ER_FEATURE_DISABLED_SEE_DOC = 3167 + ER_SERVER_ISNT_AVAILABLE = 3168 + ER_SESSION_WAS_KILLED = 3169 + ER_CAPACITY_EXCEEDED = 3170 + ER_CAPACITY_EXCEEDED_IN_RANGE_OPTIMIZER = 3171 + ER_TABLE_NEEDS_UPG_PART = 3172 + ER_CANT_WAIT_FOR_EXECUTED_GTID_SET_WHILE_OWNING_A_GTID = 3173 + ER_CANNOT_ADD_FOREIGN_BASE_COL_VIRTUAL = 3174 + ER_CANNOT_CREATE_VIRTUAL_INDEX_CONSTRAINT = 3175 + ER_ERROR_ON_MODIFYING_GTID_EXECUTED_TABLE = 3176 + ER_LOCK_REFUSED_BY_ENGINE = 3177 + ER_UNSUPPORTED_ALTER_ONLINE_ON_VIRTUAL_COLUMN = 3178 + ER_MASTER_KEY_ROTATION_NOT_SUPPORTED_BY_SE = 3179 + ER_MASTER_KEY_ROTATION_ERROR_BY_SE = 3180 + ER_MASTER_KEY_ROTATION_BINLOG_FAILED = 3181 + ER_MASTER_KEY_ROTATION_SE_UNAVAILABLE = 3182 + ER_TABLESPACE_CANNOT_ENCRYPT = 3183 + ER_INVALID_ENCRYPTION_OPTION = 3184 + ER_CANNOT_FIND_KEY_IN_KEYRING = 3185 + ER_CAPACITY_EXCEEDED_IN_PARSER = 3186 + ER_UNSUPPORTED_ALTER_ENCRYPTION_INPLACE = 3187 + ER_KEYRING_UDF_KEYRING_SERVICE_ERROR = 3188 + ER_USER_COLUMN_OLD_LENGTH = 3189 +) diff --git a/yarn.lock b/yarn.lock index 413ff19e472..c98bec855e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -228,9 +228,9 @@ version "7946.0.4" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.4.tgz#4e049756383c3f055dd8f3d24e63fb543e98eb07" -"@types/jest@^21.1.4": - version "21.1.10" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-21.1.10.tgz#dcacb5217ddf997a090cc822bba219b4b2fd7984" +"@types/jest@^23.3.2": + version "23.3.2" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.2.tgz#07b90f6adf75d42c34230c026a2529e56c249dbb" "@types/node@*": version "10.9.4"