diff --git a/.circleci/config.yml b/.circleci/config.yml index eb8724bed3c..2225e0a16cf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,7 +32,7 @@ jobs: - run: sudo apt update - run: sudo apt install -y mysql-client - run: dockerize -wait tcp://127.0.0.1:3306 -timeout 120s - - run: cat docker/blocks/mysql_tests/setup.sql | mysql -h 127.0.0.1 -P 3306 -u root -prootpass + - run: cat devenv/docker/blocks/mysql_tests/setup.sql | mysql -h 127.0.0.1 -P 3306 -u root -prootpass - run: name: mysql integration tests command: 'GRAFANA_TEST_DB=mysql go test ./pkg/services/sqlstore/... ./pkg/tsdb/mysql/... ' @@ -51,7 +51,7 @@ jobs: - run: sudo apt update - run: sudo apt install -y postgresql-client - run: dockerize -wait tcp://127.0.0.1:5432 -timeout 120s - - run: 'PGPASSWORD=grafanatest psql -p 5432 -h 127.0.0.1 -U grafanatest -d grafanatest -f docker/blocks/postgres_tests/setup.sql' + - run: 'PGPASSWORD=grafanatest psql -p 5432 -h 127.0.0.1 -U grafanatest -d grafanatest -f devenv/docker/blocks/postgres_tests/setup.sql' - run: name: postgres integration tests command: 'GRAFANA_TEST_DB=postgres go test ./pkg/services/sqlstore/... ./pkg/tsdb/postgres/...' @@ -81,15 +81,16 @@ jobs: working_directory: /go/src/github.com/grafana/grafana steps: - checkout - - run: 'go get -u gopkg.in/alecthomas/gometalinter.v2' + - 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.v2 --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 bf97948d178..08525d92519 100644 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,8 @@ public/css/*.min.css conf/custom.ini fig.yml -docker-compose.yml -docker-compose.yaml +devenv/docker-compose.yml +devenv/docker-compose.yaml /conf/provisioning/**/custom.yaml /conf/provisioning/**/dev.yaml /conf/ldap_dev.toml @@ -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 08c51cf97e9..e0fd5caf819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ +# 5.4.0 (unreleased) + +### New Features + +* **Annotations**: Enable template variables in tagged annotations queries [#9735](https://github.com/grafana/grafana/issues/9735) + +### 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) +* **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) + # 5.3.0 (unreleased) +### Minor + +* **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) +* **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) ### New Major Features @@ -81,7 +104,7 @@ ### New experimental features -These are new features that's still being worked on and are in an experimental phase. We incourage users to try these out and provide any feedback in related issue. +These are new features that's still being worked on and are in an experimental phase. We encourage users to try these out and provide any feedback in related issue. * **Dashboard**: Auto fit dashboard panels to optimize space used for current TV / Monitor [#12768](https://github.com/grafana/grafana/issues/12768) @@ -90,6 +113,10 @@ These are new features that's still being worked on and are in an experimental p * **Frontend**: Convert all Frontend Karma tests to Jest tests [#12224](https://github.com/grafana/grafana/issues/12224) * **Backend**: Upgrade to golang 1.11 [#13030](https://github.com/grafana/grafana/issues/13030) +# 5.2.4 (2018-09-07) + +* **GrafanaCli**: Fixed issue with grafana-cli install plugin resulting in corrupt http response from source error. Fixes [#13079](https://github.com/grafana/grafana/issues/13079) + # 5.2.3 (2018-08-29) ### Important fix for LDAP & OAuth login vulnerability @@ -294,7 +321,7 @@ See [security announcement](https://community.grafana.com/t/grafana-5-2-3-and-4- * **Dashboard**: Sizing and positioning of settings menu icons [#11572](https://github.com/grafana/grafana/pull/11572) * **Dashboard**: Add search filter/tabs to new panel control [#10427](https://github.com/grafana/grafana/issues/10427) * **Folders**: User with org viewer role should not be able to save/move dashboards in/to general folder [#11553](https://github.com/grafana/grafana/issues/11553) -* **Influxdb**: Dont assume the first column in table response is time. [#11476](https://github.com/grafana/grafana/issues/11476), thx [@hahnjo](https://github.com/hahnjo) +* **Influxdb**: Don't assume the first column in table response is time. [#11476](https://github.com/grafana/grafana/issues/11476), thx [@hahnjo](https://github.com/hahnjo) ### Tech * Backend code simplification [#11613](https://github.com/grafana/grafana/pull/11613), thx [@knweiss](https://github.com/knweiss) @@ -481,7 +508,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) @@ -1440,7 +1467,7 @@ Grafana 2.x is fundamentally different from 1.x; it now ships with an integrated **New features** - [Issue #1623](https://github.com/grafana/grafana/issues/1623). Share Dashboard: Dashboard snapshot sharing (dash and data snapshot), save to local or save to public snapshot dashboard snapshots.raintank.io site -- [Issue #1622](https://github.com/grafana/grafana/issues/1622). Share Panel: The share modal now has an embed option, gives you an iframe that you can use to embedd a single graph on another web site +- [Issue #1622](https://github.com/grafana/grafana/issues/1622). Share Panel: The share modal now has an embed option, gives you an iframe that you can use to embed a single graph on another web site - [Issue #718](https://github.com/grafana/grafana/issues/718). Dashboard: When saving a dashboard and another user has made changes in between the user is prompted with a warning if he really wants to overwrite the other's changes - [Issue #1331](https://github.com/grafana/grafana/issues/1331). Graph & Singlestat: New axis/unit format selector and more units (kbytes, Joule, Watt, eV), and new design for graph axis & grid tab and single stat options tab views - [Issue #1241](https://github.com/grafana/grafana/issues/1242). Timepicker: New option in timepicker (under dashboard settings), to change ``now`` to be for example ``now-1m``, useful when you want to ignore last minute because it contains incomplete data diff --git a/Gruntfile.js b/Gruntfile.js index 8a71fb44148..2d5990b5f58 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -25,7 +25,6 @@ module.exports = function (grunt) { } } - config.coverage = grunt.option('coverage'); config.phjs = grunt.option('phjsToRelease'); config.pkg.version = grunt.option('pkgVer') || config.pkg.version; diff --git a/Makefile b/Makefile index c6915409ed7..c9e51d897f3 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,3 @@ test: test-go test-js run: ./bin/grafana-server - -protoc: - protoc -I pkg/tsdb/models pkg/tsdb/models/*.proto --go_out=plugins=grpc:pkg/tsdb/models/. diff --git a/build.go b/build.go index 561dd70df0e..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,17 +115,16 @@ 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() } - case "pkg-rpm": grunt(gruntBuildArg("release")...) createRpmPackages() @@ -379,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...) @@ -417,11 +421,11 @@ func test(pkg string) { func build(binaryName, pkg string, tags []string) { binary := fmt.Sprintf("./bin/%s-%s/%s", goos, goarch, binaryName) if isDev { - //dont include os and arch in output path in dev environment + //don't include os and arch in output path in dev environment binary = fmt.Sprintf("./bin/%s", binaryName) } - if goos == "windows" { + if goos == windows { binary += ".exe" } @@ -485,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/codecov.yml b/codecov.yml deleted file mode 100644 index b2a839365ac..00000000000 --- a/codecov.yml +++ /dev/null @@ -1,11 +0,0 @@ -coverage: - precision: 2 - round: down - range: "50...100" - - status: - project: yes - patch: yes - changes: no - -comment: off diff --git a/conf/defaults.ini b/conf/defaults.ini index 85d0953c6af..eb8debc0094 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -321,6 +321,7 @@ allow_sign_up = true client_id = some_id client_secret = some_secret scopes = user:email +email_attribute_name = email:primary auth_url = token_url = api_url = @@ -473,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/ldap.toml b/conf/ldap.toml index 9a7088ed823..b684f2556d5 100644 --- a/conf/ldap.toml +++ b/conf/ldap.toml @@ -31,37 +31,11 @@ search_filter = "(cn=%s)" # An array of base dns to search through search_base_dns = ["dc=grafana,dc=org"] -# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups. -# This is done by enabling group_search_filter below. You must also set member_of= "cn" -# in [servers.attributes] below. - -# Users with nested/recursive group membership and an LDAP server that supports LDAP_MATCHING_RULE_IN_CHAIN -# can set group_search_filter, group_search_filter_user_attribute, group_search_base_dns and member_of -# below in such a way that the user's recursive group membership is considered. -# -# Nested Groups + Active Directory (AD) Example: -# -# AD groups store the Distinguished Names (DNs) of members, so your filter must -# recursively search your groups for the authenticating user's DN. For example: -# -# group_search_filter = "(member:1.2.840.113556.1.4.1941:=%s)" -# group_search_filter_user_attribute = "distinguishedName" -# group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] -# -# [servers.attributes] -# ... -# member_of = "distinguishedName" - -## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available) +## For Posix or LDAP setups that does not support member_of attribute you can define the below settings +## Please check grafana LDAP docs for examples # group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))" -## Group search filter user attribute defines what user attribute gets substituted for %s in group_search_filter. -## Defaults to the value of username in [server.attributes] -## Valid options are any of your values in [servers.attributes] -## If you are using nested groups you probably want to set this and member_of in -## [servers.attributes] to "distinguishedName" -# group_search_filter_user_attribute = "distinguishedName" -## An array of the base DNs to search through for groups. Typically uses ou=groups # group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] +# group_search_filter_user_attribute = "uid" # Specify names of the ldap attributes your ldap uses [servers.attributes] diff --git a/conf/sample.ini b/conf/sample.ini index 2ef254f79b9..f393c66a20e 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 diff --git a/scripts/benchmarks/ab/ab_test.sh b/devenv/benchmarks/ab/ab_test.sh similarity index 100% rename from scripts/benchmarks/ab/ab_test.sh rename to devenv/benchmarks/ab/ab_test.sh 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/docker/create_docker_compose.sh b/devenv/create_docker_compose.sh similarity index 94% rename from docker/create_docker_compose.sh rename to devenv/create_docker_compose.sh index 9d28ede8e7e..5da9e8f5c8f 100755 --- a/docker/create_docker_compose.sh +++ b/devenv/create_docker_compose.sh @@ -1,13 +1,13 @@ #!/bin/bash -blocks_dir=blocks +blocks_dir=docker/blocks docker_dir=docker template_dir=templates grafana_config_file=conf.tmp grafana_config=config -compose_header_file=compose_header.yml +compose_header_file=docker/compose_header.yml fig_file=docker-compose.yaml fig_config=docker-compose.yaml diff --git a/devenv/dev-dashboards/panel_tests_polystat.json b/devenv/dev-dashboards/panel_tests_polystat.json new file mode 100644 index 00000000000..51d3085c438 --- /dev/null +++ b/devenv/dev-dashboards/panel_tests_polystat.json @@ -0,0 +1,3343 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_4", + "datasource": "gdev-testdata", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 4, + "links": [], + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": "", + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": "", + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "C", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "D", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "E", + "scenarioId": "random_walk" + } + ], + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "title": "Poor use of space", + "type": "grafana-polystat-panel", + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date & time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-ampere reactive (var)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-ampere reactive (kvar)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Farenheit (°F)", + "value": "farenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + }, + { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_5", + "datasource": "gdev-testdata", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 5, + "links": [], + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": "", + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": "", + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [ + { + "compositeName": "comp", + "members": [ + { + "seriesName": "A-series" + }, + { + "seriesName": "B-series" + } + ], + "enabled": true, + "clickThrough": "", + "hideMembers": true, + "showName": true, + "showValue": true, + "animateMode": "all", + "thresholdLevel": 0, + "sanitizeURLEnabled": true, + "sanitizedURL": "" + } + ], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "targets": [ + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "C", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "D", + "scenarioId": "random_walk" + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "E", + "scenarioId": "random_walk" + } + ], + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "title": "Composite crash", + "type": "grafana-polystat-panel", + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date & time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-ampere reactive (var)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-ampere reactive (kvar)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Farenheit (°F)", + "value": "farenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + }, + { + "animationModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "colors": [ + "#299c46", + "rgba(237, 129, 40, 0.89)", + "#d44a3a" + ], + "d3DivId": "d3_svg_2", + "datasource": "gdev-testdata", + "decimals": 2, + "displayModes": [ + { + "text": "Show All", + "value": "all" + }, + { + "text": "Show Triggered", + "value": "triggered" + } + ], + "fontSizes": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 70 + ], + "fontTypes": [ + "Open Sans", + "Arial", + "Avant Garde", + "Bookman", + "Consolas", + "Courier", + "Courier New", + "Futura", + "Garamond", + "Helvetica", + "Palatino", + "Times", + "Times New Roman", + "Verdana" + ], + "format": "none", + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 2, + "links": [], + "notcolors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "operatorName": "avg", + "operatorOptions": [ + { + "text": "Average", + "value": "avg" + }, + { + "text": "Count", + "value": "count" + }, + { + "text": "Current", + "value": "current" + }, + { + "text": "Delta", + "value": "delta" + }, + { + "text": "Difference", + "value": "diff" + }, + { + "text": "First", + "value": "first" + }, + { + "text": "Log Min", + "value": "logmin" + }, + { + "text": "Max", + "value": "max" + }, + { + "text": "Min", + "value": "min" + }, + { + "text": "Name", + "value": "name" + }, + { + "text": "Time of Last Point", + "value": "last_time" + }, + { + "text": "Time Step", + "value": "time_step" + }, + { + "text": "Total", + "value": "total" + } + ], + "polystat": { + "animationSpeed": 2500, + "columnAutoSize": true, + "columns": 1, + "defaultClickThrough": "", + "defaultClickThroughSanitize": true, + "displayLimit": 100, + "fontAutoScale": true, + "fontSize": 12, + "globalDisplayMode": "all", + "globalOperatorName": "avg", + "gradientEnabled": true, + "hexagonSortByDirection": "asc", + "hexagonSortByField": "name", + "maxMetrics": 0, + "polygonBorderColor": "black", + "polygonBorderSize": 2, + "radius": "", + "radiusAutoSize": true, + "rowAutoSize": true, + "rows": 1, + "shape": "hexagon_pointed_top", + "tooltipDisplayMode": "all", + "tooltipDisplayTextTriggeredEmpty": "OK", + "tooltipFontSize": 12, + "tooltipFontType": "Open Sans", + "tooltipPrimarySortDirection": "desc", + "tooltipPrimarySortField": "thresholdLevel", + "tooltipSecondarySortDirection": "desc", + "tooltipSecondarySortField": "value", + "tooltipTimestampEnabled": true + }, + "savedComposites": [], + "savedOverrides": [], + "shapes": [ + { + "text": "Hexagon Pointed Top", + "value": "hexagon_pointed_top" + }, + { + "text": "Hexagon Flat Top", + "value": "hexagon_flat_top" + }, + { + "text": "Circle", + "value": "circle" + }, + { + "text": "Cross", + "value": "cross" + }, + { + "text": "Diamond", + "value": "diamond" + }, + { + "text": "Square", + "value": "square" + }, + { + "text": "Star", + "value": "star" + }, + { + "text": "Triangle", + "value": "triangle" + }, + { + "text": "Wye", + "value": "wye" + } + ], + "sortDirections": [ + { + "text": "Ascending", + "value": "asc" + }, + { + "text": "Descending", + "value": "desc" + } + ], + "sortFields": [ + { + "text": "Name", + "value": "name" + }, + { + "text": "Threshold Level", + "value": "thresholdLevel" + }, + { + "text": "Value", + "value": "value" + } + ], + "svgContainer": {}, + "targets": [ + { + "alias": "Sensor-A", + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + }, + { + "alias": "Sensor-B", + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "3433,23432,55" + }, + { + "alias": "Sensor-C", + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,2,3,4,5,6" + }, + { + "alias": "Sensor-E", + "expr": "", + "format": "time_series", + "intervalFactor": 1, + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + } + ], + "thresholdStates": [ + { + "text": "ok", + "value": 0 + }, + { + "text": "warning", + "value": 1 + }, + { + "text": "critical", + "value": 2 + }, + { + "text": "custom", + "value": 3 + } + ], + "title": "No Value in Sensor-C Bug", + "type": "grafana-polystat-panel", + "unitFormats": [ + { + "submenu": [ + { + "text": "none", + "value": "none" + }, + { + "text": "short", + "value": "short" + }, + { + "text": "percent (0-100)", + "value": "percent" + }, + { + "text": "percent (0.0-1.0)", + "value": "percentunit" + }, + { + "text": "Humidity (%H)", + "value": "humidity" + }, + { + "text": "decibel", + "value": "dB" + }, + { + "text": "hexadecimal (0x)", + "value": "hex0x" + }, + { + "text": "hexadecimal", + "value": "hex" + }, + { + "text": "scientific notation", + "value": "sci" + }, + { + "text": "locale format", + "value": "locale" + } + ], + "text": "none" + }, + { + "submenu": [ + { + "text": "Dollars ($)", + "value": "currencyUSD" + }, + { + "text": "Pounds (£)", + "value": "currencyGBP" + }, + { + "text": "Euro (€)", + "value": "currencyEUR" + }, + { + "text": "Yen (¥)", + "value": "currencyJPY" + }, + { + "text": "Rubles (₽)", + "value": "currencyRUB" + }, + { + "text": "Hryvnias (₴)", + "value": "currencyUAH" + }, + { + "text": "Real (R$)", + "value": "currencyBRL" + }, + { + "text": "Danish Krone (kr)", + "value": "currencyDKK" + }, + { + "text": "Icelandic Króna (kr)", + "value": "currencyISK" + }, + { + "text": "Norwegian Krone (kr)", + "value": "currencyNOK" + }, + { + "text": "Swedish Krona (kr)", + "value": "currencySEK" + }, + { + "text": "Czech koruna (czk)", + "value": "currencyCZK" + }, + { + "text": "Swiss franc (CHF)", + "value": "currencyCHF" + }, + { + "text": "Polish Złoty (PLN)", + "value": "currencyPLN" + }, + { + "text": "Bitcoin (฿)", + "value": "currencyBTC" + } + ], + "text": "currency" + }, + { + "submenu": [ + { + "text": "Hertz (1/s)", + "value": "hertz" + }, + { + "text": "nanoseconds (ns)", + "value": "ns" + }, + { + "text": "microseconds (µs)", + "value": "µs" + }, + { + "text": "milliseconds (ms)", + "value": "ms" + }, + { + "text": "seconds (s)", + "value": "s" + }, + { + "text": "minutes (m)", + "value": "m" + }, + { + "text": "hours (h)", + "value": "h" + }, + { + "text": "days (d)", + "value": "d" + }, + { + "text": "duration (ms)", + "value": "dtdurationms" + }, + { + "text": "duration (s)", + "value": "dtdurations" + }, + { + "text": "duration (hh:mm:ss)", + "value": "dthms" + }, + { + "text": "Timeticks (s/100)", + "value": "timeticks" + } + ], + "text": "time" + }, + { + "submenu": [ + { + "text": "YYYY-MM-DD HH:mm:ss", + "value": "dateTimeAsIso" + }, + { + "text": "DD/MM/YYYY h:mm:ss a", + "value": "dateTimeAsUS" + }, + { + "text": "From Now", + "value": "dateTimeFromNow" + } + ], + "text": "date & time" + }, + { + "submenu": [ + { + "text": "bits", + "value": "bits" + }, + { + "text": "bytes", + "value": "bytes" + }, + { + "text": "kibibytes", + "value": "kbytes" + }, + { + "text": "mebibytes", + "value": "mbytes" + }, + { + "text": "gibibytes", + "value": "gbytes" + } + ], + "text": "data (IEC)" + }, + { + "submenu": [ + { + "text": "bits", + "value": "decbits" + }, + { + "text": "bytes", + "value": "decbytes" + }, + { + "text": "kilobytes", + "value": "deckbytes" + }, + { + "text": "megabytes", + "value": "decmbytes" + }, + { + "text": "gigabytes", + "value": "decgbytes" + } + ], + "text": "data (Metric)" + }, + { + "submenu": [ + { + "text": "packets/sec", + "value": "pps" + }, + { + "text": "bits/sec", + "value": "bps" + }, + { + "text": "bytes/sec", + "value": "Bps" + }, + { + "text": "kilobits/sec", + "value": "Kbits" + }, + { + "text": "kilobytes/sec", + "value": "KBs" + }, + { + "text": "megabits/sec", + "value": "Mbits" + }, + { + "text": "megabytes/sec", + "value": "MBs" + }, + { + "text": "gigabytes/sec", + "value": "GBs" + }, + { + "text": "gigabits/sec", + "value": "Gbits" + } + ], + "text": "data rate" + }, + { + "submenu": [ + { + "text": "hashes/sec", + "value": "Hs" + }, + { + "text": "kilohashes/sec", + "value": "KHs" + }, + { + "text": "megahashes/sec", + "value": "MHs" + }, + { + "text": "gigahashes/sec", + "value": "GHs" + }, + { + "text": "terahashes/sec", + "value": "THs" + }, + { + "text": "petahashes/sec", + "value": "PHs" + }, + { + "text": "exahashes/sec", + "value": "EHs" + } + ], + "text": "hash rate" + }, + { + "submenu": [ + { + "text": "ops/sec (ops)", + "value": "ops" + }, + { + "text": "requests/sec (rps)", + "value": "reqps" + }, + { + "text": "reads/sec (rps)", + "value": "rps" + }, + { + "text": "writes/sec (wps)", + "value": "wps" + }, + { + "text": "I/O ops/sec (iops)", + "value": "iops" + }, + { + "text": "ops/min (opm)", + "value": "opm" + }, + { + "text": "reads/min (rpm)", + "value": "rpm" + }, + { + "text": "writes/min (wpm)", + "value": "wpm" + } + ], + "text": "throughput" + }, + { + "submenu": [ + { + "text": "millimetre (mm)", + "value": "lengthmm" + }, + { + "text": "meter (m)", + "value": "lengthm" + }, + { + "text": "feet (ft)", + "value": "lengthft" + }, + { + "text": "kilometer (km)", + "value": "lengthkm" + }, + { + "text": "mile (mi)", + "value": "lengthmi" + } + ], + "text": "length" + }, + { + "submenu": [ + { + "text": "Square Meters (m²)", + "value": "areaM2" + }, + { + "text": "Square Feet (ft²)", + "value": "areaF2" + }, + { + "text": "Square Miles (mi²)", + "value": "areaMI2" + } + ], + "text": "area" + }, + { + "submenu": [ + { + "text": "milligram (mg)", + "value": "massmg" + }, + { + "text": "gram (g)", + "value": "massg" + }, + { + "text": "kilogram (kg)", + "value": "masskg" + }, + { + "text": "metric ton (t)", + "value": "masst" + } + ], + "text": "mass" + }, + { + "submenu": [ + { + "text": "metres/second (m/s)", + "value": "velocityms" + }, + { + "text": "kilometers/hour (km/h)", + "value": "velocitykmh" + }, + { + "text": "miles/hour (mph)", + "value": "velocitymph" + }, + { + "text": "knot (kn)", + "value": "velocityknot" + } + ], + "text": "velocity" + }, + { + "submenu": [ + { + "text": "millilitre (mL)", + "value": "mlitre" + }, + { + "text": "litre (L)", + "value": "litre" + }, + { + "text": "cubic metre", + "value": "m3" + }, + { + "text": "Normal cubic metre", + "value": "Nm3" + }, + { + "text": "cubic decimetre", + "value": "dm3" + }, + { + "text": "gallons", + "value": "gallons" + } + ], + "text": "volume" + }, + { + "submenu": [ + { + "text": "Watt (W)", + "value": "watt" + }, + { + "text": "Kilowatt (kW)", + "value": "kwatt" + }, + { + "text": "Milliwatt (mW)", + "value": "mwatt" + }, + { + "text": "Watt per square metre (W/m²)", + "value": "Wm2" + }, + { + "text": "Volt-ampere (VA)", + "value": "voltamp" + }, + { + "text": "Kilovolt-ampere (kVA)", + "value": "kvoltamp" + }, + { + "text": "Volt-ampere reactive (var)", + "value": "voltampreact" + }, + { + "text": "Kilovolt-ampere reactive (kvar)", + "value": "kvoltampreact" + }, + { + "text": "Watt-hour (Wh)", + "value": "watth" + }, + { + "text": "Kilowatt-hour (kWh)", + "value": "kwatth" + }, + { + "text": "Kilowatt-min (kWm)", + "value": "kwattm" + }, + { + "text": "Joule (J)", + "value": "joule" + }, + { + "text": "Electron volt (eV)", + "value": "ev" + }, + { + "text": "Ampere (A)", + "value": "amp" + }, + { + "text": "Kiloampere (kA)", + "value": "kamp" + }, + { + "text": "Milliampere (mA)", + "value": "mamp" + }, + { + "text": "Volt (V)", + "value": "volt" + }, + { + "text": "Kilovolt (kV)", + "value": "kvolt" + }, + { + "text": "Millivolt (mV)", + "value": "mvolt" + }, + { + "text": "Decibel-milliwatt (dBm)", + "value": "dBm" + }, + { + "text": "Ohm (Ω)", + "value": "ohm" + }, + { + "text": "Lumens (Lm)", + "value": "lumens" + } + ], + "text": "energy" + }, + { + "submenu": [ + { + "text": "Celsius (°C)", + "value": "celsius" + }, + { + "text": "Farenheit (°F)", + "value": "farenheit" + }, + { + "text": "Kelvin (K)", + "value": "kelvin" + } + ], + "text": "temperature" + }, + { + "submenu": [ + { + "text": "Millibars", + "value": "pressurembar" + }, + { + "text": "Bars", + "value": "pressurebar" + }, + { + "text": "Kilobars", + "value": "pressurekbar" + }, + { + "text": "Hectopascals", + "value": "pressurehpa" + }, + { + "text": "Kilopascals", + "value": "pressurekpa" + }, + { + "text": "Inches of mercury", + "value": "pressurehg" + }, + { + "text": "PSI", + "value": "pressurepsi" + } + ], + "text": "pressure" + }, + { + "submenu": [ + { + "text": "Newton-meters (Nm)", + "value": "forceNm" + }, + { + "text": "Kilonewton-meters (kNm)", + "value": "forcekNm" + }, + { + "text": "Newtons (N)", + "value": "forceN" + }, + { + "text": "Kilonewtons (kN)", + "value": "forcekN" + } + ], + "text": "force" + }, + { + "submenu": [ + { + "text": "Gallons/min (gpm)", + "value": "flowgpm" + }, + { + "text": "Cubic meters/sec (cms)", + "value": "flowcms" + }, + { + "text": "Cubic feet/sec (cfs)", + "value": "flowcfs" + }, + { + "text": "Cubic feet/min (cfm)", + "value": "flowcfm" + }, + { + "text": "Litre/hour", + "value": "litreh" + }, + { + "text": "Litre/min (l/min)", + "value": "flowlpm" + }, + { + "text": "milliLitre/min (mL/min)", + "value": "flowmlpm" + } + ], + "text": "flow" + }, + { + "submenu": [ + { + "text": "Degrees (°)", + "value": "degree" + }, + { + "text": "Radians", + "value": "radian" + }, + { + "text": "Gradian", + "value": "grad" + } + ], + "text": "angle" + }, + { + "submenu": [ + { + "text": "Meters/sec²", + "value": "accMS2" + }, + { + "text": "Feet/sec²", + "value": "accFS2" + }, + { + "text": "G unit", + "value": "accG" + } + ], + "text": "acceleration" + }, + { + "submenu": [ + { + "text": "Becquerel (Bq)", + "value": "radbq" + }, + { + "text": "curie (Ci)", + "value": "radci" + }, + { + "text": "Gray (Gy)", + "value": "radgy" + }, + { + "text": "rad", + "value": "radrad" + }, + { + "text": "Sievert (Sv)", + "value": "radsv" + }, + { + "text": "rem", + "value": "radrem" + }, + { + "text": "Exposure (C/kg)", + "value": "radexpckg" + }, + { + "text": "roentgen (R)", + "value": "radr" + }, + { + "text": "Sievert/hour (Sv/h)", + "value": "radsvh" + } + ], + "text": "radiation" + }, + { + "submenu": [ + { + "text": "parts-per-million (ppm)", + "value": "ppm" + }, + { + "text": "parts-per-billion (ppb)", + "value": "conppb" + }, + { + "text": "nanogram per cubic metre (ng/m³)", + "value": "conngm3" + }, + { + "text": "nanogram per normal cubic metre (ng/Nm³)", + "value": "conngNm3" + }, + { + "text": "microgram per cubic metre (μg/m³)", + "value": "conμgm3" + }, + { + "text": "microgram per normal cubic metre (μg/Nm³)", + "value": "conμgNm3" + }, + { + "text": "milligram per cubic metre (mg/m³)", + "value": "conmgm3" + }, + { + "text": "milligram per normal cubic metre (mg/Nm³)", + "value": "conmgNm3" + }, + { + "text": "gram per cubic metre (g/m³)", + "value": "congm3" + }, + { + "text": "gram per normal cubic metre (g/Nm³)", + "value": "congNm3" + } + ], + "text": "concentration" + } + ] + } + ], + "schemaVersion": 16, + "style": "dark", + "tags": [ + "panel-test", + "gdev" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "", + "title": "Panel Tests - Polystat", + "uid": "Kp9Z0hTik", + "version": 5 +} diff --git a/docker/blocks/apache_proxy/Dockerfile b/devenv/docker/blocks/apache_proxy/Dockerfile similarity index 100% rename from docker/blocks/apache_proxy/Dockerfile rename to devenv/docker/blocks/apache_proxy/Dockerfile diff --git a/docker/blocks/apache_proxy/docker-compose.yaml b/devenv/docker/blocks/apache_proxy/docker-compose.yaml similarity index 88% rename from docker/blocks/apache_proxy/docker-compose.yaml rename to devenv/docker/blocks/apache_proxy/docker-compose.yaml index 86d4befadd6..3791213f05a 100644 --- a/docker/blocks/apache_proxy/docker-compose.yaml +++ b/devenv/docker/blocks/apache_proxy/docker-compose.yaml @@ -5,5 +5,5 @@ # root_url = %(protocol)s://%(domain)s:10081/grafana/ apacheproxy: - build: blocks/apache_proxy + build: docker/blocks/apache_proxy network_mode: host diff --git a/docker/blocks/apache_proxy/ports.conf b/devenv/docker/blocks/apache_proxy/ports.conf similarity index 100% rename from docker/blocks/apache_proxy/ports.conf rename to devenv/docker/blocks/apache_proxy/ports.conf diff --git a/docker/blocks/apache_proxy/proxy.conf b/devenv/docker/blocks/apache_proxy/proxy.conf similarity index 100% rename from docker/blocks/apache_proxy/proxy.conf rename to devenv/docker/blocks/apache_proxy/proxy.conf diff --git a/docker/blocks/collectd/Dockerfile b/devenv/docker/blocks/collectd/Dockerfile similarity index 100% rename from docker/blocks/collectd/Dockerfile rename to devenv/docker/blocks/collectd/Dockerfile diff --git a/docker/blocks/collectd/README.md b/devenv/docker/blocks/collectd/README.md similarity index 100% rename from docker/blocks/collectd/README.md rename to devenv/docker/blocks/collectd/README.md diff --git a/docker/blocks/collectd/collectd.conf.tpl b/devenv/docker/blocks/collectd/collectd.conf.tpl similarity index 100% rename from docker/blocks/collectd/collectd.conf.tpl rename to devenv/docker/blocks/collectd/collectd.conf.tpl diff --git a/docker/blocks/collectd/docker-compose.yaml b/devenv/docker/blocks/collectd/docker-compose.yaml similarity index 87% rename from docker/blocks/collectd/docker-compose.yaml rename to devenv/docker/blocks/collectd/docker-compose.yaml index c95827f7928..c5e189b58d8 100644 --- a/docker/blocks/collectd/docker-compose.yaml +++ b/devenv/docker/blocks/collectd/docker-compose.yaml @@ -1,5 +1,5 @@ collectd: - build: blocks/collectd + build: docker/blocks/collectd environment: HOST_NAME: myserver GRAPHITE_HOST: graphite diff --git a/docker/blocks/collectd/etc_mtab b/devenv/docker/blocks/collectd/etc_mtab similarity index 100% rename from docker/blocks/collectd/etc_mtab rename to devenv/docker/blocks/collectd/etc_mtab diff --git a/docker/blocks/collectd/start_container b/devenv/docker/blocks/collectd/start_container similarity index 100% rename from docker/blocks/collectd/start_container rename to devenv/docker/blocks/collectd/start_container diff --git a/docker/blocks/elastic/docker-compose.yaml b/devenv/docker/blocks/elastic/docker-compose.yaml similarity index 100% rename from docker/blocks/elastic/docker-compose.yaml rename to devenv/docker/blocks/elastic/docker-compose.yaml diff --git a/docker/blocks/elastic/elasticsearch.yml b/devenv/docker/blocks/elastic/elasticsearch.yml similarity index 100% rename from docker/blocks/elastic/elasticsearch.yml rename to devenv/docker/blocks/elastic/elasticsearch.yml diff --git a/docker/blocks/elastic1/docker-compose.yaml b/devenv/docker/blocks/elastic1/docker-compose.yaml similarity index 100% rename from docker/blocks/elastic1/docker-compose.yaml rename to devenv/docker/blocks/elastic1/docker-compose.yaml diff --git a/docker/blocks/elastic1/elasticsearch.yml b/devenv/docker/blocks/elastic1/elasticsearch.yml similarity index 100% rename from docker/blocks/elastic1/elasticsearch.yml rename to devenv/docker/blocks/elastic1/elasticsearch.yml diff --git a/docker/blocks/elastic5/docker-compose.yaml b/devenv/docker/blocks/elastic5/docker-compose.yaml similarity index 100% rename from docker/blocks/elastic5/docker-compose.yaml rename to devenv/docker/blocks/elastic5/docker-compose.yaml diff --git a/docker/blocks/elastic5/elasticsearch.yml b/devenv/docker/blocks/elastic5/elasticsearch.yml similarity index 100% rename from docker/blocks/elastic5/elasticsearch.yml rename to devenv/docker/blocks/elastic5/elasticsearch.yml diff --git a/docker/blocks/elastic6/docker-compose.yaml b/devenv/docker/blocks/elastic6/docker-compose.yaml similarity index 100% rename from docker/blocks/elastic6/docker-compose.yaml rename to devenv/docker/blocks/elastic6/docker-compose.yaml diff --git a/docker/blocks/elastic6/elasticsearch.yml b/devenv/docker/blocks/elastic6/elasticsearch.yml similarity index 100% rename from docker/blocks/elastic6/elasticsearch.yml rename to devenv/docker/blocks/elastic6/elasticsearch.yml diff --git a/docker/blocks/graphite/Dockerfile b/devenv/docker/blocks/graphite/Dockerfile similarity index 100% rename from docker/blocks/graphite/Dockerfile rename to devenv/docker/blocks/graphite/Dockerfile diff --git a/docker/blocks/graphite/docker-compose.yaml b/devenv/docker/blocks/graphite/docker-compose.yaml similarity index 89% rename from docker/blocks/graphite/docker-compose.yaml rename to devenv/docker/blocks/graphite/docker-compose.yaml index 606e28638f7..acebd2bd9c0 100644 --- a/docker/blocks/graphite/docker-compose.yaml +++ b/devenv/docker/blocks/graphite/docker-compose.yaml @@ -1,5 +1,5 @@ graphite09: - build: blocks/graphite + build: docker/blocks/graphite ports: - "8080:80" - "2003:2003" diff --git a/docker/blocks/graphite/files/carbon.conf b/devenv/docker/blocks/graphite/files/carbon.conf similarity index 100% rename from docker/blocks/graphite/files/carbon.conf rename to devenv/docker/blocks/graphite/files/carbon.conf diff --git a/docker/blocks/graphite/files/events_views.py b/devenv/docker/blocks/graphite/files/events_views.py similarity index 100% rename from docker/blocks/graphite/files/events_views.py rename to devenv/docker/blocks/graphite/files/events_views.py diff --git a/docker/blocks/graphite/files/initial_data.json b/devenv/docker/blocks/graphite/files/initial_data.json similarity index 100% rename from docker/blocks/graphite/files/initial_data.json rename to devenv/docker/blocks/graphite/files/initial_data.json diff --git a/docker/blocks/graphite/files/local_settings.py b/devenv/docker/blocks/graphite/files/local_settings.py similarity index 100% rename from docker/blocks/graphite/files/local_settings.py rename to devenv/docker/blocks/graphite/files/local_settings.py diff --git a/docker/blocks/graphite/files/my_htpasswd b/devenv/docker/blocks/graphite/files/my_htpasswd similarity index 100% rename from docker/blocks/graphite/files/my_htpasswd rename to devenv/docker/blocks/graphite/files/my_htpasswd diff --git a/docker/blocks/graphite/files/nginx.conf b/devenv/docker/blocks/graphite/files/nginx.conf similarity index 100% rename from docker/blocks/graphite/files/nginx.conf rename to devenv/docker/blocks/graphite/files/nginx.conf diff --git a/docker/blocks/graphite/files/statsd_config.js b/devenv/docker/blocks/graphite/files/statsd_config.js similarity index 100% rename from docker/blocks/graphite/files/statsd_config.js rename to devenv/docker/blocks/graphite/files/statsd_config.js diff --git a/docker/blocks/graphite/files/storage-aggregation.conf b/devenv/docker/blocks/graphite/files/storage-aggregation.conf similarity index 100% rename from docker/blocks/graphite/files/storage-aggregation.conf rename to devenv/docker/blocks/graphite/files/storage-aggregation.conf diff --git a/docker/blocks/graphite/files/storage-schemas.conf b/devenv/docker/blocks/graphite/files/storage-schemas.conf similarity index 100% rename from docker/blocks/graphite/files/storage-schemas.conf rename to devenv/docker/blocks/graphite/files/storage-schemas.conf diff --git a/docker/blocks/graphite/files/supervisord.conf b/devenv/docker/blocks/graphite/files/supervisord.conf similarity index 100% rename from docker/blocks/graphite/files/supervisord.conf rename to devenv/docker/blocks/graphite/files/supervisord.conf diff --git a/docker/blocks/graphite1/Dockerfile b/devenv/docker/blocks/graphite1/Dockerfile similarity index 100% rename from docker/blocks/graphite1/Dockerfile rename to devenv/docker/blocks/graphite1/Dockerfile diff --git a/docker/blocks/graphite1/big-dashboard.json b/devenv/docker/blocks/graphite1/big-dashboard.json similarity index 100% rename from docker/blocks/graphite1/big-dashboard.json rename to devenv/docker/blocks/graphite1/big-dashboard.json diff --git a/docker/blocks/graphite1/conf/etc/logrotate.d/graphite-statsd b/devenv/docker/blocks/graphite1/conf/etc/logrotate.d/graphite-statsd similarity index 100% rename from docker/blocks/graphite1/conf/etc/logrotate.d/graphite-statsd rename to devenv/docker/blocks/graphite1/conf/etc/logrotate.d/graphite-statsd diff --git a/docker/blocks/graphite1/conf/etc/my_init.d/01_conf_init.sh b/devenv/docker/blocks/graphite1/conf/etc/my_init.d/01_conf_init.sh similarity index 100% rename from docker/blocks/graphite1/conf/etc/my_init.d/01_conf_init.sh rename to devenv/docker/blocks/graphite1/conf/etc/my_init.d/01_conf_init.sh diff --git a/docker/blocks/graphite1/conf/etc/nginx/nginx.conf b/devenv/docker/blocks/graphite1/conf/etc/nginx/nginx.conf similarity index 100% rename from docker/blocks/graphite1/conf/etc/nginx/nginx.conf rename to devenv/docker/blocks/graphite1/conf/etc/nginx/nginx.conf diff --git a/docker/blocks/graphite1/conf/etc/nginx/sites-enabled/graphite-statsd.conf b/devenv/docker/blocks/graphite1/conf/etc/nginx/sites-enabled/graphite-statsd.conf similarity index 100% rename from docker/blocks/graphite1/conf/etc/nginx/sites-enabled/graphite-statsd.conf rename to devenv/docker/blocks/graphite1/conf/etc/nginx/sites-enabled/graphite-statsd.conf diff --git a/docker/blocks/graphite1/conf/etc/service/carbon-aggregator/run b/devenv/docker/blocks/graphite1/conf/etc/service/carbon-aggregator/run similarity index 100% rename from docker/blocks/graphite1/conf/etc/service/carbon-aggregator/run rename to devenv/docker/blocks/graphite1/conf/etc/service/carbon-aggregator/run diff --git a/docker/blocks/graphite1/conf/etc/service/carbon/run b/devenv/docker/blocks/graphite1/conf/etc/service/carbon/run similarity index 100% rename from docker/blocks/graphite1/conf/etc/service/carbon/run rename to devenv/docker/blocks/graphite1/conf/etc/service/carbon/run diff --git a/docker/blocks/graphite1/conf/etc/service/graphite/run b/devenv/docker/blocks/graphite1/conf/etc/service/graphite/run similarity index 100% rename from docker/blocks/graphite1/conf/etc/service/graphite/run rename to devenv/docker/blocks/graphite1/conf/etc/service/graphite/run diff --git a/docker/blocks/graphite1/conf/etc/service/nginx/run b/devenv/docker/blocks/graphite1/conf/etc/service/nginx/run similarity index 100% rename from docker/blocks/graphite1/conf/etc/service/nginx/run rename to devenv/docker/blocks/graphite1/conf/etc/service/nginx/run diff --git a/docker/blocks/graphite1/conf/etc/service/statsd/run b/devenv/docker/blocks/graphite1/conf/etc/service/statsd/run similarity index 100% rename from docker/blocks/graphite1/conf/etc/service/statsd/run rename to devenv/docker/blocks/graphite1/conf/etc/service/statsd/run diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf similarity index 96% rename from docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/aggregation-rules.conf index c9520124a2a..792bbfd6857 100644 --- a/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/docker/blocks/graphite1/conf/opt/graphite/conf/blacklist.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/blacklist.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/blacklist.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/blacklist.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.amqp.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/carbon.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/dashboard.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/graphTemplates.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/graphTemplates.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/graphTemplates.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/graphTemplates.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/relay-rules.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/relay-rules.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/relay-rules.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/relay-rules.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/rewrite-rules.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/rewrite-rules.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/rewrite-rules.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/rewrite-rules.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/storage-aggregation.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/storage-aggregation.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/storage-aggregation.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/storage-aggregation.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/storage-schemas.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/storage-schemas.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/storage-schemas.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/storage-schemas.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/conf/whitelist.conf b/devenv/docker/blocks/graphite1/conf/opt/graphite/conf/whitelist.conf similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/conf/whitelist.conf rename to devenv/docker/blocks/graphite1/conf/opt/graphite/conf/whitelist.conf diff --git a/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/app_settings.py b/devenv/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/app_settings.py similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/app_settings.py rename to devenv/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/app_settings.py diff --git a/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/local_settings.py b/devenv/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/local_settings.py similarity index 100% rename from docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/local_settings.py rename to devenv/docker/blocks/graphite1/conf/opt/graphite/webapp/graphite/local_settings.py diff --git a/docker/blocks/graphite1/conf/opt/statsd/config.js b/devenv/docker/blocks/graphite1/conf/opt/statsd/config.js similarity index 100% rename from docker/blocks/graphite1/conf/opt/statsd/config.js rename to devenv/docker/blocks/graphite1/conf/opt/statsd/config.js diff --git a/docker/blocks/graphite1/conf/usr/local/bin/django_admin_init.exp b/devenv/docker/blocks/graphite1/conf/usr/local/bin/django_admin_init.exp similarity index 100% rename from docker/blocks/graphite1/conf/usr/local/bin/django_admin_init.exp rename to devenv/docker/blocks/graphite1/conf/usr/local/bin/django_admin_init.exp diff --git a/docker/blocks/graphite1/conf/usr/local/bin/manage.sh b/devenv/docker/blocks/graphite1/conf/usr/local/bin/manage.sh similarity index 100% rename from docker/blocks/graphite1/conf/usr/local/bin/manage.sh rename to devenv/docker/blocks/graphite1/conf/usr/local/bin/manage.sh diff --git a/docker/blocks/graphite1/docker-compose.yaml b/devenv/docker/blocks/graphite1/docker-compose.yaml similarity index 90% rename from docker/blocks/graphite1/docker-compose.yaml rename to devenv/docker/blocks/graphite1/docker-compose.yaml index cd10593f423..1fa3e738ba8 100644 --- a/docker/blocks/graphite1/docker-compose.yaml +++ b/devenv/docker/blocks/graphite1/docker-compose.yaml @@ -1,6 +1,6 @@ graphite: build: - context: blocks/graphite1 + context: docker/blocks/graphite1 args: version: master ports: diff --git a/docker/blocks/graphite11/big-dashboard.json b/devenv/docker/blocks/graphite11/big-dashboard.json similarity index 100% rename from docker/blocks/graphite11/big-dashboard.json rename to devenv/docker/blocks/graphite11/big-dashboard.json diff --git a/docker/blocks/graphite11/docker-compose.yaml b/devenv/docker/blocks/graphite11/docker-compose.yaml similarity index 100% rename from docker/blocks/graphite11/docker-compose.yaml rename to devenv/docker/blocks/graphite11/docker-compose.yaml diff --git a/docker/blocks/influxdb/docker-compose.yaml b/devenv/docker/blocks/influxdb/docker-compose.yaml similarity index 100% rename from docker/blocks/influxdb/docker-compose.yaml rename to devenv/docker/blocks/influxdb/docker-compose.yaml diff --git a/docker/blocks/influxdb/influxdb.conf b/devenv/docker/blocks/influxdb/influxdb.conf similarity index 100% rename from docker/blocks/influxdb/influxdb.conf rename to devenv/docker/blocks/influxdb/influxdb.conf diff --git a/docker/blocks/jaeger/docker-compose.yaml b/devenv/docker/blocks/jaeger/docker-compose.yaml similarity index 100% rename from docker/blocks/jaeger/docker-compose.yaml rename to devenv/docker/blocks/jaeger/docker-compose.yaml diff --git a/docker/blocks/memcached/docker-compose.yaml b/devenv/docker/blocks/memcached/docker-compose.yaml similarity index 100% rename from docker/blocks/memcached/docker-compose.yaml rename to devenv/docker/blocks/memcached/docker-compose.yaml diff --git a/docker/blocks/mssql/build/Dockerfile b/devenv/docker/blocks/mssql/build/Dockerfile similarity index 100% rename from docker/blocks/mssql/build/Dockerfile rename to devenv/docker/blocks/mssql/build/Dockerfile diff --git a/docker/blocks/mssql/build/entrypoint.sh b/devenv/docker/blocks/mssql/build/entrypoint.sh similarity index 100% rename from docker/blocks/mssql/build/entrypoint.sh rename to devenv/docker/blocks/mssql/build/entrypoint.sh diff --git a/docker/blocks/mssql/build/setup.sh b/devenv/docker/blocks/mssql/build/setup.sh similarity index 100% rename from docker/blocks/mssql/build/setup.sh rename to devenv/docker/blocks/mssql/build/setup.sh diff --git a/docker/blocks/mssql/build/setup.sql.template b/devenv/docker/blocks/mssql/build/setup.sql.template similarity index 100% rename from docker/blocks/mssql/build/setup.sql.template rename to devenv/docker/blocks/mssql/build/setup.sql.template diff --git a/docker/blocks/mssql/docker-compose.yaml b/devenv/docker/blocks/mssql/docker-compose.yaml similarity index 90% rename from docker/blocks/mssql/docker-compose.yaml rename to devenv/docker/blocks/mssql/docker-compose.yaml index a346fb791f7..05a93629e73 100644 --- a/docker/blocks/mssql/docker-compose.yaml +++ b/devenv/docker/blocks/mssql/docker-compose.yaml @@ -1,6 +1,6 @@ mssql: build: - context: blocks/mssql/build + context: docker/blocks/mssql/build environment: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: Password! diff --git a/docker/blocks/mssql_tests/docker-compose.yaml b/devenv/docker/blocks/mssql_tests/docker-compose.yaml similarity index 85% rename from docker/blocks/mssql_tests/docker-compose.yaml rename to devenv/docker/blocks/mssql_tests/docker-compose.yaml index 5da6aad82af..eea4d1e3561 100644 --- a/docker/blocks/mssql_tests/docker-compose.yaml +++ b/devenv/docker/blocks/mssql_tests/docker-compose.yaml @@ -1,6 +1,6 @@ mssqltests: build: - context: blocks/mssql/build + context: docker/blocks/mssql/build environment: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: Password! diff --git a/docker/blocks/mysql/config b/devenv/docker/blocks/mysql/config similarity index 100% rename from docker/blocks/mysql/config rename to devenv/docker/blocks/mysql/config diff --git a/docker/blocks/mysql/docker-compose.yaml b/devenv/docker/blocks/mysql/docker-compose.yaml similarity index 100% rename from docker/blocks/mysql/docker-compose.yaml rename to devenv/docker/blocks/mysql/docker-compose.yaml diff --git a/docker/blocks/mysql_opendata/Dockerfile b/devenv/docker/blocks/mysql_opendata/Dockerfile similarity index 100% rename from docker/blocks/mysql_opendata/Dockerfile rename to devenv/docker/blocks/mysql_opendata/Dockerfile diff --git a/docker/blocks/mysql_opendata/docker-compose.yaml b/devenv/docker/blocks/mysql_opendata/docker-compose.yaml similarity index 82% rename from docker/blocks/mysql_opendata/docker-compose.yaml rename to devenv/docker/blocks/mysql_opendata/docker-compose.yaml index 594eeed284a..4d478ee0860 100644 --- a/docker/blocks/mysql_opendata/docker-compose.yaml +++ b/devenv/docker/blocks/mysql_opendata/docker-compose.yaml @@ -1,5 +1,5 @@ mysql_opendata: - build: blocks/mysql_opendata + build: docker/blocks/mysql_opendata environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: testdata diff --git a/docker/blocks/mysql_opendata/import_csv.sql b/devenv/docker/blocks/mysql_opendata/import_csv.sql similarity index 100% rename from docker/blocks/mysql_opendata/import_csv.sql rename to devenv/docker/blocks/mysql_opendata/import_csv.sql diff --git a/docker/blocks/mysql_tests/Dockerfile b/devenv/docker/blocks/mysql_tests/Dockerfile similarity index 100% rename from docker/blocks/mysql_tests/Dockerfile rename to devenv/docker/blocks/mysql_tests/Dockerfile diff --git a/docker/blocks/mysql_tests/docker-compose.yaml b/devenv/docker/blocks/mysql_tests/docker-compose.yaml similarity index 84% rename from docker/blocks/mysql_tests/docker-compose.yaml rename to devenv/docker/blocks/mysql_tests/docker-compose.yaml index 035a6167017..a7509d47880 100644 --- a/docker/blocks/mysql_tests/docker-compose.yaml +++ b/devenv/docker/blocks/mysql_tests/docker-compose.yaml @@ -1,6 +1,6 @@ mysqltests: build: - context: blocks/mysql_tests + context: docker/blocks/mysql_tests environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: grafana_tests diff --git a/docker/blocks/mysql_tests/setup.sql b/devenv/docker/blocks/mysql_tests/setup.sql similarity index 100% rename from docker/blocks/mysql_tests/setup.sql rename to devenv/docker/blocks/mysql_tests/setup.sql diff --git a/docker/blocks/nginx_proxy/Dockerfile b/devenv/docker/blocks/nginx_proxy/Dockerfile similarity index 100% rename from docker/blocks/nginx_proxy/Dockerfile rename to devenv/docker/blocks/nginx_proxy/Dockerfile diff --git a/docker/blocks/nginx_proxy/docker-compose.yaml b/devenv/docker/blocks/nginx_proxy/docker-compose.yaml similarity index 88% rename from docker/blocks/nginx_proxy/docker-compose.yaml rename to devenv/docker/blocks/nginx_proxy/docker-compose.yaml index a0ceceb83ac..aefd7226f36 100644 --- a/docker/blocks/nginx_proxy/docker-compose.yaml +++ b/devenv/docker/blocks/nginx_proxy/docker-compose.yaml @@ -5,5 +5,5 @@ # root_url = %(protocol)s://%(domain)s:10080/grafana/ nginxproxy: - build: blocks/nginx_proxy + build: docker/blocks/nginx_proxy network_mode: host diff --git a/docker/blocks/nginx_proxy/htpasswd b/devenv/docker/blocks/nginx_proxy/htpasswd similarity index 100% rename from docker/blocks/nginx_proxy/htpasswd rename to devenv/docker/blocks/nginx_proxy/htpasswd diff --git a/docker/blocks/nginx_proxy/nginx.conf b/devenv/docker/blocks/nginx_proxy/nginx.conf similarity index 100% rename from docker/blocks/nginx_proxy/nginx.conf rename to devenv/docker/blocks/nginx_proxy/nginx.conf diff --git a/docker/blocks/openldap/Dockerfile b/devenv/docker/blocks/openldap/Dockerfile similarity index 100% rename from docker/blocks/openldap/Dockerfile rename to devenv/docker/blocks/openldap/Dockerfile diff --git a/docker/blocks/openldap/docker-compose.yaml b/devenv/docker/blocks/openldap/docker-compose.yaml similarity index 82% rename from docker/blocks/openldap/docker-compose.yaml rename to devenv/docker/blocks/openldap/docker-compose.yaml index be06524a57d..d11858ccfb9 100644 --- a/docker/blocks/openldap/docker-compose.yaml +++ b/devenv/docker/blocks/openldap/docker-compose.yaml @@ -1,5 +1,5 @@ openldap: - build: blocks/openldap + build: docker/blocks/openldap environment: SLAPD_PASSWORD: grafana SLAPD_DOMAIN: grafana.org diff --git a/docker/blocks/openldap/entrypoint.sh b/devenv/docker/blocks/openldap/entrypoint.sh similarity index 100% rename from docker/blocks/openldap/entrypoint.sh rename to devenv/docker/blocks/openldap/entrypoint.sh diff --git a/docker/blocks/openldap/ldap_dev.toml b/devenv/docker/blocks/openldap/ldap_dev.toml similarity index 99% rename from docker/blocks/openldap/ldap_dev.toml rename to devenv/docker/blocks/openldap/ldap_dev.toml index e79771b57de..8767ff3c64a 100644 --- a/docker/blocks/openldap/ldap_dev.toml +++ b/devenv/docker/blocks/openldap/ldap_dev.toml @@ -72,6 +72,7 @@ email = "email" [[servers.group_mappings]] group_dn = "cn=admins,ou=groups,dc=grafana,dc=org" org_role = "Admin" +grafana_admin = true # The Grafana organization database id, optional, if left out the default org (id 1) will be used # org_id = 1 diff --git a/docker/blocks/openldap/modules/memberof.ldif b/devenv/docker/blocks/openldap/modules/memberof.ldif similarity index 100% rename from docker/blocks/openldap/modules/memberof.ldif rename to devenv/docker/blocks/openldap/modules/memberof.ldif diff --git a/docker/blocks/openldap/notes.md b/devenv/docker/blocks/openldap/notes.md similarity index 100% rename from docker/blocks/openldap/notes.md rename to devenv/docker/blocks/openldap/notes.md diff --git a/docker/blocks/openldap/prepopulate.sh b/devenv/docker/blocks/openldap/prepopulate.sh similarity index 100% rename from docker/blocks/openldap/prepopulate.sh rename to devenv/docker/blocks/openldap/prepopulate.sh diff --git a/docker/blocks/openldap/prepopulate/1_units.ldif b/devenv/docker/blocks/openldap/prepopulate/1_units.ldif similarity index 100% rename from docker/blocks/openldap/prepopulate/1_units.ldif rename to devenv/docker/blocks/openldap/prepopulate/1_units.ldif diff --git a/docker/blocks/openldap/prepopulate/2_users.ldif b/devenv/docker/blocks/openldap/prepopulate/2_users.ldif similarity index 100% rename from docker/blocks/openldap/prepopulate/2_users.ldif rename to devenv/docker/blocks/openldap/prepopulate/2_users.ldif diff --git a/docker/blocks/openldap/prepopulate/3_groups.ldif b/devenv/docker/blocks/openldap/prepopulate/3_groups.ldif similarity index 100% rename from docker/blocks/openldap/prepopulate/3_groups.ldif rename to devenv/docker/blocks/openldap/prepopulate/3_groups.ldif diff --git a/docker/blocks/opentsdb/docker-compose.yaml b/devenv/docker/blocks/opentsdb/docker-compose.yaml similarity index 100% rename from docker/blocks/opentsdb/docker-compose.yaml rename to devenv/docker/blocks/opentsdb/docker-compose.yaml diff --git a/docker/blocks/postgres/docker-compose.yaml b/devenv/docker/blocks/postgres/docker-compose.yaml similarity index 100% rename from docker/blocks/postgres/docker-compose.yaml rename to devenv/docker/blocks/postgres/docker-compose.yaml diff --git a/docker/blocks/postgres_tests/Dockerfile b/devenv/docker/blocks/postgres_tests/Dockerfile similarity index 100% rename from docker/blocks/postgres_tests/Dockerfile rename to devenv/docker/blocks/postgres_tests/Dockerfile diff --git a/docker/blocks/postgres_tests/docker-compose.yaml b/devenv/docker/blocks/postgres_tests/docker-compose.yaml similarity index 80% rename from docker/blocks/postgres_tests/docker-compose.yaml rename to devenv/docker/blocks/postgres_tests/docker-compose.yaml index f5ce0a5a3d3..7e6da7d8517 100644 --- a/docker/blocks/postgres_tests/docker-compose.yaml +++ b/devenv/docker/blocks/postgres_tests/docker-compose.yaml @@ -1,6 +1,6 @@ postgrestest: build: - context: blocks/postgres_tests + context: docker/blocks/postgres_tests environment: POSTGRES_USER: grafanatest POSTGRES_PASSWORD: grafanatest diff --git a/docker/blocks/postgres_tests/setup.sql b/devenv/docker/blocks/postgres_tests/setup.sql similarity index 100% rename from docker/blocks/postgres_tests/setup.sql rename to devenv/docker/blocks/postgres_tests/setup.sql diff --git a/docker/blocks/prometheus/Dockerfile b/devenv/docker/blocks/prometheus/Dockerfile similarity index 100% rename from docker/blocks/prometheus/Dockerfile rename to devenv/docker/blocks/prometheus/Dockerfile diff --git a/docker/blocks/prometheus/alert.rules b/devenv/docker/blocks/prometheus/alert.rules similarity index 100% rename from docker/blocks/prometheus/alert.rules rename to devenv/docker/blocks/prometheus/alert.rules diff --git a/docker/blocks/prometheus/docker-compose.yaml b/devenv/docker/blocks/prometheus/docker-compose.yaml similarity index 86% rename from docker/blocks/prometheus/docker-compose.yaml rename to devenv/docker/blocks/prometheus/docker-compose.yaml index 3c304cc74ad..db778060dde 100644 --- a/docker/blocks/prometheus/docker-compose.yaml +++ b/devenv/docker/blocks/prometheus/docker-compose.yaml @@ -1,5 +1,5 @@ prometheus: - build: blocks/prometheus + build: docker/blocks/prometheus network_mode: host ports: - "9090:9090" @@ -25,7 +25,7 @@ - "9093:9093" prometheus-random-data: - build: blocks/prometheus_random_data + build: docker/blocks/prometheus_random_data network_mode: host ports: - "8081:8080" diff --git a/docker/blocks/prometheus/prometheus.yml b/devenv/docker/blocks/prometheus/prometheus.yml similarity index 100% rename from docker/blocks/prometheus/prometheus.yml rename to devenv/docker/blocks/prometheus/prometheus.yml diff --git a/docker/blocks/prometheus2/Dockerfile b/devenv/docker/blocks/prometheus2/Dockerfile similarity index 100% rename from docker/blocks/prometheus2/Dockerfile rename to devenv/docker/blocks/prometheus2/Dockerfile diff --git a/docker/blocks/prometheus2/alert.rules b/devenv/docker/blocks/prometheus2/alert.rules similarity index 100% rename from docker/blocks/prometheus2/alert.rules rename to devenv/docker/blocks/prometheus2/alert.rules diff --git a/docker/blocks/prometheus2/docker-compose.yaml b/devenv/docker/blocks/prometheus2/docker-compose.yaml similarity index 85% rename from docker/blocks/prometheus2/docker-compose.yaml rename to devenv/docker/blocks/prometheus2/docker-compose.yaml index 589df868084..d586b4b5742 100644 --- a/docker/blocks/prometheus2/docker-compose.yaml +++ b/devenv/docker/blocks/prometheus2/docker-compose.yaml @@ -1,5 +1,5 @@ prometheus: - build: blocks/prometheus2 + build: docker/blocks/prometheus2 network_mode: host ports: - "9090:9090" @@ -25,7 +25,7 @@ - "9093:9093" prometheus-random-data: - build: blocks/prometheus_random_data + build: docker/blocks/prometheus_random_data network_mode: host ports: - "8081:8080" diff --git a/docker/blocks/prometheus2/prometheus.yml b/devenv/docker/blocks/prometheus2/prometheus.yml similarity index 100% rename from docker/blocks/prometheus2/prometheus.yml rename to devenv/docker/blocks/prometheus2/prometheus.yml diff --git a/docker/blocks/prometheus_mac/Dockerfile b/devenv/docker/blocks/prometheus_mac/Dockerfile similarity index 100% rename from docker/blocks/prometheus_mac/Dockerfile rename to devenv/docker/blocks/prometheus_mac/Dockerfile diff --git a/docker/blocks/prometheus_mac/alert.rules b/devenv/docker/blocks/prometheus_mac/alert.rules similarity index 100% rename from docker/blocks/prometheus_mac/alert.rules rename to devenv/docker/blocks/prometheus_mac/alert.rules diff --git a/docker/blocks/prometheus_mac/docker-compose.yaml b/devenv/docker/blocks/prometheus_mac/docker-compose.yaml similarity index 82% rename from docker/blocks/prometheus_mac/docker-compose.yaml rename to devenv/docker/blocks/prometheus_mac/docker-compose.yaml index ef53b07418a..b73d278fae2 100644 --- a/docker/blocks/prometheus_mac/docker-compose.yaml +++ b/devenv/docker/blocks/prometheus_mac/docker-compose.yaml @@ -1,5 +1,5 @@ prometheus: - build: blocks/prometheus_mac + build: docker/blocks/prometheus_mac ports: - "9090:9090" @@ -21,6 +21,6 @@ - "9093:9093" prometheus-random-data: - build: blocks/prometheus_random_data + build: docker/blocks/prometheus_random_data ports: - "8081:8080" diff --git a/docker/blocks/prometheus_mac/prometheus.yml b/devenv/docker/blocks/prometheus_mac/prometheus.yml similarity index 100% rename from docker/blocks/prometheus_mac/prometheus.yml rename to devenv/docker/blocks/prometheus_mac/prometheus.yml diff --git a/docker/blocks/prometheus_random_data/Dockerfile b/devenv/docker/blocks/prometheus_random_data/Dockerfile similarity index 100% rename from docker/blocks/prometheus_random_data/Dockerfile rename to devenv/docker/blocks/prometheus_random_data/Dockerfile diff --git a/docker/blocks/smtp/Dockerfile b/devenv/docker/blocks/smtp/Dockerfile similarity index 100% rename from docker/blocks/smtp/Dockerfile rename to devenv/docker/blocks/smtp/Dockerfile diff --git a/docker/blocks/smtp/bootstrap.sh b/devenv/docker/blocks/smtp/bootstrap.sh similarity index 100% rename from docker/blocks/smtp/bootstrap.sh rename to devenv/docker/blocks/smtp/bootstrap.sh diff --git a/docker/blocks/smtp/docker-compose.yaml b/devenv/docker/blocks/smtp/docker-compose.yaml similarity index 100% rename from docker/blocks/smtp/docker-compose.yaml rename to devenv/docker/blocks/smtp/docker-compose.yaml diff --git a/docker/buildcontainer/Dockerfile b/devenv/docker/buildcontainer/Dockerfile similarity index 100% rename from docker/buildcontainer/Dockerfile rename to devenv/docker/buildcontainer/Dockerfile diff --git a/docker/buildcontainer/build.sh b/devenv/docker/buildcontainer/build.sh similarity index 100% rename from docker/buildcontainer/build.sh rename to devenv/docker/buildcontainer/build.sh diff --git a/docker/buildcontainer/build_circle.sh b/devenv/docker/buildcontainer/build_circle.sh similarity index 100% rename from docker/buildcontainer/build_circle.sh rename to devenv/docker/buildcontainer/build_circle.sh diff --git a/docker/buildcontainer/run_circle.sh b/devenv/docker/buildcontainer/run_circle.sh similarity index 100% rename from docker/buildcontainer/run_circle.sh rename to devenv/docker/buildcontainer/run_circle.sh diff --git a/docker/compose_header.yml b/devenv/docker/compose_header.yml similarity index 100% rename from docker/compose_header.yml rename to devenv/docker/compose_header.yml diff --git a/docker/debtest/Dockerfile b/devenv/docker/debtest/Dockerfile similarity index 100% rename from docker/debtest/Dockerfile rename to devenv/docker/debtest/Dockerfile diff --git a/docker/debtest/build.sh b/devenv/docker/debtest/build.sh similarity index 100% rename from docker/debtest/build.sh rename to devenv/docker/debtest/build.sh 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..78f98ab8dc5 --- /dev/null +++ b/devenv/docker/ha_test/docker-compose.yaml @@ -0,0 +1,57 @@ +version: "2.1" + +services: + nginx-proxy: + image: jwilder/nginx-proxy + ports: + - "80:80" + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + + mysql: + image: mysql + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: grafana + MYSQL_USER: grafana + MYSQL_PASSWORD: password + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] + 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_TYPE=mysql + - GF_DATABASE_HOST=mysql:3306 + - GF_DATABASE_NAME=grafana + - GF_DATABASE_USER=grafana + - GF_DATABASE_PASSWORD=password + - GF_SESSION_PROVIDER=mysql + - GF_SESSION_PROVIDER_CONFIG=grafana:password@tcp(mysql:3306)/grafana?allowNativePasswords=true + ports: + - 3000 + depends_on: + mysql: + 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 \ No newline at end of file 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/docker/rpmtest/build.sh b/devenv/docker/rpmtest/build.sh similarity index 100% rename from docker/rpmtest/build.sh rename to devenv/docker/rpmtest/build.sh diff --git a/tests/api/clearState.test.ts b/devenv/e2e-api-tests/clearState.test.ts similarity index 100% rename from tests/api/clearState.test.ts rename to devenv/e2e-api-tests/clearState.test.ts diff --git a/tests/api/client.ts b/devenv/e2e-api-tests/client.ts similarity index 100% rename from tests/api/client.ts rename to devenv/e2e-api-tests/client.ts diff --git a/tests/api/dashboard.test.ts b/devenv/e2e-api-tests/dashboard.test.ts similarity index 100% rename from tests/api/dashboard.test.ts rename to devenv/e2e-api-tests/dashboard.test.ts diff --git a/tests/api/folder.test.ts b/devenv/e2e-api-tests/folder.test.ts similarity index 100% rename from tests/api/folder.test.ts rename to devenv/e2e-api-tests/folder.test.ts diff --git a/tests/api/jest.js b/devenv/e2e-api-tests/jest.js similarity index 100% rename from tests/api/jest.js rename to devenv/e2e-api-tests/jest.js diff --git a/tests/api/search.test.ts b/devenv/e2e-api-tests/search.test.ts similarity index 100% rename from tests/api/search.test.ts rename to devenv/e2e-api-tests/search.test.ts diff --git a/tests/api/setup.ts b/devenv/e2e-api-tests/setup.ts similarity index 100% rename from tests/api/setup.ts rename to devenv/e2e-api-tests/setup.ts diff --git a/tests/api/tsconfig.json b/devenv/e2e-api-tests/tsconfig.json similarity index 100% rename from tests/api/tsconfig.json rename to devenv/e2e-api-tests/tsconfig.json diff --git a/tests/api/user.test.ts b/devenv/e2e-api-tests/user.test.ts similarity index 100% rename from tests/api/user.test.ts rename to devenv/e2e-api-tests/user.test.ts 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 c8d83ea1c54..16d425d289a 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -71,6 +71,7 @@ Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/pupp Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana) Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana) Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana) +Jsonnet | [https://github.com/grafana/grafonnet-lib/](https://github.com/grafana/grafonnet-lib/) ## Datasources @@ -199,7 +200,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/generic-oauth.md b/docs/sources/auth/generic-oauth.md index bec5a98e04a..5bb5c4cd753 100644 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -32,7 +32,14 @@ allowed_domains = mycompany.com mycompany.org allow_sign_up = true ``` -Set api_url to the resource that returns [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo) compatible information. +Set `api_url` to the resource that returns [OpenID UserInfo](https://connect2id.com/products/server/docs/api/userinfo) compatible information. + +Grafana will attempt to determine the user's e-mail address by querying the OAuth provider as described below in the following order until an e-mail address is found: + +1. Check for the presence of an e-mail address via the `email` field encoded in the OAuth `id_token` parameter. +2. Check for the presence of an e-mail address in the `attributes` map encoded in the OAuth `id_token` parameter. By default Grafana will perform a lookup into the attributes map using the `email:primary` key, however, this is configurable and can be adjusted by using the `email_attribute_name` configuration option. +3. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`) and check for the presence of an e-mail address marked as a primary address. +4. If no e-mail address is found in steps (1-3), then the e-mail address of the user is set to the empty string. ## Set up OAuth2 with Okta @@ -167,6 +174,38 @@ allowed_organizations = allowed_organizations = ``` +> Note: It's important to ensure that the [root_url](/installation/configuration/#root-url) in Grafana is set in your Azure Application Reply URLs (App -> Settings -> Reply URLs) + +## Set up OAuth2 with Centrify + +1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard. + +2. Create a memorable unique Application ID, e.g. "grafana", "grafana_aws", etc. + +3. Put in other basic configuration (name, description, logo, category) + +4. On the Trust tab, generate a long password and put it into the OpenID Connect Client Secret field. + +5. Put the URL to the front page of your Grafana instance into the "Resource Application URL" field. + +6. Add an authorized Redirect URI like https://your-grafana-server/login/generic_oauth + +7. Set up permissions, policies, etc. just like any other Centrify app + +8. Configure Grafana as follows: + + ```bash + [auth.generic_oauth] + name = Centrify + enabled = true + allow_sign_up = true + client_id = + client_secret = .my.centrify.com/OAuth2/Authorize/ + token_url = https://.my.centrify.com/OAuth2/Token/ + ``` +
diff --git a/docs/sources/auth/ldap.md b/docs/sources/auth/ldap.md index f63a44e1750..82db8214fb7 100644 --- a/docs/sources/auth/ldap.md +++ b/docs/sources/auth/ldap.md @@ -1,7 +1,7 @@ +++ title = "LDAP Authentication" description = "Grafana LDAP Authentication Guide " -keywords = ["grafana", "configuration", "documentation", "ldap"] +keywords = ["grafana", "configuration", "documentation", "ldap", "active directory"] type = "docs" [menu.docs] name = "LDAP" @@ -10,35 +10,42 @@ parent = "authentication" weight = 2 +++ -# LDAP +# LDAP Authentication The LDAP integration in Grafana allows your Grafana users to login with their LDAP credentials. You can also specify mappings between LDAP -group memberships and Grafana Organization user roles. Below we detail grafana.ini config file -settings and ldap.toml config file options. +group memberships and Grafana Organization user roles. + +## Supported LDAP Servers + +Grafana uses a [third-party LDAP library](https://github.com/go-ldap/ldap) under the hood that supports basic LDAP v3 functionality. +This means that you should be able to configure LDAP integration using any compliant LDAPv3 server, for example [OpenLDAP](#openldap) or +[Active Directory](#active-directory) among [others](https://en.wikipedia.org/wiki/Directory_service#LDAP_implementations). ## Enable LDAP -You turn on LDAP in the [main config file]({{< relref "installation/configuration.md" >}}) as well as specify the path to the LDAP +In order to use LDAP integration you'll first need to enable LDAP in the [main config file]({{< relref "installation/configuration.md" >}}) as well as specify the path to the LDAP specific configuration file (default: `/etc/grafana/ldap.toml`). ```bash [auth.ldap] # Set to `true` to enable LDAP integration (default: `false`) enabled = true + # Path to the LDAP specific configuration file (default: `/etc/grafana/ldap.toml`) -config_file = /etc/grafana/ldap.toml` +config_file = /etc/grafana/ldap.toml + # Allow sign up should almost always be true (default) to allow new Grafana users to be created (if ldap authentication is ok). If set to # false only pre-existing Grafana users will be able to login (if ldap authentication is ok). allow_sign_up = true ``` -## LDAP Configuration +## Grafana LDAP Configuration +Depending on which LDAP server you're using and how that's configured your Grafana LDAP configuration may vary. +See [configuration examples](#configuration-examples) for more information. + +**LDAP specific configuration file (ldap.toml) example:** ```bash -# To troubleshoot and get more log info enable ldap debug logging in grafana.ini -# [log] -# filters = ldap:debug - [[servers]] # Ldap server host (specify multiple hosts space separated) host = "127.0.0.1" @@ -69,13 +76,8 @@ search_filter = "(cn=%s)" # An array of base dns to search through search_base_dns = ["dc=grafana,dc=org"] -# In POSIX LDAP schemas, without memberOf attribute a secondary query must be made for groups. -# This is done by enabling group_search_filter below. You must also set member_of= "cn" -# in [servers.attributes] below. - -## Group search filter, to retrieve the groups of which the user is a member (only set if memberOf attribute is not available) # group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))" -## An array of the base DNs to search through for groups. Typically uses ou=groups +# group_search_filter_user_attribute = "distinguishedName" # group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] # Specify names of the ldap attributes your ldap uses @@ -85,28 +87,11 @@ surname = "sn" username = "cn" member_of = "memberOf" email = "email" - -# Map ldap groups to grafana org roles -[[servers.group_mappings]] -group_dn = "cn=admins,dc=grafana,dc=org" -org_role = "Admin" -# To make user an instance admin (Grafana Admin) uncomment line below -# grafana_admin = true -# The Grafana organization database id, optional, if left out the default org (id 1) will be used. Setting this allows for multiple group_dn's to be assigned to the same org_role provided the org_id differs -# org_id = 1 - -[[servers.group_mappings]] -group_dn = "cn=users,dc=grafana,dc=org" -org_role = "Editor" - -[[servers.group_mappings]] -# If you want to match all (or no ldap groups) then you can use wildcard -group_dn = "*" -org_role = "Viewer" - ``` -## Bind & Bind Password +### Bind + +#### Bind & Bind Password By default the configuration expects you to specify a bind DN and bind password. This should be a read only user that can perform LDAP searches. When the user DN is found a second bind is performed with the user provided username & password (in the normal Grafana login form). @@ -116,7 +101,7 @@ bind_dn = "cn=admin,dc=grafana,dc=org" bind_password = "grafana" ``` -### Single Bind Example +#### Single Bind Example If you can provide a single bind expression that matches all possible users, you can skip the second bind and bind against the user DN directly. This allows you to not specify a bind_password in the configuration file. @@ -128,7 +113,7 @@ bind_dn = "cn=%s,o=users,dc=grafana,dc=org" In this case you skip providing a `bind_password` and instead provide a `bind_dn` value with a `%s` somewhere. This will be replaced with the username entered in on the Grafana login page. The search filter and search bases settings are still needed to perform the LDAP search to retrieve the other LDAP information (like LDAP groups and email). -## POSIX schema (no memberOf attribute) +### POSIX schema If your ldap server does not support the memberOf attribute add these options: ```bash @@ -136,27 +121,140 @@ If your ldap server does not support the memberOf attribute add these options: group_search_filter = "(&(objectClass=posixGroup)(memberUid=%s))" ## An array of the base DNs to search through for groups. Typically uses ou=groups group_search_base_dns = ["ou=groups,dc=grafana,dc=org"] +## the %s in the search filter will be replaced with the attribute defined below +group_search_filter_user_attribute = "uid" ``` -Also change set `member_of = "cn"` in the `[servers.attributes]` section. +Also set `member_of = "dn"` in the `[servers.attributes]` section. +### Group Mappings -## LDAP to Grafana Org Role Sync +In `[[servers.group_mappings]]` you can map an LDAP group to a Grafana organization and role. These will be synced every time the user logs in, with LDAP being +the authoritative source. So, if you change a user's role in the Grafana Org. Users page, this change will be reset the next time the user logs in. If you +change the LDAP groups of a user, the change will take effect the next time the user logs in. -### Mappings -In `[[servers.group_mappings]]` you can map an LDAP group to a Grafana organization -and role. These will be synced every time the user logs in, with LDAP being -the authoritative source. So, if you change a user's role in the Grafana Org. -Users page, this change will be reset the next time the user logs in. If you -change the LDAP groups of a user, the change will take effect the next -time the user logs in. +The first group mapping that an LDAP user is matched to will be used for the sync. If you have LDAP users that fit multiple mappings, the topmost mapping in the +TOML config will be used. -### Grafana Admin -with a servers.group_mappings section you can set grafana_admin = true or false to sync Grafana Admin permission. A Grafana server admin has admin access over all orgs & -users. +**LDAP specific configuration file (ldap.toml) example:** +```bash +[[servers]] +# other settings omitted for clarity -### Priority -The first group mapping that an LDAP user is matched to will be used for the sync. If you have LDAP users that fit multiple mappings, the topmost mapping in the TOML config will be used. +[[servers.group_mappings]] +group_dn = "cn=superadmins,dc=grafana,dc=org" +org_role = "Admin" +grafana_admin = true # Available in Grafana v5.3 and above +[[servers.group_mappings]] +group_dn = "cn=admins,dc=grafana,dc=org" +org_role = "Admin" +[[servers.group_mappings]] +group_dn = "cn=users,dc=grafana,dc=org" +org_role = "Editor" +[[servers.group_mappings]] +group_dn = "*" +org_role = "Viewer" +``` + +Setting | Required | Description | Default +------------ | ------------ | ------------- | ------------- +`group_dn` | Yes | LDAP distinguished name (DN) of LDAP group. If you want to match all (or no LDAP groups) then you can use wildcard (`"*"`) | +`org_role` | Yes | Assign users of `group_dn` the organisation role `"Admin"`, `"Editor"` or `"Viewer"` | +`org_id` | No | The Grafana organization database id. Setting this allows for multiple group_dn's to be assigned to the same `org_role` provided the `org_id` differs | `1` (default org id) +`grafana_admin` | No | When `true` makes user of `group_dn` Grafana server admin. A Grafana server admin has admin access over all organisations and users. Available in Grafana v5.3 and above | `false` + +### Nested/recursive group membership + +Users with nested/recursive group membership must have an LDAP server that supports `LDAP_MATCHING_RULE_IN_CHAIN` +and configure `group_search_filter` in a way that it returns the groups the submitted username is a member of. + +**Active Directory example:** + +Active Directory groups store the Distinguished Names (DNs) of members, so your filter will need to know the DN for the user based only on the submitted username. +Multiple DN templates can be searched by combining filters with the LDAP OR-operator. Examples: + +```bash +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 troubleshooting, by changing `member_of` in `[servers.attributes]` to "dn" it will show you more accurate group memberships when [debug is enabled](#troubleshooting). + +## Configuration examples + +### OpenLDAP + +[OpenLDAP](http://www.openldap.org/) is an open source directory service. + +**LDAP specific configuration file (ldap.toml):** +```bash +[[servers]] +host = "127.0.0.1" +port = 389 +use_ssl = false +start_tls = false +ssl_skip_verify = false +bind_dn = "cn=admin,dc=grafana,dc=org" +bind_password = 'grafana' +search_filter = "(cn=%s)" +search_base_dns = ["dc=grafana,dc=org"] + +[servers.attributes] +name = "givenName" +surname = "sn" +username = "cn" +member_of = "memberOf" +email = "email" + +# [[servers.group_mappings]] omitted for clarity +``` + +### Active Directory + +[Active Directory](https://technet.microsoft.com/en-us/library/hh831484(v=ws.11).aspx) is a directory service which is commonly used in Windows environments. + +Assuming the following Active Directory server setup: + +* IP address: `10.0.0.1` +* Domain: `CORP` +* DNS name: `corp.local` + +**LDAP specific configuration file (ldap.toml):** +```bash +[[servers]] +host = "10.0.0.1" +port = 3269 +use_ssl = true +start_tls = false +ssl_skip_verify = true +bind_dn = "CORP\\%s" +search_filter = "(sAMAccountName=%s)" +search_base_dns = ["dc=corp,dc=local"] + +[servers.attributes] +name = "givenName" +surname = "sn" +username = "sAMAccountName" +member_of = "memberOf" +email = "mail" + +# [[servers.group_mappings]] omitted for clarity +``` + +#### Port requirements + +In above example SSL is enabled and an encrypted port have been configured. If your Active Directory don't support SSL please change `enable_ssl = false` and `port = 389`. +Please inspect your Active Directory configuration and documentation to find the correct settings. For more information about Active Directory and port requirements see [link](https://technet.microsoft.com/en-us/library/dd772723(v=ws.10)). + +## Troubleshooting + +To troubleshoot and get more log info enable ldap debug logging in the [main config file]({{< relref "installation/configuration.md" >}}). + +```bash +[log] +filters = ldap:debug +``` diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index 3a38ed83988..20010a9ac09 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -32,11 +32,11 @@ permissions and org memberships. ## Grafana Auth -Grafana of course has a built in user authentication system with password authenticaten enabled by default. You can +Grafana of course has a built in user authentication system with password authentication enabled by default. You can disable authentication by enabling anonymous access. You can also hide login form and only allow login through an auth provider (listed above). There is also options for allowing self sign up. -### Anonymous authenticaten +### Anonymous authentication You can make Grafana accessible without any login required by enabling anonymous access in the configuration file. @@ -84,4 +84,3 @@ Set to the option detailed below to true to hide sign-out menu link. Useful if y [auth] disable_signout_menu = true ``` - diff --git a/docs/sources/features/datasources/mssql.md b/docs/sources/features/datasources/mssql.md index 6bfcfd807f1..debf771ffb0 100644 --- a/docs/sources/features/datasources/mssql.md +++ b/docs/sources/features/datasources/mssql.md @@ -174,6 +174,8 @@ The resulting table panel: If you set `Format as` to `Time series`, for use in Graph panel for example, then the query must must have a column named `time` that returns either a sql datetime or any numeric datatype representing unix epoch in seconds. You may return a column named `metric` that is used as metric name for the value column. Any column except `time` and `metric` is treated as a value column. If you omit the `metric` column, the name of the value column will be the metric name. You may select multiple value columns, each will have its name as metric. If you return multiple value columns and a column named `metric` then this column is used as prefix for the series name (only available in Grafana 5.3+). +Resultsets of time series queries need to be sorted by time. + **Example database table:** ```sql diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md index e13abcf80a2..d713a4b42b7 100644 --- a/docs/sources/features/datasources/mysql.md +++ b/docs/sources/features/datasources/mysql.md @@ -129,6 +129,8 @@ Any column except `time` and `metric` is treated as a value column. You may return a column named `metric` that is used as metric name for the value column. If you return multiple value columns and a column named `metric` then this column is used as prefix for the series name (only available in Grafana 5.3+). +Resultsets of time series queries need to be sorted by time. + **Example with `metric` column:** ```sql diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md index 013d6342634..7076ff033b3 100644 --- a/docs/sources/features/datasources/postgres.md +++ b/docs/sources/features/datasources/postgres.md @@ -16,7 +16,7 @@ Grafana ships with a built-in PostgreSQL data source plugin that allows you to q ## Adding the data source 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`. +2. In the side menu under the `Configuration` icon you should find a link named `Data Sources`. 3. Click the `+ Add data source` button in the top header. 4. Select *PostgreSQL* from the *Type* dropdown. @@ -57,7 +57,7 @@ Identifier | Description The database user you specify when you add the data source should only be granted SELECT permissions on the specified database & tables you want to query. Grafana does not validate that the query is safe. The query could include any SQL statement. For example, statements like `DELETE FROM user;` and `DROP TABLE user;` would be -executed. To protect against this we **Highly** recommmend you create a specific postgresql user with restricted permissions. +executed. To protect against this we **highly** recommend you create a specific PostgreSQL user with restricted permissions. Example: @@ -69,9 +69,72 @@ Example: Make sure the user does not get any unwanted privileges from the public role. +## Query Editor + +> Only available in Grafana v5.3+. + +{{< docs-imagebox img="/img/docs/v53/postgres_query_still.png" class="docs-image--no-shadow" animated-gif="/img/docs/v53/postgres_query.gif" >}} + +You find the PostgreSQL query editor in the metrics tab in Graph or Singlestat panel's edit mode. You enter edit mode by clicking the +panel title, then edit. + +The query editor has a link named `Generated SQL` that shows up after a query has been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed. + +### Select table, time column and metric column (FROM) + +When you enter edit mode for the first time or add a new query Grafana will try to prefill the query builder with the first table that has a timestamp column and a numeric column. + +In the FROM field, Grafana will suggest tables that are in the `search_path` of the database user. To select a table or view not in your `search_path` +you can manually enter a fully qualified name (schema.table) like `public.metrics`. + +The Time column field refers to the name of the column holding your time values. Selecting a value for the Metric column field is optional. If a value is selected, the Metric column field will be used as the series name. + +The metric column suggestions will only contain columns with a text datatype (char,varchar,text). +If you want to use a column with a different datatype as metric column you may enter the column name with a cast: `ip::text`. +You may also enter arbitrary SQL expressions in the metric column field that evaluate to a text datatype like +`hostname || ' ' || container_name`. + +### Columns, Window and Aggregation functions (SELECT) + +In the `SELECT` row you can specify what columns and functions you want to use. +In the column field you may write arbitrary expressions instead of a column name like `column1 * column2 / column3`. + +The available functions in the query editor depend on the PostgreSQL version you selected when configuring the datasource. +If you use aggregate functions you need to group your resultset. The editor will automatically add a `GROUP BY time` if you add an aggregate function. + +The editor tries to simplify and unify this part of the query. For example:
+![](/img/docs/v53/postgres_select_editor.png)
+ +The above will generate the following PostgreSQL `SELECT` clause: + +```sql +avg(tx_bytes) OVER (ORDER BY "time" ROWS 5 PRECEDING) AS "tx_bytes" +``` + +You may add further value columns by clicking the plus button and selecting `Column` from the menu. Multiple value columns will be plotted as separate series in the graph panel. + +### Filter data (WHERE) +To add a filter click the plus icon to the right of the `WHERE` condition. You can remove filters by clicking on +the filter and selecting `Remove`. A filter for the current selected timerange is automatically added to new queries. + +### Group By +To group by time or any other columns click the plus icon at the end of the GROUP BY row. The suggestion dropdown will only show text columns of your currently selected table but you may manually enter any column. +You can remove the group by clicking on the item and then selecting `Remove`. + +If you add any grouping, all selected columns need to have an aggregate function applied. The query builder will automatically add aggregate functions to all columns without aggregate functions when you add groupings. + +#### Gap Filling + +Grafana can fill in missing values when you group by time. The time function accepts two arguments. The first argument is the time window that you would like to group by, and the second argument is the value you want Grafana to fill missing items with. + +### Text Editor Mode (RAW) +You can switch to the raw query editor mode by clicking the hamburger icon and selecting `Switch editor mode` or by clicking `Edit SQL` below the query. + +> If you use the raw query editor, be sure your query at minimum has `ORDER BY time` and a filter on the returned time range. + ## Macros -To simplify syntax and to allow for dynamic parts, like date range filters, the query can contain macros. +Macros can be used within a query to simplify syntax and allow for dynamic parts. Macro example | Description ------------ | ------------- @@ -80,21 +143,19 @@ Macro example | Description *$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:06:17Z'* *$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *'2017-04-21T05:01:17Z'* *$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *'2017-04-21T05:06:17Z'* -*$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *(extract(epoch from dateColumn)/300)::bigint*300* -*$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so missing points in that series will be added by grafana and 0 will be used as value. +*$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in a GROUP BY clause. For example, *(extract(epoch from dateColumn)/300)::bigint*300* +*$__timeGroup(dateColumn,'5m', 0)* | Same as above but with a fill parameter so missing points in that series will be added by Grafana and 0 will be used as the value. *$__timeGroup(dateColumn,'5m', NULL)* | Same as above but NULL will be used as value for missing points. -*$__timeGroup(dateColumn,'5m', previous)* | Same as above but the previous value in that series will be used as fill value if no value has been seen yet NULL will be used (only available in Grafana 5.3+). -*$__timeGroupAlias(dateColumn,'5m')* | Will be replaced identical to $__timeGroup but with an added column alias (only available in Grafana 5.3+). -*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183* +*$__timeGroup(dateColumn,'5m', previous)* | Same as above but the previous value in that series will be used as fill value. If no value has been seen yet, NULL will be used (only available in Grafana 5.3+). +*$__timeGroupAlias(dateColumn,'5m')* | Will be replaced with an expression identical to $__timeGroup, but with an added column alias (only available in Grafana 5.3+). +*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamps. For example, *dateColumn >= 1494410783 AND dateColumn <= 1494497183* *$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* *$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* -*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup but for times stored as unix timestamp (only available in Grafana 5.3+). -*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above but also adds a column alias (only available in Grafana 5.3+). +*$__unixEpochGroup(dateColumn,'5m', [fillmode])* | Same as $__timeGroup, but for times stored as unix timestamp (only available in Grafana 5.3+). +*$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])* | Same as above, but also adds a column alias (only available in Grafana 5.3+). We plan to add many more macros. If you have suggestions for what macros you would like to see, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo. -The query editor has a link named `Generated SQL` that shows up after a query as been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed. - ## Table queries If the `Format as` query option is set to `Table` then you can basically do any type of SQL query. The table panel will automatically show the results of whatever columns & rows your query returns. @@ -124,11 +185,13 @@ The resulting table panel: ## Time series queries -If you set `Format as` to `Time series`, for use in Graph panel for example, then the query must return a column named `time` that returns either a sql datetime or any numeric datatype representing unix epoch. -Any column except `time` and `metric` is treated as a value column. +If you set `Format as` to `Time series`, for use in Graph panel for example, then the query must return a column named `time` that returns either a SQL datetime or any numeric datatype representing unix epoch. +Any column except `time` and `metric` are treated as a value column. You may return a column named `metric` that is used as metric name for the value column. If you return multiple value columns and a column named `metric` then this column is used as prefix for the series name (only available in Grafana 5.3+). +Resultsets of time series queries need to be sorted by time. + **Example with `metric` column:** ```sql @@ -204,7 +267,7 @@ Another option is a query that can create a key/value variable. The query should SELECT hostname AS __text, id AS __value FROM host ``` -You can also create nested variables. For example if you had another variable named `region`. Then you could have +You can also create nested variables. Using a variable named `region`, you could have the hosts variable only show hosts from the current selected region with a query like this (if `region` is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values): ```sql @@ -213,7 +276,7 @@ SELECT hostname FROM host WHERE region IN($region) ### Using Variables in Queries -From Grafana 4.3.0 to 4.6.0, template variables are always quoted automatically so if it is a string value do not wrap them in quotes in where clauses. +From Grafana 4.3.0 to 4.6.0, template variables are always quoted automatically. If your template variables are strings, do not wrap them in quotes in where clauses. From Grafana 4.7.0, template variable values are only quoted when the template variable is a `multi-value`. @@ -245,7 +308,7 @@ ORDER BY atimestamp ASC #### Disabling Quoting for Multi-value Variables -Grafana automatically creates a quoted, comma-separated string for multi-value variables. For example: if `server01` and `server02` are selected then it will be formatted as: `'server01', 'server02'`. Do disable quoting, use the csv formatting option for variables: +Grafana automatically creates a quoted, comma-separated string for multi-value variables. For example: if `server01` and `server02` are selected then it will be formatted as: `'server01', 'server02'`. To disable quoting, use the csv formatting option for variables: `${servers:csv}` @@ -289,7 +352,7 @@ tags | Optional field name to use for event tags as a comma separated string. ## Alerting -Time series queries should work in alerting conditions. Table formatted queries is not yet supported in alert rule +Time series queries should work in alerting conditions. Table formatted queries are not yet supported in alert rule conditions. ## Configure the Datasource with Provisioning diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md new file mode 100644 index 00000000000..890972376c2 --- /dev/null +++ b/docs/sources/features/datasources/stackdriver.md @@ -0,0 +1,168 @@ ++++ +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 + +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. It is only available in Grafana 5.3+. The datasource is currently a beta feature and is subject to change. + +## 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/getting_started.md b/docs/sources/guides/getting_started.md index a27c6ca4c99..27957990265 100644 --- a/docs/sources/guides/getting_started.md +++ b/docs/sources/guides/getting_started.md @@ -69,7 +69,7 @@ The image above shows you the top header for a Dashboard. ## Dashboards, Panels, the building blocks of Grafana... -Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a grid. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, Prometheus and Cloudwatch). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail. +Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a grid. Grafana ships with a variety of Panels. Grafana makes it easy to construct the right queries, and customize the display properties so that you can create the perfect Dashboard for your need. Each Panel can interact with data from any configured Grafana Data Source (currently Graphite, Prometheus, Elasticsearch, InfluxDB, OpenTSDB, MySQL, PostgreSQL, Microsoft SQL Server and AWS Cloudwatch). The [Basic Concepts](/guides/basic_concepts) guide explores these key ideas in detail. 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/guides/whats-new-in-v5-3.md b/docs/sources/guides/whats-new-in-v5-3.md new file mode 100644 index 00000000000..4a2674c9b39 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v5-3.md @@ -0,0 +1,18 @@ ++++ +title = "What's New in Grafana v5.3" +description = "Feature & improvement highlights for Grafana v5.3" +keywords = ["grafana", "new", "documentation", "5.3"] +type = "docs" +[menu.docs] +name = "Version 5.3" +identifier = "v5.3" +parent = "whatsnew" +weight = -9 ++++ + +# What's New in Grafana v5.3 + +## Changelog + +Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. diff --git a/docs/sources/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/reference/annotations.md b/docs/sources/reference/annotations.md index bfc104ef522..3bb50f4badf 100644 --- a/docs/sources/reference/annotations.md +++ b/docs/sources/reference/annotations.md @@ -45,8 +45,9 @@ can still show them if you add a new **Annotation Query** and filter by tags. Bu ### Query by tag You can create new annotation queries that fetch annotations from the native annotation store via the `-- Grafana --` data source and by setting *Filter by* to `Tags`. Specify at least -one tag. For example create an annotation query name `outages` and specify a tag named `outage`. This query will show all annotations you create (from any dashboard or via API) that -have the `outage` tag. +one tag. For example create an annotation query name `outages` and specify a tag named `outage`. This query will show all annotations you create (from any dashboard or via API) that have the `outage` tag. By default, if you add multiple tags in the annotation query, Grafana will only show annotations that have all the tags you supplied. You can invert the behavior by enabling `Match any` which means that Grafana will show annotations that contains at least one of the tags you supplied. + +In 5.4+ it's possible to use template variables in the tag query. So if you have a dashboard showing stats for different services and an template variable that dictates which services to show, you can now use the same template variable in your annotation query to only show annotations for those services. ## Querying other data sources 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/examples/README.md b/examples/README.md deleted file mode 100644 index 75f1f9a9a86..00000000000 --- a/examples/README.md +++ /dev/null @@ -1,5 +0,0 @@ -## Example plugin implementations - -datasource:[simple-json-datasource](https://github.com/grafana/simple-json-datasource) -app: [example-app](https://github.com/grafana/example-app) -panel: [grafana-piechart-panel](https://github.com/grafana/piechart-panel) diff --git a/examples/alerting-dashboard.json b/examples/alerting-dashboard.json deleted file mode 100644 index 744460d7847..00000000000 --- a/examples/alerting-dashboard.json +++ /dev/null @@ -1,800 +0,0 @@ -{ - "__inputs": [ - { - "name": "DS_GRAPHITE", - "label": "graphite", - "description": "", - "type": "datasource", - "pluginId": "graphite", - "pluginName": "Graphite" - } - ], - "__requires": [ - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "" - }, - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "3.1.0" - }, - { - "type": "datasource", - "id": "graphite", - "name": "Graphite", - "version": "1.0.0" - } - ], - "id": null, - "title": "Alerting example", - "tags": [], - "style": "dark", - "timezone": "browser", - "editable": true, - "hideControls": false, - "sharedCrosshair": false, - "rows": [ - { - "collapse": false, - "editable": true, - "height": "250px", - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 355 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Critical alert panel", - "notifications": [], - "severity": "critical" - }, - "alerting": {}, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "grid": {}, - "id": 1, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 4, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - }, - { - "refId": "B", - "target": "aliasByNode(scale(statsd.$apa.counters.session_start.*.count, 10), 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "line": true, - "op": "gt", - "value": 355 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 20 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Warning panel alert", - "notifications": [], - "severity": "warning" - }, - "alerting": {}, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 2, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 4, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "warning", - "fill": true, - "fillColor": "rgba(235, 138, 14, 0.12)", - "line": true, - "lineColor": "rgba(247, 149, 32, 0.60)", - "op": "gt", - "value": 20 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 1 - ], - "type": "lt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "count" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "No datapoints", - "notifications": [], - "severity": "critical" - }, - "alerting": {}, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 20, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 4, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "value": 1, - "op": "lt", - "fill": true, - "line": true, - "colorMode": "critical" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Count datapoints", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - } - ], - "title": "Row" - }, - { - "collapse": false, - "editable": true, - "height": "250px", - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 20 - ], - "type": "lt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Alert below value", - "notifications": [], - "severity": "critical" - }, - "alerting": {}, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 17, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "lt", - "value": 20 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Alert below value", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 10, - 80 - ], - "type": "outside_range" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "10s", - "handler": 1, - "name": "Alert is outside range", - "notifications": [], - "severity": "critical" - }, - "alerting": {}, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 18, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "lt", - "value": 10 - }, - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 80 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Alert is outside range", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 60, - 80 - ], - "type": "within_range" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "10s", - "handler": 1, - "name": "Alert is within range", - "notifications": [], - "severity": "critical" - }, - "alerting": {}, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 19, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 60 - }, - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "lt", - "value": 80 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Alert is within range", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - } - ], - "title": "New row" - } - ], - "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" - ] - }, - "templating": { - "list": [ - { - "current": { - "text": "fakesite", - "value": "fakesite" - }, - "datasource": null, - "hide": 0, - "includeAll": false, - "multi": false, - "name": "apa", - "options": [ - { - "selected": true, - "text": "fakesite", - "value": "fakesite" - } - ], - "query": "fakesite", - "refresh": 0, - "type": "custom" - } - ] - }, - "annotations": { - "list": [] - }, - "schemaVersion": 13, - "version": 15, - "links": [], - "gnetId": null -} \ No newline at end of file diff --git a/examples/alerting-multiple-alerts.json b/examples/alerting-multiple-alerts.json deleted file mode 100644 index e6e729ecc06..00000000000 --- a/examples/alerting-multiple-alerts.json +++ /dev/null @@ -1,2216 +0,0 @@ -{ - "__inputs": [ - { - "name": "DS_GRAPHITE", - "label": "graphite", - "description": "", - "type": "datasource", - "pluginId": "graphite", - "pluginName": "Graphite" - } - ], - "__requires": [ - { - "type": "panel", - "id": "graph", - "name": "Graph", - "version": "" - }, - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "3.1.0" - }, - { - "type": "datasource", - "id": "graphite", - "name": "Graphite", - "version": "1.0.0" - } - ], - "id": null, - "title": "Dashboard with many alerts", - "tags": [], - "style": "dark", - "timezone": "browser", - "editable": true, - "hideControls": false, - "sharedCrosshair": false, - "rows": [ - { - "collapse": false, - "editable": true, - "height": "250px", - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 30 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "sum" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Critical alert panel", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "grid": {}, - "id": 1, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 30 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 30 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "sum" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Critical alert panel", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "grid": {}, - "id": 5, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 30 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 30 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "sum" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Critical alert panel", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "grid": {}, - "id": 6, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 30 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 30 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "sum" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Critical alert panel", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "grid": {}, - "id": 8, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 30 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - } - ], - "title": "Row" - }, - { - "collapse": false, - "editable": true, - "height": "250px", - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 20 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Warning panel alert", - "notifications": [], - "severity": "warning" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 2, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "warning", - "fill": true, - "fillColor": "rgba(235, 138, 14, 0.12)", - "line": true, - "lineColor": "rgba(247, 149, 32, 0.60)", - "op": "gt", - "value": 20 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 20 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Warning panel alert", - "notifications": [], - "severity": "warning" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 3, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "warning", - "fill": true, - "fillColor": "rgba(235, 138, 14, 0.12)", - "line": true, - "lineColor": "rgba(247, 149, 32, 0.60)", - "op": "gt", - "value": 20 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 20 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Warning panel alert", - "notifications": [], - "severity": "warning" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 4, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "warning", - "fill": true, - "fillColor": "rgba(235, 138, 14, 0.12)", - "line": true, - "lineColor": "rgba(247, 149, 32, 0.60)", - "op": "gt", - "value": 20 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 20 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "60s", - "handler": 1, - "name": "Warning panel alert", - "notifications": [], - "severity": "warning" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 7, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "warning", - "fill": true, - "fillColor": "rgba(235, 138, 14, 0.12)", - "line": true, - "lineColor": "rgba(247, 149, 32, 0.60)", - "op": "gt", - "value": 20 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - } - ], - "title": "New row" - }, - { - "collapse": false, - "editable": true, - "height": "250px", - "panels": [ - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 50 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "10s", - "handler": 1, - "name": "Fast Critical panel alert", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 9, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 50 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 50 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "10s", - "handler": 1, - "name": "Fast Critical panel alert", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 10, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 50 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 50 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "10s", - "handler": 1, - "name": "Fast Critical panel alert", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 11, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 50 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "conditions": [ - { - "evaluator": { - "params": [ - 50 - ], - "type": "gt" - }, - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "params": [], - "type": "avg" - }, - "type": "query" - } - ], - "enabled": true, - "frequency": "10s", - "handler": 1, - "name": "Fast Critical panel alert", - "notifications": [], - "severity": "critical" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 12, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "colorMode": "critical", - "fill": true, - "fillColor": "rgba(234, 112, 112, 0.12)", - "line": true, - "lineColor": "rgba(237, 46, 24, 0.60)", - "op": "gt", - "value": 50 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Critical panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - } - ], - "title": "New row" - }, - { - "collapse": false, - "editable": true, - "height": "250px", - "panels": [ - { - "alert": { - "enabled": true, - "conditions": [ - { - "type": "query", - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "type": "avg", - "params": [] - }, - "evaluator": { - "type": "gt", - "params": [ - 10 - ] - } - } - ], - "severity": "warning", - "frequency": "1s", - "handler": 1, - "notifications": [], - "name": "Fast Warning panel alert" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 13, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "value": 10, - "op": "gt", - "fill": true, - "line": true, - "colorMode": "warning", - "fillColor": "rgba(235, 138, 14, 0.12)", - "lineColor": "rgba(247, 149, 32, 0.60)" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "enabled": true, - "conditions": [ - { - "type": "query", - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "type": "avg", - "params": [] - }, - "evaluator": { - "type": "gt", - "params": [ - 10 - ] - } - } - ], - "severity": "warning", - "frequency": "1s", - "handler": 1, - "notifications": [], - "name": "Fast Warning panel alert" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 14, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "value": 10, - "op": "gt", - "fill": true, - "line": true, - "colorMode": "warning", - "fillColor": "rgba(235, 138, 14, 0.12)", - "lineColor": "rgba(247, 149, 32, 0.60)" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "enabled": true, - "conditions": [ - { - "type": "query", - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "type": "avg", - "params": [] - }, - "evaluator": { - "type": "gt", - "params": [ - 10 - ] - } - } - ], - "severity": "warning", - "frequency": "1s", - "handler": 1, - "notifications": [], - "name": "Fast Warning panel alert" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 15, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "value": 10, - "op": "gt", - "fill": true, - "line": true, - "colorMode": "warning", - "fillColor": "rgba(235, 138, 14, 0.12)", - "lineColor": "rgba(247, 149, 32, 0.60)" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - }, - { - "alert": { - "enabled": true, - "conditions": [ - { - "type": "query", - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "type": "avg", - "params": [] - }, - "evaluator": { - "type": "gt", - "params": [ - 10 - ] - } - } - ], - "severity": "warning", - "frequency": "1s", - "handler": 1, - "notifications": [], - "name": "Fast Warning panel alert" - }, - "aliasColors": {}, - "bars": false, - "datasource": "${DS_GRAPHITE}", - "editable": true, - "error": false, - "fill": 1, - "id": 16, - "isNew": true, - "legend": { - "avg": false, - "current": false, - "max": false, - "min": false, - "show": true, - "total": false, - "values": false - }, - "lines": true, - "linewidth": 2, - "links": [], - "nullPointMode": "connected", - "percentage": false, - "pointradius": 5, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "span": 3, - "stack": false, - "steppedLine": false, - "targets": [ - { - "refId": "A", - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)" - } - ], - "thresholds": [ - { - "value": 10, - "op": "gt", - "fill": true, - "line": true, - "colorMode": "warning", - "fillColor": "rgba(235, 138, 14, 0.12)", - "lineColor": "rgba(247, 149, 32, 0.60)" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Fast Warning panel", - "tooltip": { - "msResolution": false, - "shared": true, - "sort": 0, - "value_type": "cumulative" - }, - "type": "graph", - "xaxis": { - "show": true - }, - "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 - } - ] - } - ], - "title": "New row" - }, - { - "title": "New row", - "height": "250px", - "editable": true, - "collapse": false, - "panels": [ - { - "title": "Alert below value", - "error": false, - "span": 3, - "editable": true, - "type": "graph", - "isNew": true, - "id": 17, - "targets": [ - { - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)", - "refId": "A" - } - ], - "datasource": "${DS_GRAPHITE}", - "renderer": "flot", - "yaxes": [ - { - "label": null, - "show": true, - "logBase": 1, - "min": null, - "max": null, - "format": "short" - }, - { - "label": null, - "show": true, - "logBase": 1, - "min": null, - "max": null, - "format": "short" - } - ], - "xaxis": { - "show": true - }, - "alert": { - "conditions": [ - { - "type": "query", - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "type": "avg", - "params": [] - }, - "evaluator": { - "type": "lt", - "params": [ - 20 - ] - } - } - ], - "severity": "critical", - "frequency": "60s", - "handler": 1, - "notifications": [], - "name": "Alert below value", - "enabled": true - }, - "lines": true, - "fill": 1, - "linewidth": 2, - "points": false, - "pointradius": 5, - "bars": false, - "stack": false, - "percentage": false, - "legend": { - "show": true, - "values": false, - "min": false, - "max": false, - "current": false, - "total": false, - "avg": false - }, - "nullPointMode": "connected", - "steppedLine": false, - "tooltip": { - "value_type": "cumulative", - "shared": true, - "sort": 0, - "msResolution": false - }, - "timeFrom": null, - "timeShift": null, - "aliasColors": {}, - "seriesOverrides": [], - "thresholds": [ - { - "value": 20, - "op": "lt", - "fill": true, - "line": true, - "colorMode": "critical", - "fillColor": "rgba(234, 112, 112, 0.12)", - "lineColor": "rgba(237, 46, 24, 0.60)" - } - ], - "links": [] - }, - { - "title": "Alert is outside range", - "error": false, - "span": 3, - "editable": true, - "type": "graph", - "isNew": true, - "id": 18, - "targets": [ - { - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)", - "refId": "A" - } - ], - "datasource": "${DS_GRAPHITE}", - "renderer": "flot", - "yaxes": [ - { - "label": null, - "show": true, - "logBase": 1, - "min": null, - "max": null, - "format": "short" - }, - { - "label": null, - "show": true, - "logBase": 1, - "min": null, - "max": null, - "format": "short" - } - ], - "xaxis": { - "show": true - }, - "alert": { - "conditions": [ - { - "type": "query", - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "type": "avg", - "params": [] - }, - "evaluator": { - "type": "outside_range", - "params": [ - 10, - 80 - ] - } - } - ], - "severity": "critical", - "frequency": "10s", - "handler": 1, - "notifications": [], - "name": "Alert is outside range", - "enabled": true - }, - "lines": true, - "fill": 1, - "linewidth": 2, - "points": false, - "pointradius": 5, - "bars": false, - "stack": false, - "percentage": false, - "legend": { - "show": true, - "values": false, - "min": false, - "max": false, - "current": false, - "total": false, - "avg": false - }, - "nullPointMode": "connected", - "steppedLine": false, - "tooltip": { - "value_type": "cumulative", - "shared": true, - "sort": 0, - "msResolution": false - }, - "timeFrom": null, - "timeShift": null, - "aliasColors": {}, - "seriesOverrides": [], - "thresholds": [ - { - "value": 10, - "op": "lt", - "fill": true, - "line": true, - "colorMode": "critical", - "fillColor": "rgba(234, 112, 112, 0.12)", - "lineColor": "rgba(237, 46, 24, 0.60)" - }, - { - "value": 80, - "op": "gt", - "fill": true, - "line": true, - "colorMode": "critical", - "fillColor": "rgba(234, 112, 112, 0.12)", - "lineColor": "rgba(237, 46, 24, 0.60)" - } - ], - "links": [] - }, - { - "title": "Alert is within range", - "error": false, - "span": 3, - "editable": true, - "type": "graph", - "isNew": true, - "id": 19, - "targets": [ - { - "target": "aliasByNode(statsd.fakesite.counters.session_start.*.count, 4)", - "refId": "A" - } - ], - "datasource": "${DS_GRAPHITE}", - "renderer": "flot", - "yaxes": [ - { - "label": null, - "show": true, - "logBase": 1, - "min": null, - "max": null, - "format": "short" - }, - { - "label": null, - "show": true, - "logBase": 1, - "min": null, - "max": null, - "format": "short" - } - ], - "xaxis": { - "show": true - }, - "alert": { - "conditions": [ - { - "type": "query", - "query": { - "params": [ - "A", - "5m", - "now" - ] - }, - "reducer": { - "type": "avg", - "params": [] - }, - "evaluator": { - "type": "within_range", - "params": [ - 60, - 80 - ] - } - } - ], - "severity": "critical", - "frequency": "10s", - "handler": 1, - "notifications": [], - "name": "Alert is within range", - "enabled": true - }, - "lines": true, - "fill": 1, - "linewidth": 2, - "points": false, - "pointradius": 5, - "bars": false, - "stack": false, - "percentage": false, - "legend": { - "show": true, - "values": false, - "min": false, - "max": false, - "current": false, - "total": false, - "avg": false - }, - "nullPointMode": "connected", - "steppedLine": false, - "tooltip": { - "value_type": "cumulative", - "shared": true, - "sort": 0, - "msResolution": false - }, - "timeFrom": null, - "timeShift": null, - "aliasColors": {}, - "seriesOverrides": [], - "thresholds": [ - { - "value": 60, - "op": "gt", - "fill": true, - "line": true, - "colorMode": "critical", - "fillColor": "rgba(234, 112, 112, 0.12)", - "lineColor": "rgba(237, 46, 24, 0.60)" - }, - { - "value": 80, - "op": "lt", - "fill": true, - "line": true, - "colorMode": "critical", - "fillColor": "rgba(234, 112, 112, 0.12)", - "lineColor": "rgba(237, 46, 24, 0.60)" - } - ], - "links": [] - } - ] - } - ], - "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" - ] - }, - "templating": { - "list": [] - }, - "annotations": { - "list": [] - }, - "schemaVersion": 13, - "version": 50, - "links": [], - "gnetId": null -} \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index a5cd3416f75..cac634fbf10 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,13 +1,8 @@ module.exports = { verbose: false, - "globals": { - "ts-jest": { - "tsConfigFile": "tsconfig.json" - } - }, "transform": { - "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" + "^.+\\.(ts|tsx)$": "ts-jest" }, "moduleDirectories": ["node_modules", "public"], "roots": [ diff --git a/latest.json b/latest.json index 7b36131fea2..bce09c3283b 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "5.2.3", - "testing": "5.2.3" + "stable": "5.2.4", + "testing": "5.2.4" } diff --git a/package.json b/package.json index 7dac28236c3..a1bd61b21f4 100644 --- a/package.json +++ b/package.json @@ -4,18 +4,19 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "5.3.0-beta1", + "version": "5.3.0-beta2", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" }, "devDependencies": { "@types/d3": "^4.10.1", - "@types/enzyme": "^2.8.9", - "@types/jest": "^21.1.4", + "@types/enzyme": "^3.1.13", + "@types/jest": "^23.3.2", "@types/node": "^8.0.31", - "@types/react": "^16.0.25", - "@types/react-dom": "^16.0.3", + "@types/react": "^16.4.14", + "@types/react-custom-scrollbars": "^4.0.5", + "@types/react-dom": "^16.0.7", "angular-mocks": "1.6.6", "autoprefixer": "^6.4.0", "axios": "^0.17.1", @@ -25,15 +26,15 @@ "babel-preset-es2015": "^6.24.1", "clean-webpack-plugin": "^0.1.19", "css-loader": "^0.28.7", - "enzyme": "^3.1.0", - "enzyme-adapter-react-16": "^1.0.1", - "enzyme-to-json": "^3.3.0", + "enzyme": "^3.6.0", + "enzyme-adapter-react-16": "^1.5.0", + "enzyme-to-json": "^3.3.4", "es6-promise": "^3.0.2", "es6-shim": "^0.35.3", "expect.js": "~0.2.0", "expose-loader": "^0.7.3", "file-loader": "^1.1.11", - "fork-ts-checker-webpack-plugin": "^0.4.2", + "fork-ts-checker-webpack-plugin": "^0.4.9", "gaze": "^1.1.2", "glob": "~7.0.0", "grunt": "1.0.1", @@ -55,11 +56,10 @@ "html-webpack-harddisk-plugin": "^0.2.0", "html-webpack-plugin": "^3.2.0", "husky": "^0.14.3", - "jest": "^22.0.4", + "jest": "^23.6.0", "lint-staged": "^6.0.0", "load-grunt-tasks": "3.5.2", "mini-css-extract-plugin": "^0.4.0", - "mobx-react-devtools": "^4.2.15", "mocha": "^4.0.1", "ng-annotate-loader": "^0.6.1", "ng-annotate-webpack-plugin": "^0.3.0", @@ -71,20 +71,20 @@ "postcss-loader": "^2.0.6", "postcss-reporter": "^5.0.0", "prettier": "1.9.2", - "react-hot-loader": "^4.2.0", - "react-test-renderer": "^16.0.0", + "react-hot-loader": "^4.3.6", + "react-test-renderer": "^16.5.0", "sass-lint": "^1.10.2", "sass-loader": "^7.0.1", "sinon": "1.17.6", "style-loader": "^0.21.0", "systemjs": "0.20.19", "systemjs-plugin-css": "^0.1.36", - "ts-jest": "^22.4.6", - "ts-loader": "^4.3.0", + "ts-jest": "^23.1.4", + "ts-loader": "^5.1.0", "tslib": "^1.9.3", "tslint": "^5.8.0", "tslint-loader": "^3.5.3", - "typescript": "^2.6.2", + "typescript": "^3.0.3", "uglifyjs-webpack-plugin": "^1.2.7", "webpack": "^4.8.0", "webpack-bundle-analyzer": "^2.9.0", @@ -100,7 +100,6 @@ "watch": "webpack --progress --colors --watch --mode development --config scripts/webpack/webpack.dev.js", "build": "grunt build", "test": "grunt test", - "test:coverage": "grunt test --coverage=true", "lint": "tslint -c tslint.json --project tsconfig.json", "jest": "jest --notify --watch", "api-tests": "jest --notify --watch --config=tests/api/jest.js", @@ -132,6 +131,7 @@ "angular-native-dragdrop": "1.2.2", "angular-route": "1.6.6", "angular-sanitize": "1.6.6", + "babel-jest": "^23.6.0", "babel-polyfill": "^6.26.0", "baron": "^3.0.3", "brace": "^0.10.0", @@ -144,28 +144,31 @@ "immutable": "^3.8.2", "jquery": "^3.2.1", "lodash": "^4.17.10", - "mobx": "^3.4.1", - "mobx-react": "^4.3.5", - "mobx-state-tree": "^1.3.1", "moment": "^2.22.2", "mousetrap": "^1.6.0", "mousetrap-global-bind": "^1.1.0", "prismjs": "^1.6.0", - "prop-types": "^15.6.0", + "prop-types": "^15.6.2", "rc-cascader": "^0.14.0", - "react": "^16.2.0", - "react-dom": "^16.2.0", + "react": "^16.5.0", + "react-custom-scrollbars": "^4.2.1", + "react-dom": "^16.5.0", "react-grid-layout": "0.16.6", "react-highlight-words": "^0.10.0", "react-popper": "^0.7.5", + "react-redux": "^5.0.7", "react-select": "^1.1.0", "react-sizeme": "^2.3.6", "react-transition-group": "^2.2.1", + "redux": "^4.0.0", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.3.0", "remarkable": "^1.7.1", "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "^5.4.3", "slate": "^0.33.4", "slate-plain-serializer": "^0.5.10", + "slate-prism": "^0.5.0", "slate-react": "^0.12.4", "tether": "^1.4.0", "tether-drop": "https://github.com/torkelo/drop/tarball/master", diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 55c9c954940..242b5531f51 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -24,6 +24,7 @@ func GetAnnotations(c *m.ReqContext) Response { Limit: c.QueryInt64("limit"), Tags: c.QueryStrings("tags"), Type: c.Query("type"), + MatchAny: c.QueryBool("matchAny"), } repo := annotations.GetRepository() diff --git a/pkg/api/api.go b/pkg/api/api.go index 906481bbb8a..39b332aeb9f 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -320,7 +320,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/search/", Search) // metrics - apiRoute.Post("/tsdb/query", bind(dtos.MetricRequest{}), Wrap(QueryMetrics)) + apiRoute.Post("/tsdb/query", bind(dtos.MetricRequest{}), Wrap(hs.QueryMetrics)) apiRoute.Get("/tsdb/testdata/scenarios", Wrap(GetTestDataScenarios)) apiRoute.Get("/tsdb/testdata/gensql", reqGrafanaAdmin, Wrap(GenerateSQLTestData)) apiRoute.Get("/tsdb/testdata/random-walk", Wrap(GetTestDataRandomWalk)) 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/dataproxy.go b/pkg/api/dataproxy.go index 33839ca985d..f455d3dbd29 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -13,19 +13,20 @@ import ( const HeaderNameNoBackendCache = "X-Grafana-NoCache" -func (hs *HTTPServer) getDatasourceByID(id int64, orgID int64, nocache bool) (*m.DataSource, error) { +func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) { + nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true" cacheKey := fmt.Sprintf("ds-%d", id) if !nocache { if cached, found := hs.cache.Get(cacheKey); found { ds := cached.(*m.DataSource) - if ds.OrgId == orgID { + if ds.OrgId == c.OrgId { return ds, nil } } } - query := m.GetDataSourceByIdQuery{Id: id, OrgId: orgID} + query := m.GetDataSourceByIdQuery{Id: id, OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { return nil, err } @@ -37,10 +38,7 @@ func (hs *HTTPServer) getDatasourceByID(id int64, orgID int64, nocache bool) (*m func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) { c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer) - nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true" - - ds, err := hs.getDatasourceByID(c.ParamsInt64(":id"), c.OrgId, nocache) - + ds, err := hs.getDatasourceFromCache(c.ParamsInt64(":id"), c) if err != nil { c.JsonApiErr(500, "Unable to load datasource meta data", err) return diff --git a/pkg/api/dtos/alerting_test.go b/pkg/api/dtos/alerting_test.go index c38f281be9c..f4c09f202cb 100644 --- a/pkg/api/dtos/alerting_test.go +++ b/pkg/api/dtos/alerting_test.go @@ -29,7 +29,7 @@ func TestFormatShort(t *testing.T) { } if parsed != tc.interval { - t.Errorf("expectes the parsed duration to equal the interval. Got %v expected: %v", parsed, tc.interval) + t.Errorf("expects the parsed duration to equal the interval. Got %v expected: %v", parsed, tc.interval) } } } 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/http_server.go b/pkg/api/http_server.go index 0de63ce5e08..432d6a18369 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -233,6 +233,10 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { } func (hs *HTTPServer) metricsEndpoint(ctx *macaron.Context) { + if !hs.Cfg.MetricsEndpointEnabled { + return + } + if ctx.Req.Method != "GET" || ctx.Req.URL.Path != "/metrics" { return } diff --git a/pkg/api/index.go b/pkg/api/index.go index ea10940d3ba..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,9 +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" + data.Theme = lightName + } else if themeURLParam == darkName { + data.User.LightTheme = false + data.Theme = darkName } if hasEditPermissionInFoldersQuery.Result { diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index f2bc79df7ad..cb80bd346b8 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -13,21 +13,21 @@ import ( ) // POST /api/tsdb/query -func QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response { +func (hs *HTTPServer) QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response { timeRange := tsdb.NewTimeRange(reqDto.From, reqDto.To) if len(reqDto.Queries) == 0 { return Error(400, "No queries found in query", nil) } - dsID, err := reqDto.Queries[0].Get("datasourceId").Int64() + datasourceId, err := reqDto.Queries[0].Get("datasourceId").Int64() if err != nil { return Error(400, "Query missing datasourceId", nil) } - dsQuery := m.GetDataSourceByIdQuery{Id: dsID, OrgId: c.OrgId} - if err := bus.Dispatch(&dsQuery); err != nil { - return Error(500, "failed to fetch data source", err) + ds, err := hs.getDatasourceFromCache(datasourceId, c) + if err != nil { + return Error(500, "Unable to load datasource meta data", err) } request := &tsdb.TsdbQuery{TimeRange: timeRange} @@ -38,11 +38,11 @@ func QueryMetrics(c *m.ReqContext, reqDto dtos.MetricRequest) Response { MaxDataPoints: query.Get("maxDataPoints").MustInt64(100), IntervalMs: query.Get("intervalMs").MustInt64(1000), Model: query, - DataSource: dsQuery.Result, + DataSource: ds, }) } - resp, err := tsdb.HandleRequest(context.Background(), dsQuery.Result, request) + resp, err := tsdb.HandleRequest(c.Req.Context(), ds, request) if err != nil { return Error(500, "Metric request error", err) } 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/api/team_members.go b/pkg/api/team_members.go index 60a170a8c31..5b5970de6ad 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -4,6 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -17,6 +18,11 @@ func GetTeamMembers(c *m.ReqContext) Response { for _, member := range query.Result { member.AvatarUrl = dtos.GetGravatarUrl(member.Email) + member.Labels = []string{} + + if setting.IsEnterprise && setting.LdapEnabled && member.External { + member.Labels = append(member.Labels, "LDAP") + } } return JSON(200, query.Result) 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-cli/commands/upgrade_command.go b/pkg/cmd/grafana-cli/commands/upgrade_command.go index 355ccab3d1c..396371d3577 100644 --- a/pkg/cmd/grafana-cli/commands/upgrade_command.go +++ b/pkg/cmd/grafana-cli/commands/upgrade_command.go @@ -16,7 +16,7 @@ func upgradeCommand(c CommandLine) error { return err } - v, err2 := s.GetPlugin(localPlugin.Id, c.RepoDirectory()) + v, err2 := s.GetPlugin(pluginName, c.RepoDirectory()) if err2 != nil { return err2 @@ -24,9 +24,9 @@ func upgradeCommand(c CommandLine) error { if ShouldUpgrade(localPlugin.Info.Version, v) { s.RemoveInstalledPlugin(pluginsDir, pluginName) - return InstallPlugin(localPlugin.Id, "", c) + return InstallPlugin(pluginName, "", c) } - logger.Infof("%s %s is up to date \n", color.GreenString("✔"), localPlugin.Id) + logger.Infof("%s %s is up to date \n", color.GreenString("✔"), pluginName) return nil } diff --git a/pkg/cmd/grafana-cli/services/services.go b/pkg/cmd/grafana-cli/services/services.go index b4e50ac84df..338975bc130 100644 --- a/pkg/cmd/grafana-cli/services/services.go +++ b/pkg/cmd/grafana-cli/services/services.go @@ -63,7 +63,7 @@ func ListAllPlugins(repoUrl string) (m.PluginRepo, error) { var data m.PluginRepo err = json.Unmarshal(body, &data) if err != nil { - logger.Info("Failed to unmarshal graphite response error:", err) + logger.Info("Failed to unmarshal plugin repo response error:", err) return m.PluginRepo{}, err } @@ -140,7 +140,7 @@ func GetPlugin(pluginId, repoUrl string) (m.Plugin, error) { var data m.Plugin err = json.Unmarshal(body, &data) if err != nil { - logger.Info("Failed to unmarshal graphite response error:", err) + logger.Info("Failed to unmarshal plugin repo response error:", err) return m.Plugin{}, err } diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index f1e298671d7..c85270b64d9 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" ) diff --git a/pkg/components/imguploader/azureblobuploader.go b/pkg/components/imguploader/azureblobuploader.go index 3c0ac5b8884..a902807925b 100644 --- a/pkg/components/imguploader/azureblobuploader.go +++ b/pkg/components/imguploader/azureblobuploader.go @@ -52,7 +52,7 @@ func (az *AzureBlobUploader) Upload(ctx context.Context, imageDiskPath string) ( } randomFileName := util.GetRandomString(30) + ".png" // upload image - az.log.Debug("Uploading image to azure_blob", "conatiner_name", az.container_name, "blob_name", randomFileName) + az.log.Debug("Uploading image to azure_blob", "container_name", az.container_name, "blob_name", randomFileName) resp, err := blob.FileUpload(az.container_name, randomFileName, file) if err != nil { return "", err @@ -274,10 +274,10 @@ func (a *Auth) canonicalizedHeaders(req *http.Request) string { } } - splitted := strings.Split(buffer.String(), "\n") - sort.Strings(splitted) + split := strings.Split(buffer.String(), "\n") + sort.Strings(split) - return strings.Join(splitted, "\n") + return strings.Join(split, "\n") } /* @@ -313,8 +313,8 @@ func (a *Auth) canonicalizedResource(req *http.Request) string { buffer.WriteString(fmt.Sprintf("\n%s:%s", key, strings.Join(values, ","))) } - splitted := strings.Split(buffer.String(), "\n") - sort.Strings(splitted) + split := strings.Split(buffer.String(), "\n") + sort.Strings(split) - return strings.Join(splitted, "\n") + return strings.Join(split, "\n") } 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/components/simplejson/simplejson.go b/pkg/components/simplejson/simplejson.go index 85e2f955943..35e305eb414 100644 --- a/pkg/components/simplejson/simplejson.go +++ b/pkg/components/simplejson/simplejson.go @@ -256,7 +256,7 @@ func (j *Json) StringArray() ([]string, error) { // MustArray guarantees the return of a `[]interface{}` (with optional default) // -// useful when you want to interate over array values in a succinct manner: +// useful when you want to iterate over array values in a succinct manner: // for i, v := range js.Get("results").MustArray() { // fmt.Println(i, v) // } @@ -281,7 +281,7 @@ func (j *Json) MustArray(args ...[]interface{}) []interface{} { // MustMap guarantees the return of a `map[string]interface{}` (with optional default) // -// useful when you want to interate over map values in a succinct manner: +// useful when you want to iterate over map values in a succinct manner: // for k, v := range js.Get("dictionary").MustMap() { // fmt.Println(k, v) // } @@ -329,7 +329,7 @@ func (j *Json) MustString(args ...string) string { // MustStringArray guarantees the return of a `[]string` (with optional default) // -// useful when you want to interate over array values in a succinct manner: +// useful when you want to iterate over array values in a succinct manner: // for i, s := range js.Get("results").MustStringArray() { // fmt.Println(i, s) // } diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 053778e8deb..43f45f900d9 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -326,15 +326,19 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { a.log.Info("Searching for user's groups", "filter", filter) + // support old way of reading settings + groupIdAttribute := a.server.Attr.MemberOf + // but prefer dn attribute if default settings are used + if groupIdAttribute == "" || groupIdAttribute == "memberOf" { + groupIdAttribute = "dn" + } + groupSearchReq := ldap.SearchRequest{ BaseDN: groupSearchBase, Scope: ldap.ScopeWholeSubtree, DerefAliases: ldap.NeverDerefAliases, - Attributes: []string{ - // Here MemberOf would be the thing that identifies the group, which is normally 'cn' - a.server.Attr.MemberOf, - }, - Filter: filter, + Attributes: []string{groupIdAttribute}, + Filter: filter, } groupSearchResult, err = a.conn.Search(&groupSearchReq) @@ -344,7 +348,7 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { if len(groupSearchResult.Entries) > 0 { for i := range groupSearchResult.Entries { - memberOf = append(memberOf, getLdapAttrN(a.server.Attr.MemberOf, groupSearchResult, i)) + memberOf = append(memberOf, getLdapAttrN(groupIdAttribute, groupSearchResult, i)) } break } diff --git a/pkg/login/ldap_settings.go b/pkg/login/ldap_settings.go index 7ebfbc79ba8..40791a509db 100644 --- a/pkg/login/ldap_settings.go +++ b/pkg/login/ldap_settings.go @@ -48,7 +48,7 @@ type LdapAttributeMap struct { type LdapGroupToOrgRole struct { GroupDN string `toml:"group_dn"` OrgId int64 `toml:"org_id"` - IsGrafanaAdmin *bool `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatability) + IsGrafanaAdmin *bool `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatibility) OrgRole m.RoleType `toml:"org_role"` } diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index a8d9f7308fa..9a514fdb6f3 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -61,6 +61,23 @@ var ( M_Grafana_Version *prometheus.GaugeVec ) +func newCounterVecStartingAtZero(opts prometheus.CounterOpts, labels []string, labelValues ...string) *prometheus.CounterVec { + counter := prometheus.NewCounterVec(opts, labels) + + for _, label := range labelValues { + counter.WithLabelValues(label).Add(0) + } + + return counter +} + +func newCounterStartingAtZero(opts prometheus.CounterOpts, labelValues ...string) prometheus.Counter { + counter := prometheus.NewCounter(opts) + counter.Add(0) + + return counter +} + func init() { M_Instance_Start = prometheus.NewCounter(prometheus.CounterOpts{ Name: "instance_start_total", @@ -68,32 +85,27 @@ func init() { Namespace: exporterName, }) - M_Page_Status = prometheus.NewCounterVec( + httpStatusCodes := []string{"200", "404", "500", "unknown"} + M_Page_Status = newCounterVecStartingAtZero( prometheus.CounterOpts{ Name: "page_response_status_total", Help: "page http response status", Namespace: exporterName, - }, - []string{"code"}, - ) + }, []string{"code"}, httpStatusCodes...) - M_Api_Status = prometheus.NewCounterVec( + M_Api_Status = newCounterVecStartingAtZero( prometheus.CounterOpts{ Name: "api_response_status_total", Help: "api http response status", Namespace: exporterName, - }, - []string{"code"}, - ) + }, []string{"code"}, httpStatusCodes...) - M_Proxy_Status = prometheus.NewCounterVec( + M_Proxy_Status = newCounterVecStartingAtZero( prometheus.CounterOpts{ Name: "proxy_response_status_total", Help: "proxy http response status", Namespace: exporterName, - }, - []string{"code"}, - ) + }, []string{"code"}, httpStatusCodes...) M_Http_Request_Total = prometheus.NewCounterVec( prometheus.CounterOpts{ @@ -111,19 +123,19 @@ func init() { []string{"handler", "statuscode", "method"}, ) - M_Api_User_SignUpStarted = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_User_SignUpStarted = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_user_signup_started_total", Help: "amount of users who started the signup flow", Namespace: exporterName, }) - M_Api_User_SignUpCompleted = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_User_SignUpCompleted = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_user_signup_completed_total", Help: "amount of users who completed the signup flow", Namespace: exporterName, }) - M_Api_User_SignUpInvite = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_User_SignUpInvite = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_user_signup_invite_total", Help: "amount of users who have been invited", Namespace: exporterName, @@ -147,49 +159,49 @@ func init() { Namespace: exporterName, }) - M_Api_Admin_User_Create = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Admin_User_Create = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_admin_user_created_total", Help: "api admin user created counter", Namespace: exporterName, }) - M_Api_Login_Post = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Login_Post = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_login_post_total", Help: "api login post counter", Namespace: exporterName, }) - M_Api_Login_OAuth = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Login_OAuth = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_login_oauth_total", Help: "api login oauth counter", Namespace: exporterName, }) - M_Api_Org_Create = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Org_Create = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_org_create_total", Help: "api org created counter", Namespace: exporterName, }) - M_Api_Dashboard_Snapshot_Create = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Dashboard_Snapshot_Create = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_dashboard_snapshot_create_total", Help: "dashboard snapshots created", Namespace: exporterName, }) - M_Api_Dashboard_Snapshot_External = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Dashboard_Snapshot_External = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_dashboard_snapshot_external_total", Help: "external dashboard snapshots created", Namespace: exporterName, }) - M_Api_Dashboard_Snapshot_Get = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Dashboard_Snapshot_Get = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_dashboard_snapshot_get_total", Help: "loaded dashboards", Namespace: exporterName, }) - M_Api_Dashboard_Insert = prometheus.NewCounter(prometheus.CounterOpts{ + M_Api_Dashboard_Insert = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "api_models_dashboard_insert_total", Help: "dashboards inserted ", Namespace: exporterName, @@ -207,25 +219,25 @@ func init() { Namespace: exporterName, }, []string{"type"}) - M_Aws_CloudWatch_GetMetricStatistics = prometheus.NewCounter(prometheus.CounterOpts{ + M_Aws_CloudWatch_GetMetricStatistics = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "aws_cloudwatch_get_metric_statistics_total", Help: "counter for getting metric statistics from aws", Namespace: exporterName, }) - M_Aws_CloudWatch_ListMetrics = prometheus.NewCounter(prometheus.CounterOpts{ + M_Aws_CloudWatch_ListMetrics = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "aws_cloudwatch_list_metrics_total", Help: "counter for getting list of metrics from aws", Namespace: exporterName, }) - M_Aws_CloudWatch_GetMetricData = prometheus.NewCounter(prometheus.CounterOpts{ + M_Aws_CloudWatch_GetMetricData = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "aws_cloudwatch_get_metric_data_total", Help: "counter for getting metric data time series from aws", Namespace: exporterName, }) - M_DB_DataSource_QueryById = prometheus.NewCounter(prometheus.CounterOpts{ + M_DB_DataSource_QueryById = newCounterStartingAtZero(prometheus.CounterOpts{ Name: "db_datasource_query_by_id_total", Help: "counter for getting datasource by id", Namespace: exporterName, @@ -350,7 +362,7 @@ func getEdition() string { } } -func sendUsageStats() { +func sendUsageStats(oauthProviders map[string]bool) { if !setting.ReportingEnabled { return } @@ -440,6 +452,34 @@ func sendUsageStats() { metrics["stats.ds_access.other."+access+".count"] = count } + anStats := models.GetAlertNotifierUsageStatsQuery{} + if err := bus.Dispatch(&anStats); err != nil { + metricsLogger.Error("Failed to get alert notification stats", "error", err) + return + } + + for _, stats := range anStats.Result { + metrics["stats.alert_notifiers."+stats.Type+".count"] = stats.Count + } + + authTypes := map[string]bool{} + authTypes["anonymous"] = setting.AnonymousEnabled + authTypes["basic_auth"] = setting.BasicAuthEnabled + authTypes["ldap"] = setting.LdapEnabled + authTypes["auth_proxy"] = setting.AuthProxyEnabled + + for provider, enabled := range oauthProviders { + authTypes["oauth_"+provider] = enabled + } + + for authType, enabled := range authTypes { + enabledValue := 0 + if enabled { + enabledValue = 1 + } + metrics["stats.auth_enabled."+authType+".count"] = enabledValue + } + out, _ := json.MarshalIndent(report, "", " ") data := bytes.NewBuffer(out) diff --git a/pkg/metrics/metrics_test.go b/pkg/metrics/metrics_test.go index 8d88e03d106..43739221f1e 100644 --- a/pkg/metrics/metrics_test.go +++ b/pkg/metrics/metrics_test.go @@ -115,6 +115,24 @@ func TestMetrics(t *testing.T) { return nil }) + var getAlertNotifierUsageStatsQuery *models.GetAlertNotifierUsageStatsQuery + bus.AddHandler("test", func(query *models.GetAlertNotifierUsageStatsQuery) error { + query.Result = []*models.NotifierUsageStats{ + { + Type: "slack", + Count: 1, + }, + { + Type: "webhook", + Count: 2, + }, + } + + getAlertNotifierUsageStatsQuery = query + + return nil + }) + var wg sync.WaitGroup var responseBuffer *bytes.Buffer var req *http.Request @@ -129,11 +147,19 @@ func TestMetrics(t *testing.T) { })) usageStatsURL = ts.URL - sendUsageStats() + oauthProviders := map[string]bool{ + "github": true, + "gitlab": true, + "google": true, + "generic_oauth": true, + "grafana_com": true, + } + + sendUsageStats(oauthProviders) Convey("Given reporting not enabled and sending usage stats", func() { setting.ReportingEnabled = false - sendUsageStats() + sendUsageStats(oauthProviders) Convey("Should not gather stats or call http endpoint", func() { So(getSystemStatsQuery, ShouldBeNil) @@ -146,8 +172,13 @@ func TestMetrics(t *testing.T) { Convey("Given reporting enabled and sending usage stats", func() { setting.ReportingEnabled = true setting.BuildVersion = "5.0.0" + setting.AnonymousEnabled = true + setting.BasicAuthEnabled = true + setting.LdapEnabled = true + setting.AuthProxyEnabled = true + wg.Add(1) - sendUsageStats() + sendUsageStats(oauthProviders) Convey("Should gather stats and call http endpoint", func() { if waitTimeout(&wg, 2*time.Second) { @@ -157,6 +188,7 @@ func TestMetrics(t *testing.T) { So(getSystemStatsQuery, ShouldNotBeNil) So(getDataSourceStatsQuery, ShouldNotBeNil) So(getDataSourceAccessStatsQuery, ShouldNotBeNil) + So(getAlertNotifierUsageStatsQuery, ShouldNotBeNil) So(req, ShouldNotBeNil) So(req.Method, ShouldEqual, http.MethodPost) So(req.Header.Get("Content-Type"), ShouldEqual, "application/json") @@ -198,6 +230,19 @@ func TestMetrics(t *testing.T) { So(metrics.Get("stats.ds_access."+models.DS_PROMETHEUS+".proxy.count").MustInt(), ShouldEqual, 3) So(metrics.Get("stats.ds_access.other.direct.count").MustInt(), ShouldEqual, 6+7) So(metrics.Get("stats.ds_access.other.proxy.count").MustInt(), ShouldEqual, 4+8) + + So(metrics.Get("stats.alert_notifiers.slack.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.alert_notifiers.webhook.count").MustInt(), ShouldEqual, 2) + + So(metrics.Get("stats.auth_enabled.anonymous.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.basic_auth.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.ldap.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.auth_proxy.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.oauth_github.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.oauth_gitlab.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.oauth_google.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.oauth_generic_oauth.count").MustInt(), ShouldEqual, 1) + So(metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt(), ShouldEqual, 1) }) }) diff --git a/pkg/metrics/service.go b/pkg/metrics/service.go index ec38e0acfec..d2c0c815da9 100644 --- a/pkg/metrics/service.go +++ b/pkg/metrics/service.go @@ -28,9 +28,9 @@ func init() { type InternalMetricsService struct { Cfg *setting.Cfg `inject:""` - enabled bool intervalSeconds int64 graphiteCfg *graphitebridge.Config + oauthProviders map[string]bool } func (im *InternalMetricsService) Init() error { @@ -61,7 +61,7 @@ func (im *InternalMetricsService) Run(ctx context.Context) error { for { select { case <-onceEveryDayTick.C: - sendUsageStats() + sendUsageStats(im.oauthProviders) case <-everyMinuteTicker.C: updateTotalStats() case <-ctx.Done(): diff --git a/pkg/metrics/settings.go b/pkg/metrics/settings.go index 58b84a7192f..18b9e78d6ff 100644 --- a/pkg/metrics/settings.go +++ b/pkg/metrics/settings.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/grafana/grafana/pkg/social" + "github.com/grafana/grafana/pkg/metrics/graphitebridge" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" @@ -16,17 +18,14 @@ func (im *InternalMetricsService) readSettings() error { return fmt.Errorf("Unable to find metrics config section %v", err) } - im.enabled = section.Key("enabled").MustBool(false) im.intervalSeconds = section.Key("interval_seconds").MustInt64(10) - if !im.enabled { - return nil - } - if err := im.parseGraphiteSettings(); err != nil { return fmt.Errorf("Unable to parse metrics graphite section, %v", err) } + im.oauthProviders = social.GetOAuthProviders(im.Cfg) + return nil } diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go index 42d33d5ed22..b90b3d36ced 100644 --- a/pkg/models/alert_notifications.go +++ b/pkg/models/alert_notifications.go @@ -98,7 +98,7 @@ type GetLatestNotificationQuery struct { AlertId int64 NotifierId int64 - Result *AlertNotificationJournal + Result []AlertNotificationJournal } type CleanNotificationJournalCommand struct { 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/models/stats.go b/pkg/models/stats.go index 4cd50d37463..d3e145dedf4 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -40,6 +40,15 @@ type GetDataSourceAccessStatsQuery struct { Result []*DataSourceAccessStats } +type NotifierUsageStats struct { + Type string + Count int64 +} + +type GetAlertNotifierUsageStatsQuery struct { + Result []*NotifierUsageStats +} + type AdminStats struct { Users int `json:"users"` Orgs int `json:"orgs"` diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 9434dad8ecd..dd64787f465 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -12,10 +12,11 @@ var ( // TeamMember model type TeamMember struct { - Id int64 - OrgId int64 - TeamId int64 - UserId int64 + Id int64 + OrgId int64 + TeamId int64 + UserId int64 + External bool Created time.Time Updated time.Time @@ -25,9 +26,10 @@ type TeamMember struct { // COMMANDS type AddTeamMemberCommand struct { - UserId int64 `json:"userId" binding:"Required"` - OrgId int64 `json:"-"` - TeamId int64 `json:"-"` + UserId int64 `json:"userId" binding:"Required"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` + External bool `json:"-"` } type RemoveTeamMemberCommand struct { @@ -40,20 +42,23 @@ type RemoveTeamMemberCommand struct { // QUERIES type GetTeamMembersQuery struct { - OrgId int64 - TeamId int64 - UserId int64 - Result []*TeamMemberDTO + OrgId int64 + TeamId int64 + UserId int64 + External bool + Result []*TeamMemberDTO } // ---------------------- // Projections and DTOs type TeamMemberDTO struct { - OrgId int64 `json:"orgId"` - TeamId int64 `json:"teamId"` - UserId int64 `json:"userId"` - Email string `json:"email"` - Login string `json:"login"` - AvatarUrl string `json:"avatarUrl"` + OrgId int64 `json:"orgId"` + TeamId int64 `json:"teamId"` + UserId int64 `json:"userId"` + External bool `json:"-"` + Email string `json:"email"` + Login string `json:"login"` + AvatarUrl string `json:"avatarUrl"` + Labels []string `json:"labels"` } diff --git a/pkg/plugins/app_plugin.go b/pkg/plugins/app_plugin.go index b070ba592f0..922b2444b7b 100644 --- a/pkg/plugins/app_plugin.go +++ b/pkg/plugins/app_plugin.go @@ -23,12 +23,13 @@ type AppPlugin struct { } type AppPluginRoute struct { - Path string `json:"path"` - Method string `json:"method"` - ReqRole models.RoleType `json:"reqRole"` - Url string `json:"url"` - Headers []AppPluginRouteHeader `json:"headers"` - TokenAuth *JwtTokenAuth `json:"tokenAuth"` + Path string `json:"path"` + Method string `json:"method"` + ReqRole models.RoleType `json:"reqRole"` + Url string `json:"url"` + Headers []AppPluginRouteHeader `json:"headers"` + TokenAuth *JwtTokenAuth `json:"tokenAuth"` + JwtTokenAuth *JwtTokenAuth `json:"jwtTokenAuth"` } type AppPluginRouteHeader struct { @@ -36,8 +37,11 @@ type AppPluginRouteHeader struct { Content string `json:"content"` } +// JwtTokenAuth struct is both for normal Token Auth and JWT Token Auth with +// an uploaded JWT file. type JwtTokenAuth struct { Url string `json:"url"` + Scopes []string `json:"scopes"` Params map[string]string `json:"params"` } diff --git a/pkg/plugins/dashboard_importer_test.go b/pkg/plugins/dashboard_importer_test.go index 6f31b49f99d..ca8dfcd515c 100644 --- a/pkg/plugins/dashboard_importer_test.go +++ b/pkg/plugins/dashboard_importer_test.go @@ -35,7 +35,7 @@ func TestDashboardImport(t *testing.T) { So(cmd.Result, ShouldNotBeNil) resultStr, _ := mock.SavedDashboards[0].Dashboard.Data.EncodePretty() - expectedBytes, _ := ioutil.ReadFile("../../tests/test-app/dashboards/connections_result.json") + expectedBytes, _ := ioutil.ReadFile("testdata/test-app/dashboards/connections_result.json") expectedJson, _ := simplejson.NewJson(expectedBytes) expectedStr, _ := expectedJson.EncodePretty() @@ -89,7 +89,7 @@ func pluginScenario(desc string, t *testing.T, fn func()) { Convey("Given a plugin", t, func() { setting.Raw = ini.Empty() sec, _ := setting.Raw.NewSection("plugin.test-app") - sec.NewKey("path", "../../tests/test-app") + sec.NewKey("path", "testdata/test-app") pm := &PluginManager{} err := pm.Init() diff --git a/pkg/plugins/dashboards_test.go b/pkg/plugins/dashboards_test.go index c422a1431c0..6fc6ace0e00 100644 --- a/pkg/plugins/dashboards_test.go +++ b/pkg/plugins/dashboards_test.go @@ -16,7 +16,7 @@ func TestPluginDashboards(t *testing.T) { Convey("When asking plugin dashboard info", t, func() { setting.Raw = ini.Empty() sec, _ := setting.Raw.NewSection("plugin.test-app") - sec.NewKey("path", "../../tests/test-app") + sec.NewKey("path", "testdata/test-app") pm := &PluginManager{} err := pm.Init() diff --git a/pkg/plugins/dashboards_updater.go b/pkg/plugins/dashboards_updater.go index ebe11ed32d4..616d4541bec 100644 --- a/pkg/plugins/dashboards_updater.go +++ b/pkg/plugins/dashboards_updater.go @@ -48,11 +48,7 @@ func autoUpdateAppDashboard(pluginDashInfo *PluginDashboardInfoDTO, orgId int64) Path: pluginDashInfo.Path, } - if err := bus.Dispatch(&updateCmd); err != nil { - return err - } - - return nil + return bus.Dispatch(&updateCmd) } func syncPluginDashboards(pluginDef *PluginBase, orgId int64) { diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index fa68ae4389d..d16e6abb4c7 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -30,7 +30,7 @@ func TestPluginScans(t *testing.T) { Convey("When reading app plugin definition", t, func() { setting.Raw = ini.Empty() sec, _ := setting.Raw.NewSection("plugin.nginx-app") - sec.NewKey("path", "../../tests/test-app") + sec.NewKey("path", "testdata/test-app") pm := &PluginManager{} err := pm.Init() diff --git a/tests/datasource-test/module.js b/pkg/plugins/testdata/datasource-test/module.js similarity index 100% rename from tests/datasource-test/module.js rename to pkg/plugins/testdata/datasource-test/module.js diff --git a/tests/datasource-test/plugin.json b/pkg/plugins/testdata/datasource-test/plugin.json similarity index 100% rename from tests/datasource-test/plugin.json rename to pkg/plugins/testdata/datasource-test/plugin.json diff --git a/tests/test-app/dashboards/connections.json b/pkg/plugins/testdata/test-app/dashboards/connections.json similarity index 100% rename from tests/test-app/dashboards/connections.json rename to pkg/plugins/testdata/test-app/dashboards/connections.json diff --git a/tests/test-app/dashboards/connections_result.json b/pkg/plugins/testdata/test-app/dashboards/connections_result.json similarity index 100% rename from tests/test-app/dashboards/connections_result.json rename to pkg/plugins/testdata/test-app/dashboards/connections_result.json diff --git a/tests/test-app/dashboards/memory.json b/pkg/plugins/testdata/test-app/dashboards/memory.json similarity index 100% rename from tests/test-app/dashboards/memory.json rename to pkg/plugins/testdata/test-app/dashboards/memory.json diff --git a/tests/test-app/plugin.json b/pkg/plugins/testdata/test-app/plugin.json similarity index 100% rename from tests/test-app/plugin.json rename to pkg/plugins/testdata/test-app/plugin.json diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index e1c1bfacb2e..229092e217b 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -82,12 +82,13 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json, if collapsed && collapsedJSON.MustBool() { // extract alerts from sub panels for collapsed panels - als, err := e.getAlertFromPanels(panel, validateAlertFunc) + alertSlice, err := e.getAlertFromPanels(panel, + validateAlertFunc) if err != nil { return nil, err } - alerts = append(alerts, als...) + alerts = append(alerts, alertSlice...) continue } diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 7fbd956f4f9..cbad5cbfdcf 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -67,7 +68,7 @@ func (n *notificationService) sendNotifications(evalContext *EvalContext, notifi // Verify that we can send the notification again // but this time within the same transaction. - if !evalContext.IsTestRun && !not.ShouldNotify(context.Background(), evalContext) { + if !evalContext.IsTestRun && !not.ShouldNotify(ctx, evalContext) { return nil } @@ -108,11 +109,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() diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index ca011356247..d8f9aadffc5 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -11,6 +11,10 @@ import ( "github.com/grafana/grafana/pkg/services/alerting" ) +const ( + triggMetrString = "Triggered metrics:\n\n" +) + type NotifierBase struct { Name string Type string @@ -42,12 +46,21 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase { } } -func defaultShouldNotify(context *alerting.EvalContext, sendReminder bool, frequency time.Duration, lastNotify time.Time) bool { +func defaultShouldNotify(context *alerting.EvalContext, sendReminder bool, frequency time.Duration, journals []models.AlertNotificationJournal) bool { // Only notify on state change. if context.PrevAlertState == context.Rule.State && !sendReminder { return false } + // get last successfully sent notification + lastNotify := time.Time{} + for _, j := range journals { + if j.Success { + lastNotify = time.Unix(j.SentAt, 0) + break + } + } + // Do not notify if interval has not elapsed if sendReminder && !lastNotify.IsZero() && lastNotify.Add(frequency).After(time.Now()) { return false @@ -75,20 +88,12 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, c *alerting.EvalContext } 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)) + return defaultShouldNotify(c, n.SendReminder, n.Frequency, cmd.Result) } func (n *NotifierBase) GetType() string { diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 57b82f32466..9ea4b82fd54 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -15,51 +15,105 @@ 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 + journals []m.AlertNotificationJournal + + 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.AlertStatePending, + prevState: m.AlertStateOK, + sendReminder: false, + journals: []m.AlertNotificationJournal{}, + + 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.AlertStateOK, + prevState: m.AlertStateAlerting, + sendReminder: false, + journals: []m.AlertNotificationJournal{}, + + 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.AlertStateOK, + prevState: m.AlertStatePending, + sendReminder: false, + journals: []m.AlertNotificationJournal{}, + + expect: false, }, { name: "ok -> ok should not trigger an notification", newState: m.AlertStateOK, prevState: m.AlertStateOK, - expected: false, sendReminder: false, + journals: []m.AlertNotificationJournal{}, + + expect: false, }, { - name: "ok -> alerting should not trigger an notification", + name: "ok -> alerting should trigger an notification", newState: m.AlertStateOK, prevState: m.AlertStateAlerting, - expected: true, sendReminder: true, + journals: []m.AlertNotificationJournal{}, + + expect: true, }, { name: "ok -> ok with reminder should not trigger an notification", newState: m.AlertStateOK, prevState: m.AlertStateOK, - expected: false, sendReminder: true, + journals: []m.AlertNotificationJournal{}, + + expect: false, + }, + { + name: "alerting -> alerting with reminder and no journaling should trigger", + newState: m.AlertStateAlerting, + prevState: m.AlertStateAlerting, + frequency: time.Minute * 10, + sendReminder: true, + journals: []m.AlertNotificationJournal{}, + + expect: true, + }, + { + name: "alerting -> alerting with reminder and successful recent journal event should not trigger", + newState: m.AlertStateAlerting, + prevState: m.AlertStateAlerting, + frequency: time.Minute * 10, + sendReminder: true, + journals: []m.AlertNotificationJournal{ + {SentAt: tnow.Add(-time.Minute).Unix(), Success: true}, + }, + + expect: false, + }, + { + name: "alerting -> alerting with reminder and failed recent journal event should trigger", + newState: m.AlertStateAlerting, + prevState: m.AlertStateAlerting, + frequency: time.Minute * 10, + sendReminder: true, + expect: true, + journals: []m.AlertNotificationJournal{ + {SentAt: tnow.Add(-time.Minute).Unix(), Success: false}, // recent failed notification + {SentAt: tnow.Add(-time.Hour).Unix(), Success: true}, // old successful notification + }, }, } @@ -69,8 +123,8 @@ func TestShouldSendAlertNotification(t *testing.T) { }) 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) + if defaultShouldNotify(evalContext, true, tc.frequency, tc.journals) != tc.expect { + t.Errorf("failed test %s.\n expected \n%+v \nto return: %v", tc.name, tc, tc.expect) } } } @@ -87,16 +141,6 @@ func TestShouldNotifyWhenNoJournalingIsFound(t *testing.T) { }) 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") diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index 1c284ec3d2b..388cec79597 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -125,7 +125,7 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { case models.AlertStateOK: color = "green" case models.AlertStateNoData: - color = "grey" + color = "gray" case models.AlertStateAlerting: color = "red" } 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/notifiers/teams.go b/pkg/services/alerting/notifiers/teams.go index 09bcf600533..2dad11285b4 100644 --- a/pkg/services/alerting/notifiers/teams.go +++ b/pkg/services/alerting/notifiers/teams.go @@ -74,7 +74,7 @@ func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error { } message := "" - if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok. + if evalContext.Rule.State != m.AlertStateOK { //don't add message when going back to alert state ok. message = evalContext.Rule.Message } @@ -96,14 +96,26 @@ func (this *TeamsNotifier) Notify(evalContext *alerting.EvalContext) error { }, }, "text": message, - "potentialAction": []map[string]interface{}{ + }, + }, + "potentialAction": []map[string]interface{}{ + { + "@context": "http://schema.org", + "@type": "OpenUri", + "name": "View Rule", + "targets": []map[string]interface{}{ { - "@context": "http://schema.org", - "@type": "ViewAction", - "name": "View Rule", - "target": []string{ - ruleUrl, - }, + "os": "default", "uri": ruleUrl, + }, + }, + }, + { + "@context": "http://schema.org", + "@type": "OpenUri", + "name": "View Graph", + "targets": []map[string]interface{}{ + { + "os": "default", "uri": evalContext.ImagePublicUrl, }, }, }, diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 363d06d1132..893cca948f9 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -100,7 +100,7 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { } } } - handler.notifier.SendIfNeeded(evalContext) + handler.notifier.SendIfNeeded(evalContext) return nil } diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index 9b490169d3b..60a92aa897a 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -21,6 +21,7 @@ type ItemQuery struct { RegionId int64 `json:"regionId"` Tags []string `json:"tags"` Type string `json:"type"` + MatchAny bool `json:"matchAny"` Limit int64 `json:"limit"` } 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/provisioning/datasources/testdata/broken-yaml/commented.yaml b/pkg/services/provisioning/datasources/testdata/broken-yaml/commented.yaml index 1bb9cb53b45..b532c9012ec 100644 --- a/pkg/services/provisioning/datasources/testdata/broken-yaml/commented.yaml +++ b/pkg/services/provisioning/datasources/testdata/broken-yaml/commented.yaml @@ -4,7 +4,7 @@ # org_id: 1 # # list of datasources to insert/update depending -# # whats available in the datbase +# # what's available in the database #datasources: # # name of the datasource. Required # - name: Graphite diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index d47dfaeaae1..40259c44746 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -70,7 +70,7 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend return nil, ErrTimeout } - // if we didnt get a 200 response, something went wrong. + // if we didn't get a 200 response, something went wrong. if resp.StatusCode != http.StatusOK { rs.log.Error("Remote rendering request failed", "error", resp.Status) return nil, fmt.Errorf("Remote rendering request failed. %d: %s", resp.StatusCode, resp.Status) @@ -83,7 +83,7 @@ func (rs *RenderingService) renderViaHttp(ctx context.Context, opts Opts) (*Rend defer out.Close() _, err = io.Copy(out, resp.Body) if err != nil { - // check that we didnt timeout while receiving the response. + // check that we didn't timeout while receiving the response. if reqContext.Err() == context.DeadlineExceeded { rs.log.Info("Rendering timed out") return nil, ErrTimeout 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 ff4a67cc9b6..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:""` } @@ -45,7 +46,7 @@ func (rs *RenderingService) Init() error { // set value used for domain attribute of renderKey cookie if rs.Cfg.RendererUrl != "" { - // RendererCallbackUrl has already been passed, it wont generate an error. + // RendererCallbackUrl has already been passed, it won't generate an error. u, _ := url.Parse(rs.Cfg.RendererCallbackUrl) rs.domain = u.Hostname() } else if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR { @@ -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 af911dc22e6..ba898769578 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -40,7 +40,7 @@ func GetAlertById(query *m.GetAlertByIdQuery) error { func GetAllAlertQueryHandler(query *m.GetAllAlertsQuery) error { var alerts []*m.Alert - err := x.Sql("select * from alert").Find(&alerts) + err := x.SQL("select * from alert").Find(&alerts) if err != nil { return err } @@ -190,7 +190,7 @@ func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS alert.Updated = timeNow() alert.State = alertToUpdate.State sess.MustCols("message") - _, err := sess.Id(alert.Id).Update(alert) + _, err := sess.ID(alert.Id).Update(alert) if err != nil { return err } @@ -249,7 +249,7 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error { return inTransaction(func(sess *DBSession) error { alert := m.Alert{} - if has, err := sess.Id(cmd.AlertId).Get(&alert); err != nil { + if has, err := sess.ID(cmd.AlertId).Get(&alert); err != nil { return err } else if !has { return fmt.Errorf("Could not find alert") diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index 8fb1e2212a9..df247e6891d 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -119,7 +119,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS } results := make([]*m.AlertNotification, 0) - if err := sess.Sql(sql.String(), params...).Find(&results); err != nil { + if err := sess.SQL(sql.String(), params...).Find(&results); err != nil { return err } @@ -230,7 +230,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { } func RecordNotificationJournal(ctx context.Context, cmd *m.RecordNotificationJournalCommand) error { - return inTransactionCtx(ctx, func(sess *DBSession) error { + return withDbSession(ctx, func(sess *DBSession) error { journalEntry := &m.AlertNotificationJournal{ OrgId: cmd.OrgId, AlertId: cmd.AlertId, @@ -239,30 +239,25 @@ func RecordNotificationJournal(ctx context.Context, cmd *m.RecordNotificationJou Success: cmd.Success, } - if _, err := sess.Insert(journalEntry); err != nil { - return err - } - - return nil + _, err := sess.Insert(journalEntry) + return err }) } func GetLatestNotification(ctx context.Context, cmd *m.GetLatestNotificationQuery) error { - return inTransactionCtx(ctx, func(sess *DBSession) error { - nj := &m.AlertNotificationJournal{} + return withDbSession(ctx, func(sess *DBSession) error { + nj := []m.AlertNotificationJournal{} - _, 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.Desc("alert_notification_journal.sent_at"). + Where("alert_notification_journal.org_id = ?", cmd.OrgId). + Where("alert_notification_journal.alert_id = ?", cmd.AlertId). + Where("alert_notification_journal.notifier_id = ?", cmd.NotifierId). + Find(&nj) if err != nil { return err } - if nj.AlertId == 0 && nj.Id == 0 && nj.NotifierId == 0 && nj.OrgId == 0 { - return m.ErrJournalingNotFound - } - cmd.Result = nj return nil }) diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go index 83fb42db9bb..1e3df45b5cf 100644 --- a/pkg/services/sqlstore/alert_notification_test.go +++ b/pkg/services/sqlstore/alert_notification_test.go @@ -15,16 +15,21 @@ func TestAlertNotificationSQLAccess(t *testing.T) { InitTestDB(t) Convey("Alert notification journal", func() { - var alertId int64 = 5 + var alertId int64 = 7 var orgId int64 = 5 - var notifierId int64 = 5 + var notifierId int64 = 10 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) + GetLatestNotification(context.Background(), query) + So(len(query.Result), ShouldEqual, 0) - Convey("shoulbe be able to record two journaling events", func() { + // recording an journal entry in another org to make sure org filter works as expected. + journalInOtherOrg := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: 10, Success: true, SentAt: 1} + err := RecordNotificationJournal(context.Background(), journalInOtherOrg) + So(err, ShouldBeNil) + + Convey("should be able to record two journaling events", func() { createCmd := &m.RecordNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId, Success: true, SentAt: 1} err := RecordNotificationJournal(context.Background(), createCmd) @@ -38,17 +43,20 @@ func TestAlertNotificationSQLAccess(t *testing.T) { Convey("get last journaling event", func() { err := GetLatestNotification(context.Background(), query) So(err, ShouldBeNil) - So(query.Result.SentAt, ShouldEqual, 1001) + So(len(query.Result), ShouldEqual, 2) + last := query.Result[0] + So(last.SentAt, ShouldEqual, 1001) Convey("be able to clear all journaling for an notifier", func() { cmd := &m.CleanNotificationJournalCommand{AlertId: alertId, NotifierId: notifierId, OrgId: orgId} err := CleanNotificationJournal(context.Background(), cmd) So(err, ShouldBeNil) - Convey("querying for last junaling should raise error", func() { + Convey("querying for last journaling should return no journal entries", func() { query := &m.GetLatestNotificationQuery{AlertId: alertId, OrgId: orgId, NotifierId: notifierId} err := GetLatestNotification(context.Background(), query) - So(err, ShouldEqual, m.ErrJournalingNotFound) + So(err, ShouldBeNil) + So(len(query.Result), ShouldEqual, 0) }) }) }) diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index a65bc136554..274481baeca 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -110,7 +110,7 @@ func (r *SqlAnnotationRepo) Update(item *annotations.Item) error { existing.Tags = item.Tags - _, err = sess.Table("annotation").Id(existing.Id).Cols("epoch", "text", "region_id", "updated", "tags").Update(existing) + _, err = sess.Table("annotation").ID(existing.Id).Cols("epoch", "text", "region_id", "updated", "tags").Update(existing) return err }) } @@ -211,7 +211,12 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I ) `, strings.Join(keyValueFilters, " OR ")) - sql.WriteString(fmt.Sprintf(" AND (%s) = %d ", tagsSubQuery, len(tags))) + if query.MatchAny { + sql.WriteString(fmt.Sprintf(" AND (%s) > 0 ", tagsSubQuery)) + } else { + sql.WriteString(fmt.Sprintf(" AND (%s) = %d ", tagsSubQuery, len(tags))) + } + } } @@ -223,7 +228,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I items := make([]*annotations.ItemDTO, 0) - if err := x.Sql(sql.String(), params...).Find(&items); err != nil { + if err := x.SQL(sql.String(), params...).Find(&items); err != nil { return nil, err } diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go index c0d267f2578..d3459527e7d 100644 --- a/pkg/services/sqlstore/annotation_test.go +++ b/pkg/services/sqlstore/annotation_test.go @@ -78,7 +78,31 @@ func TestAnnotations(t *testing.T) { So(err, ShouldBeNil) So(annotation2.Id, ShouldBeGreaterThan, 0) - Convey("Can query for annotation", func() { + globalAnnotation1 := &annotations.Item{ + OrgId: 1, + UserId: 1, + Text: "deploy", + Type: "", + Epoch: 15, + Tags: []string{"deploy"}, + } + err = repo.Save(globalAnnotation1) + So(err, ShouldBeNil) + So(globalAnnotation1.Id, ShouldBeGreaterThan, 0) + + globalAnnotation2 := &annotations.Item{ + OrgId: 1, + UserId: 1, + Text: "rollback", + Type: "", + Epoch: 17, + Tags: []string{"rollback"}, + } + err = repo.Save(globalAnnotation2) + So(err, ShouldBeNil) + So(globalAnnotation2.Id, ShouldBeGreaterThan, 0) + + Convey("Can query for annotation by dashboard id", func() { items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, DashboardId: 1, @@ -165,7 +189,7 @@ func TestAnnotations(t *testing.T) { OrgId: 1, DashboardId: 1, From: 1, - To: 15, + To: 15, //this will exclude the second test annotation Tags: []string{"outage", "error"}, }) @@ -173,6 +197,19 @@ func TestAnnotations(t *testing.T) { So(items, ShouldHaveLength, 1) }) + Convey("Should find two annotations using partial match", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + From: 1, + To: 25, + MatchAny: true, + Tags: []string{"rollback", "deploy"}, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 2) + }) + Convey("Should find one when all key value tag filters does match", func() { items, err := repo.Find(&annotations.ItemQuery{ OrgId: 1, diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index aff532bb3b5..e43279208e7 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -225,7 +225,7 @@ func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSear var res []DashboardSearchProjection sql, params := sb.ToSql() - err := x.Sql(sql, params...).Find(&res) + err := x.SQL(sql, params...).Find(&res) if err != nil { return nil, err } @@ -295,10 +295,11 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error { FROM dashboard INNER JOIN dashboard_tag on dashboard_tag.dashboard_id = dashboard.id WHERE dashboard.org_id=? - GROUP BY term` + GROUP BY term + ORDER BY term` query.Result = make([]*m.DashboardTagCloudItem, 0) - sess := x.Sql(sql, query.OrgId) + sess := x.SQL(sql, query.OrgId) err := sess.Find(&query.Result) return err } @@ -412,7 +413,7 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery params = append(params, query.UserId) params = append(params, dialect.BooleanStr(false)) - err := x.Sql(sql, params...).Find(&query.Result) + err := x.SQL(sql, params...).Find(&query.Result) for _, p := range query.Result { p.PermissionName = p.Permission.String() @@ -631,7 +632,7 @@ func HasEditPermissionInFolders(query *m.HasEditPermissionInFoldersQuery) error } resp := make([]*folderCount, 0) - if err := x.Sql(builder.GetSqlString(), builder.params...).Find(&resp); err != nil { + if err := x.SQL(builder.GetSqlString(), builder.params...).Find(&resp); err != nil { return err } diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index d231d3283e2..49920dee490 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -105,7 +105,7 @@ func addAnnotationMig(mg *Migrator) { })) // - // Convert epoch saved as seconds to miliseconds + // Convert epoch saved as seconds to milliseconds // updateEpochSql := "UPDATE annotation SET epoch = (epoch*1000) where epoch < 9999999999" mg.AddMigration("Convert existing annotations from seconds to milliseconds", NewRawSqlMigration(updateEpochSql)) diff --git a/pkg/services/sqlstore/migrations/team_mig.go b/pkg/services/sqlstore/migrations/team_mig.go index 9800d27f8ab..34c46ad13cf 100644 --- a/pkg/services/sqlstore/migrations/team_mig.go +++ b/pkg/services/sqlstore/migrations/team_mig.go @@ -51,4 +51,7 @@ func addTeamMigrations(mg *Migrator) { Name: "email", Type: DB_NVarchar, Nullable: true, Length: 190, })) + mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{ + Name: "external", Type: DB_Bool, Nullable: true, + })) } diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index 400033aaa33..e273cb7d542 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -134,7 +134,7 @@ type TempUserDTO struct { func (m *AddMissingUserSaltAndRandsMigration) Exec(sess *xorm.Session, mg *Migrator) error { users := make([]*TempUserDTO, 0) - err := sess.Sql(fmt.Sprintf("SELECT id, login from %s WHERE rands = ''", mg.Dialect.Quote("user"))).Find(&users) + err := sess.SQL(fmt.Sprintf("SELECT id, login from %s WHERE rands = ''", mg.Dialect.Quote("user"))).Find(&users) if err != nil { return err } diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index 8931f1cf0f5..e36a80322d8 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -133,7 +133,7 @@ func UpdateOrg(cmd *m.UpdateOrgCommand) error { Updated: time.Now(), } - affectedRows, err := sess.Id(cmd.OrgId).Update(&org) + affectedRows, err := sess.ID(cmd.OrgId).Update(&org) if err != nil { return err @@ -166,7 +166,7 @@ func UpdateOrgAddress(cmd *m.UpdateOrgAddressCommand) error { Updated: time.Now(), } - if _, err := sess.Id(cmd.OrgId).Update(&org); err != nil { + if _, err := sess.ID(cmd.OrgId).Update(&org); err != nil { return err } diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index aad72cdacb4..14981cfde64 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -21,7 +21,7 @@ func AddOrgUser(cmd *m.AddOrgUserCommand) error { return inTransaction(func(sess *DBSession) error { // check if user exists var user m.User - if exists, err := sess.Id(cmd.UserId).Get(&user); err != nil { + if exists, err := sess.ID(cmd.UserId).Get(&user); err != nil { return err } else if !exists { return m.ErrUserNotFound @@ -85,7 +85,7 @@ func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error { orgUser.Role = cmd.Role orgUser.Updated = time.Now() - _, err = sess.Id(orgUser.Id).Update(&orgUser) + _, err = sess.ID(orgUser.Id).Update(&orgUser) if err != nil { return err } @@ -138,7 +138,7 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error { return inTransaction(func(sess *DBSession) error { // check if user exists var user m.User - if exists, err := sess.Id(cmd.UserId).Get(&user); err != nil { + if exists, err := sess.ID(cmd.UserId).Get(&user); err != nil { return err } else if !exists { return m.ErrUserNotFound diff --git a/pkg/services/sqlstore/plugin_setting.go b/pkg/services/sqlstore/plugin_setting.go index 676d26fad56..8fbf1b6be1c 100644 --- a/pkg/services/sqlstore/plugin_setting.go +++ b/pkg/services/sqlstore/plugin_setting.go @@ -26,7 +26,7 @@ func GetPluginSettings(query *m.GetPluginSettingsQuery) error { params = append(params, query.OrgId) } - sess := x.Sql(sql, params...) + sess := x.SQL(sql, params...) query.Result = make([]*m.PluginSettingInfoDTO, 0) return sess.Find(&query.Result) } @@ -100,7 +100,7 @@ func UpdatePluginSetting(cmd *m.UpdatePluginSettingCmd) error { pluginSetting.Pinned = cmd.Pinned pluginSetting.PluginVersion = cmd.PluginVersion - _, err = sess.Id(pluginSetting.Id).Update(&pluginSetting) + _, err = sess.ID(pluginSetting.Id).Update(&pluginSetting) return err }) } diff --git a/pkg/services/sqlstore/preferences.go b/pkg/services/sqlstore/preferences.go index 885837764fc..04e787971d9 100644 --- a/pkg/services/sqlstore/preferences.go +++ b/pkg/services/sqlstore/preferences.go @@ -94,7 +94,7 @@ func SavePreferences(cmd *m.SavePreferencesCommand) error { prefs.Theme = cmd.Theme prefs.Updated = time.Now() prefs.Version += 1 - _, err = sess.Id(prefs.Id).AllCols().Update(&prefs) + _, err = sess.ID(prefs.Id).AllCols().Update(&prefs) return err }) } diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index 539555ddc50..7005b341268 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -38,7 +38,7 @@ func GetOrgQuotaByTarget(query *m.GetOrgQuotaByTargetQuery) error { //get quota used. rawSql := fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(query.Target)) resp := make([]*targetCount, 0) - if err := x.Sql(rawSql, query.OrgId).Find(&resp); err != nil { + if err := x.SQL(rawSql, query.OrgId).Find(&resp); err != nil { return err } @@ -81,7 +81,7 @@ func GetOrgQuotas(query *m.GetOrgQuotasQuery) error { //get quota used. rawSql := fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(q.Target)) resp := make([]*targetCount, 0) - if err := x.Sql(rawSql, q.OrgId).Find(&resp); err != nil { + if err := x.SQL(rawSql, q.OrgId).Find(&resp); err != nil { return err } result[i] = &m.OrgQuotaDTO{ @@ -116,7 +116,7 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error { } } else { //update existing quota entry in the DB. - if _, err := sess.Id(quota.Id).Update("a); err != nil { + if _, err := sess.ID(quota.Id).Update("a); err != nil { return err } } @@ -140,7 +140,7 @@ func GetUserQuotaByTarget(query *m.GetUserQuotaByTargetQuery) error { //get quota used. rawSql := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(query.Target)) resp := make([]*targetCount, 0) - if err := x.Sql(rawSql, query.UserId).Find(&resp); err != nil { + if err := x.SQL(rawSql, query.UserId).Find(&resp); err != nil { return err } @@ -183,7 +183,7 @@ func GetUserQuotas(query *m.GetUserQuotasQuery) error { //get quota used. rawSql := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(q.Target)) resp := make([]*targetCount, 0) - if err := x.Sql(rawSql, q.UserId).Find(&resp); err != nil { + if err := x.SQL(rawSql, q.UserId).Find(&resp); err != nil { return err } result[i] = &m.UserQuotaDTO{ @@ -218,7 +218,7 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error { } } else { //update existing quota entry in the DB. - if _, err := sess.Id(quota.Id).Update("a); err != nil { + if _, err := sess.ID(quota.Id).Update("a); err != nil { return err } } @@ -231,7 +231,7 @@ func GetGlobalQuotaByTarget(query *m.GetGlobalQuotaByTargetQuery) error { //get quota used. rawSql := fmt.Sprintf("SELECT COUNT(*) as count from %s", dialect.Quote(query.Target)) resp := make([]*targetCount, 0) - if err := x.Sql(rawSql).Find(&resp); err != nil { + if err := x.SQL(rawSql).Find(&resp); err != nil { return err } diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index 6db481bf06b..2cec86e7239 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -13,11 +13,19 @@ func init() { bus.AddHandler("sql", GetDataSourceStats) bus.AddHandler("sql", GetDataSourceAccessStats) bus.AddHandler("sql", GetAdminStats) + bus.AddHandlerCtx("sql", GetAlertNotifiersUsageStats) bus.AddHandlerCtx("sql", GetSystemUserCountStats) } var activeUserTimeLimit = time.Hour * 24 * 30 +func GetAlertNotifiersUsageStats(ctx context.Context, query *m.GetAlertNotifierUsageStatsQuery) error { + var rawSql = `SELECT COUNT(*) as count, type FROM alert_notification GROUP BY type` + query.Result = make([]*m.NotifierUsageStats, 0) + err := x.SQL(rawSql).Find(&query.Result) + return err +} + func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error { var rawSql = `SELECT COUNT(*) as count, type FROM data_source GROUP BY type` query.Result = make([]*m.DataSourceStats, 0) diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go index dae24952d17..6949a0dbda2 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/sqlstore/stats_test.go @@ -36,5 +36,11 @@ func TestStatsDataAccess(t *testing.T) { err := GetDataSourceAccessStats(&query) So(err, ShouldBeNil) }) + + Convey("Get alert notifier stats should not results in error", func() { + query := m.GetAlertNotifierUsageStatsQuery{} + err := GetAlertNotifiersUsageStats(context.Background(), &query) + So(err, ShouldBeNil) + }) }) } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 72955df9a6a..a3010a086e5 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -74,7 +74,7 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error { sess.MustCols("email") - affectedRows, err := sess.Id(cmd.Id).Update(&team) + affectedRows, err := sess.ID(cmd.Id).Update(&team) if err != nil { return err @@ -169,7 +169,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error { sql.WriteString(dialect.LimitOffset(int64(query.Limit), int64(offset))) } - if err := x.Sql(sql.String(), params...).Find(&query.Result.Teams); err != nil { + if err := x.SQL(sql.String(), params...).Find(&query.Result.Teams); err != nil { return err } @@ -196,7 +196,7 @@ func GetTeamById(query *m.GetTeamByIdQuery) error { sql.WriteString(` WHERE team.org_id = ? and team.id = ?`) var team m.TeamDTO - exists, err := x.Sql(sql.String(), query.OrgId, query.Id).Get(&team) + exists, err := x.SQL(sql.String(), query.OrgId, query.Id).Get(&team) if err != nil { return err @@ -220,7 +220,7 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error { sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`) sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`) - err := x.Sql(sql.String(), query.OrgId, query.UserId).Find(&query.Result) + err := x.SQL(sql.String(), query.OrgId, query.UserId).Find(&query.Result) return err } @@ -240,11 +240,12 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { } entity := m.TeamMember{ - OrgId: cmd.OrgId, - TeamId: cmd.TeamId, - UserId: cmd.UserId, - Created: time.Now(), - Updated: time.Now(), + OrgId: cmd.OrgId, + TeamId: cmd.TeamId, + UserId: cmd.UserId, + External: cmd.External, + Created: time.Now(), + Updated: time.Now(), } _, err := sess.Insert(&entity) @@ -289,7 +290,10 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error { if query.UserId != 0 { sess.Where("team_member.user_id=?", query.UserId) } - sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login") + if query.External { + sess.Where("team_member.external=?", dialect.BooleanStr(true)) + } + sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external") sess.Asc("user.login", "user.email") err := sess.Find(&query.Result) diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index abaa973957d..8f243617262 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -50,13 +50,29 @@ func TestTeamCommandsAndQueries(t *testing.T) { err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team1.Id, UserId: userIds[0]}) So(err, ShouldBeNil) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team1.Id, UserId: userIds[1], External: true}) + So(err, ShouldBeNil) q1 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team1.Id} err = GetTeamMembers(q1) So(err, ShouldBeNil) + So(q1.Result, ShouldHaveLength, 2) So(q1.Result[0].TeamId, ShouldEqual, team1.Id) So(q1.Result[0].Login, ShouldEqual, "loginuser0") So(q1.Result[0].OrgId, ShouldEqual, testOrgId) + So(q1.Result[1].TeamId, ShouldEqual, team1.Id) + So(q1.Result[1].Login, ShouldEqual, "loginuser1") + So(q1.Result[1].OrgId, ShouldEqual, testOrgId) + So(q1.Result[1].External, ShouldEqual, true) + + q2 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team1.Id, External: true} + err = GetTeamMembers(q2) + So(err, ShouldBeNil) + So(q2.Result, ShouldHaveLength, 1) + So(q2.Result[0].TeamId, ShouldEqual, team1.Id) + So(q2.Result[0].Login, ShouldEqual, "loginuser1") + So(q2.Result[0].OrgId, ShouldEqual, testOrgId) + So(q2.Result[0].External, ShouldEqual, true) }) Convey("Should be able to search for teams", func() { diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go index e93ba2fd641..f13752f8038 100644 --- a/pkg/services/sqlstore/temp_user.go +++ b/pkg/services/sqlstore/temp_user.go @@ -96,7 +96,7 @@ func GetTempUsersQuery(query *m.GetTempUsersQuery) error { rawSql += " ORDER BY tu.created desc" query.Result = make([]*m.TempUserDTO, 0) - sess := x.Sql(rawSql, params...) + sess := x.SQL(rawSql, params...) err := sess.Find(&query.Result) return err } @@ -121,7 +121,7 @@ func GetTempUserByCode(query *m.GetTempUserByCodeQuery) error { WHERE tu.code=?` var tempUser m.TempUserDTO - sess := x.Sql(rawSql, query.Code) + sess := x.SQL(rawSql, query.Code) has, err := sess.Get(&tempUser) if err != nil { diff --git a/pkg/services/sqlstore/transactions_test.go b/pkg/services/sqlstore/transactions_test.go index 937649921ba..41dedde5db4 100644 --- a/pkg/services/sqlstore/transactions_test.go +++ b/pkg/services/sqlstore/transactions_test.go @@ -39,7 +39,7 @@ func TestTransaction(t *testing.T) { So(err, ShouldEqual, models.ErrInvalidApiKey) }) - Convey("wont update if one handler fails", func() { + Convey("won't update if one handler fails", func() { err := ss.InTransaction(context.Background(), func(ctx context.Context) error { err := DeleteApiKeyCtx(ctx, deleteApiKeyCmd) if err != nil { diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 5d1b827e79f..848a11d81ab 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -240,7 +240,7 @@ func UpdateUser(cmd *m.UpdateUserCommand) error { Updated: time.Now(), } - if _, err := sess.Id(cmd.UserId).Update(&user); err != nil { + if _, err := sess.ID(cmd.UserId).Update(&user); err != nil { return err } @@ -264,22 +264,19 @@ func ChangeUserPassword(cmd *m.ChangeUserPasswordCommand) error { Updated: time.Now(), } - _, err := sess.Id(cmd.UserId).Update(&user) + _, err := sess.ID(cmd.UserId).Update(&user) return err }) } func UpdateUserLastSeenAt(cmd *m.UpdateUserLastSeenAtCommand) error { return inTransaction(func(sess *DBSession) error { - if cmd.UserId <= 0 { - } - user := m.User{ Id: cmd.UserId, LastSeenAt: time.Now(), } - _, err := sess.Id(cmd.UserId).Update(&user) + _, err := sess.ID(cmd.UserId).Update(&user) return err }) } @@ -310,7 +307,7 @@ func setUsingOrgInTransaction(sess *DBSession, userID int64, orgID int64) error OrgId: orgID, } - _, err := sess.Id(userID).Update(&user) + _, err := sess.ID(userID).Update(&user) return err } @@ -372,11 +369,11 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { sess := x.Table("user") if query.UserId > 0 { - sess.Sql(rawSql+"WHERE u.id=?", query.UserId) + sess.SQL(rawSql+"WHERE u.id=?", query.UserId) } else if query.Login != "" { - sess.Sql(rawSql+"WHERE u.login=?", query.Login) + sess.SQL(rawSql+"WHERE u.login=?", query.Login) } else if query.Email != "" { - sess.Sql(rawSql+"WHERE u.email=?", query.Email) + sess.SQL(rawSql+"WHERE u.email=?", query.Email) } var user m.SignedInUser @@ -472,11 +469,11 @@ func DeleteUser(cmd *m.DeleteUserCommand) error { func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error { return inTransaction(func(sess *DBSession) error { user := m.User{} - sess.Id(cmd.UserId).Get(&user) + sess.ID(cmd.UserId).Get(&user) user.IsAdmin = cmd.IsGrafanaAdmin sess.UseBool("is_admin") - _, err := sess.Id(user.Id).Update(&user) + _, err := sess.ID(user.Id).Update(&user) return err }) } @@ -490,7 +487,7 @@ func SetUserHelpFlag(cmd *m.SetUserHelpFlagCommand) error { Updated: time.Now(), } - _, err := sess.Id(cmd.UserId).Cols("help_flags1").Update(&user) + _, err := sess.ID(cmd.UserId).Cols("help_flags1").Update(&user) return err }) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index fb23a192a85..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,13 +197,18 @@ 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 + + MetricsEndpointEnabled bool } type CommandLineArgs struct { @@ -659,6 +665,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.ImagesDir = filepath.Join(DataPath, "png") cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs") cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24) + cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true) analytics := iniFile.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) @@ -674,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/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index ee2e812415b..93b1ab6f101 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -5,6 +5,7 @@ type OAuthInfo struct { Scopes []string AuthUrl, TokenUrl string Enabled bool + EmailAttributeName string AllowedDomains []string HostedDomain string ApiUrl string diff --git a/pkg/setting/setting_test.go b/pkg/setting/setting_test.go index affb3c3e7ca..6524073e4da 100644 --- a/pkg/setting/setting_test.go +++ b/pkg/setting/setting_test.go @@ -97,7 +97,7 @@ func TestLoadingSettings(t *testing.T) { Args: []string{ "cfg:default.server.domain=test2", }, - Config: filepath.Join(HomePath, "tests/config-files/override.ini"), + Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), }) So(Domain, ShouldEqual, "test2") @@ -108,7 +108,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() cfg.Load(&CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "tests/config-files/override_windows.ini"), + Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), Args: []string{`cfg:default.paths.data=c:\tmp\data`}, }) @@ -117,7 +117,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() cfg.Load(&CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "tests/config-files/override.ini"), + Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), Args: []string{"cfg:default.paths.data=/tmp/data"}, }) @@ -130,7 +130,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() cfg.Load(&CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "tests/config-files/override_windows.ini"), + Config: filepath.Join(HomePath, "pkg/setting/testdata/override_windows.ini"), Args: []string{`cfg:paths.data=c:\tmp\data`}, }) @@ -139,7 +139,7 @@ func TestLoadingSettings(t *testing.T) { cfg := NewCfg() cfg.Load(&CommandLineArgs{ HomePath: "../../", - Config: filepath.Join(HomePath, "tests/config-files/override.ini"), + Config: filepath.Join(HomePath, "pkg/setting/testdata/override.ini"), Args: []string{"cfg:paths.data=/tmp/data"}, }) diff --git a/tests/config-files/override.ini b/pkg/setting/testdata/override.ini similarity index 100% rename from tests/config-files/override.ini rename to pkg/setting/testdata/override.ini diff --git a/tests/config-files/override_windows.ini b/pkg/setting/testdata/override_windows.ini similarity index 100% rename from tests/config-files/override_windows.ini rename to pkg/setting/testdata/override_windows.ini diff --git a/pkg/social/generic_oauth.go b/pkg/social/generic_oauth.go index 8c02076096d..a97d58334c7 100644 --- a/pkg/social/generic_oauth.go +++ b/pkg/social/generic_oauth.go @@ -20,6 +20,7 @@ type SocialGenericOAuth struct { allowedOrganizations []string apiUrl string allowSignup bool + emailAttributeName string teamIds []int } @@ -264,8 +265,9 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { return data.Email } - if data.Attributes["email:primary"] != nil { - return data.Attributes["email:primary"][0] + emails, ok := data.Attributes[s.emailAttributeName] + if ok && len(emails) != 0 { + return emails[0] } if data.Upn != "" { diff --git a/pkg/social/social.go b/pkg/social/social.go index 2be71514629..8918507f3b9 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -46,35 +46,39 @@ 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", grafanaCom} ) func NewOAuthService() { setting.OAuthService = &setting.OAuther{} setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) - allOauthes := []string{"github", "gitlab", "google", "generic_oauth", "grafananet", "grafana_com"} - for _, name := range allOauthes { sec := setting.Raw.Section("auth." + name) info := &setting.OAuthInfo{ - ClientId: sec.Key("client_id").String(), - ClientSecret: sec.Key("client_secret").String(), - Scopes: util.SplitString(sec.Key("scopes").String()), - AuthUrl: sec.Key("auth_url").String(), - TokenUrl: sec.Key("token_url").String(), - ApiUrl: sec.Key("api_url").String(), - Enabled: sec.Key("enabled").MustBool(), - AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), - HostedDomain: sec.Key("hosted_domain").String(), - AllowSignup: sec.Key("allow_sign_up").MustBool(), - Name: sec.Key("name").MustString(name), - TlsClientCert: sec.Key("tls_client_cert").String(), - TlsClientKey: sec.Key("tls_client_key").String(), - TlsClientCa: sec.Key("tls_client_ca").String(), - TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), + ClientId: sec.Key("client_id").String(), + ClientSecret: sec.Key("client_secret").String(), + Scopes: util.SplitString(sec.Key("scopes").String()), + AuthUrl: sec.Key("auth_url").String(), + TokenUrl: sec.Key("token_url").String(), + ApiUrl: sec.Key("api_url").String(), + Enabled: sec.Key("enabled").MustBool(), + EmailAttributeName: sec.Key("email_attribute_name").String(), + AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), + HostedDomain: sec.Key("hosted_domain").String(), + AllowSignup: sec.Key("allow_sign_up").MustBool(), + Name: sec.Key("name").MustString(name), + TlsClientCert: sec.Key("tls_client_cert").String(), + TlsClientKey: sec.Key("tls_client_key").String(), + TlsClientCa: sec.Key("tls_client_ca").String(), + TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), } if !info.Enabled { @@ -82,7 +86,7 @@ func NewOAuthService() { } if name == "grafananet" { - name = "grafana_com" + name = grafanaCom } setting.OAuthService.OAuthInfos[name] = info @@ -153,12 +157,13 @@ func NewOAuthService() { allowedDomains: info.AllowedDomains, apiUrl: info.ApiUrl, allowSignup: info.AllowSignup, + emailAttributeName: info.EmailAttributeName, teamIds: sec.Key("team_ids").Ints(","), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), } } - if name == "grafana_com" { + if name == grafanaCom { config = oauth2.Config{ ClientID: info.ClientId, ClientSecret: info.ClientSecret, @@ -170,7 +175,7 @@ func NewOAuthService() { Scopes: info.Scopes, } - SocialMap["grafana_com"] = &SocialGrafanaCom{ + SocialMap[grafanaCom] = &SocialGrafanaCom{ SocialBase: &SocialBase{ Config: &config, log: logger, @@ -182,3 +187,26 @@ func NewOAuthService() { } } } + +// GetOAuthProviders returns available oauth providers and if they're enabled or not +var GetOAuthProviders = func(cfg *setting.Cfg) map[string]bool { + result := map[string]bool{} + + if cfg == nil || cfg.Raw == nil { + return result + } + + for _, name := range allOauthes { + if name == "grafananet" { + name = grafanaCom + } + + sec := cfg.Raw.Section("auth." + name) + if sec == nil { + continue + } + result[name] = sec.Key("enabled").MustBool() + } + + return result +} diff --git a/pkg/tracing/tracing.go b/pkg/tracing/tracing.go index 61f45af3635..fd7258b7a0a 100644 --- a/pkg/tracing/tracing.go +++ b/pkg/tracing/tracing.go @@ -58,7 +58,8 @@ func (ts *TracingService) parseSettings() { func (ts *TracingService) initGlobalTracer() error { cfg := jaegercfg.Configuration{ - Disabled: !ts.enabled, + ServiceName: "grafana", + Disabled: !ts.enabled, Sampler: &jaegercfg.SamplerConfig{ Type: ts.samplerType, Param: ts.samplerParam, @@ -78,7 +79,7 @@ func (ts *TracingService) initGlobalTracer() error { options = append(options, jaegercfg.Tag(tag, value)) } - tracer, closer, err := cfg.New("grafana", options...) + tracer, closer, err := cfg.NewTracer(options...) if err != nil { return err } diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 92352a51315..be14c6f96ec 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -196,7 +196,7 @@ func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatch params.ExtendedStatistics = query.ExtendedStatistics } - // 1 minutes resolutin metrics is stored for 15 days, 15 * 24 * 60 = 21600 + // 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600 if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) { return nil, errors.New("too long query period") } @@ -267,7 +267,7 @@ func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, regi ScanBy: aws.String("TimestampAscending"), } for _, query := range queries { - // 1 minutes resolutin metrics is stored for 15 days, 15 * 24 * 60 = 21600 + // 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600 if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) { return nil, errors.New("too long query period") } diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index ef1b53eaf1b..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) @@ -466,6 +466,9 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context, return nil, errors.New("invalid attribute path") } v = v.FieldByName(key) + if !v.IsValid() { + return nil, errors.New("invalid attribute path") + } } if attr, ok := v.Interface().(*string); ok { data = *attr diff --git a/pkg/tsdb/elasticsearch/client/client.go b/pkg/tsdb/elasticsearch/client/client.go index dff626a79eb..4ebe0db8f89 100644 --- a/pkg/tsdb/elasticsearch/client/client.go +++ b/pkg/tsdb/elasticsearch/client/client.go @@ -138,13 +138,13 @@ func (c *baseClientImpl) encodeBatchRequests(requests []*multiRequest) ([]byte, } body := string(reqBody) - body = strings.Replace(body, "$__interval_ms", strconv.FormatInt(r.interval.Value.Nanoseconds()/int64(time.Millisecond), 10), -1) + body = strings.Replace(body, "$__interval_ms", strconv.FormatInt(r.interval.Milliseconds(), 10), -1) body = strings.Replace(body, "$__interval", r.interval.Text, -1) payload.WriteString(body + "\n") } - elapsed := time.Now().Sub(start) + elapsed := time.Since(start) clientLog.Debug("Encoded batch requests to json", "took", elapsed) return payload.Bytes(), nil @@ -187,7 +187,7 @@ func (c *baseClientImpl) executeRequest(method, uriPath string, body []byte) (*h start := time.Now() defer func() { - elapsed := time.Now().Sub(start) + elapsed := time.Since(start) clientLog.Debug("Executed request", "took", elapsed) }() return ctxhttp.Do(c.ctx, httpClient, req) @@ -215,7 +215,7 @@ func (c *baseClientImpl) ExecuteMultisearch(r *MultiSearchRequest) (*MultiSearch return nil, err } - elapsed := time.Now().Sub(start) + elapsed := time.Since(start) clientLog.Debug("Decoded multisearch json response", "took", elapsed) msr.Status = res.StatusCode diff --git a/pkg/tsdb/elasticsearch/client/client_test.go b/pkg/tsdb/elasticsearch/client/client_test.go index 11d1cdb1d71..af9ac0d8fce 100644 --- a/pkg/tsdb/elasticsearch/client/client_test.go +++ b/pkg/tsdb/elasticsearch/client/client_test.go @@ -40,7 +40,7 @@ func TestClient(t *testing.T) { So(err, ShouldNotBeNil) }) - Convey("When unspported version set should return error", func() { + Convey("When unsupported version set should return error", func() { ds := &models.DataSource{ JsonData: simplejson.NewFromAny(map[string]interface{}{ "esVersion": 6, diff --git a/pkg/tsdb/elasticsearch/client/search_request.go b/pkg/tsdb/elasticsearch/client/search_request.go index 2b833ce78d3..4c577a2c31d 100644 --- a/pkg/tsdb/elasticsearch/client/search_request.go +++ b/pkg/tsdb/elasticsearch/client/search_request.go @@ -56,9 +56,7 @@ func (b *SearchRequestBuilder) Build() (*SearchRequest, error) { if err != nil { return nil, err } - for _, agg := range aggArray { - sr.Aggs = append(sr.Aggs, agg) - } + sr.Aggs = append(sr.Aggs, aggArray...) } } @@ -112,7 +110,7 @@ func (b *SearchRequestBuilder) Query() *QueryBuilder { return b.queryBuilder } -// Agg initaite and returns a new aggregation builder +// Agg initiate and returns a new aggregation builder func (b *SearchRequestBuilder) Agg() AggBuilder { aggBuilder := newAggBuilder() b.aggBuilders = append(b.aggBuilders, aggBuilder) @@ -300,9 +298,7 @@ func (b *aggBuilderImpl) Build() (AggArray, error) { return nil, err } - for _, childAgg := range childAggs { - agg.Aggregation.Aggs = append(agg.Aggregation.Aggs, childAgg) - } + agg.Aggregation.Aggs = append(agg.Aggregation.Aggs, childAggs...) } aggs = append(aggs, agg) diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 7bdab60389c..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) @@ -92,7 +105,7 @@ func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Qu } else { for _, b := range esAgg.Get("buckets").MustArray() { bucket := simplejson.NewFromAny(b) - newProps := make(map[string]string, 0) + newProps := make(map[string]string) for k, v := range props { newProps[k] = v @@ -122,7 +135,7 @@ func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Qu for _, bucketKey := range bucketKeys { bucket := simplejson.NewFromAny(buckets[bucketKey]) - newProps := make(map[string]string, 0) + newProps := make(map[string]string) for k, v := range props { newProps[k] = v @@ -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,10 +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"))) - break - case "extended_stats": + case extendedStatsType: metaKeys := make([]string, 0) meta := metric.Meta.MustMap() for k := range meta { @@ -355,7 +367,6 @@ func (rp *responseParser) processAggregationDocs(esAgg *simplejson.Json, aggDef } addMetricValue(&values, metricName, castToNullFloat(bucket.GetPath(metric.ID, "value"))) - break } } @@ -368,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/influxdb/query.go b/pkg/tsdb/influxdb/query.go index 0637a5bbb44..7cb8f0ecd82 100644 --- a/pkg/tsdb/influxdb/query.go +++ b/pkg/tsdb/influxdb/query.go @@ -4,7 +4,6 @@ import ( "fmt" "strconv" "strings" - "time" "regexp" @@ -34,7 +33,7 @@ func (query *Query) Build(queryContext *tsdb.TsdbQuery) (string, error) { res = strings.Replace(res, "$timeFilter", query.renderTimeFilter(queryContext), -1) res = strings.Replace(res, "$interval", interval.Text, -1) - res = strings.Replace(res, "$__interval_ms", strconv.FormatInt(interval.Value.Nanoseconds()/int64(time.Millisecond), 10), -1) + res = strings.Replace(res, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10), -1) res = strings.Replace(res, "$__interval", interval.Text, -1) return res, nil } diff --git a/pkg/tsdb/influxdb/query_test.go b/pkg/tsdb/influxdb/query_test.go index f1270560269..cc1358a72d7 100644 --- a/pkg/tsdb/influxdb/query_test.go +++ b/pkg/tsdb/influxdb/query_test.go @@ -158,7 +158,7 @@ func TestInfluxdbQueryBuilder(t *testing.T) { So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" < 10001`) }) - Convey("can render number greather then condition tags", func() { + Convey("can render number greater then condition tags", func() { query := &Query{Tags: []*Tag{{Operator: ">", Value: "10001", Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" > 10001`) diff --git a/pkg/tsdb/interval.go b/pkg/tsdb/interval.go index 49904f27a37..fd6adee39d7 100644 --- a/pkg/tsdb/interval.go +++ b/pkg/tsdb/interval.go @@ -49,6 +49,10 @@ func NewIntervalCalculator(opt *IntervalOptions) *intervalCalculator { return calc } +func (i *Interval) Milliseconds() int64 { + return i.Value.Nanoseconds() / int64(time.Millisecond) +} + func (ic *intervalCalculator) Calculate(timerange *TimeRange, minInterval time.Duration) Interval { to := timerange.MustGetTo().UnixNano() from := timerange.MustGetFrom().UnixNano() diff --git a/pkg/tsdb/mssql/macros.go b/pkg/tsdb/mssql/macros.go index caba043e7b6..9303712a480 100644 --- a/pkg/tsdb/mssql/macros.go +++ b/pkg/tsdb/mssql/macros.go @@ -13,12 +13,13 @@ const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type msSqlMacroEngine struct { + *tsdb.SqlMacroEngineBase timeRange *tsdb.TimeRange query *tsdb.Query } func newMssqlMacroEngine() tsdb.SqlMacroEngine { - return &msSqlMacroEngine{} + return &msSqlMacroEngine{SqlMacroEngineBase: tsdb.NewSqlMacroEngineBase()} } func (m *msSqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { @@ -27,7 +28,7 @@ func (m *msSqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRa rExp, _ := regexp.Compile(sExpr) var macroError error - sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { + sql = m.ReplaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { args := strings.Split(groups[2], ",") for i, arg := range args { args[i] = strings.Trim(arg, " ") @@ -47,23 +48,6 @@ func (m *msSqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRa return sql, nil } -func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { - result := "" - lastIndex := 0 - - for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { - groups := []string{} - for i := 0; i < len(v); i += 2 { - groups = append(groups, str[v[i]:v[i+1]]) - } - - result += str[lastIndex:v[0]] + repl(groups) - lastIndex = v[1] - } - - return result + str[lastIndex:] -} - func (m *msSqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { case "__time": diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index 30d1da3bda1..f9525fc37ac 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -35,6 +35,11 @@ func TestMSSQL(t *testing.T) { return x, nil } + origInterpolate := tsdb.Interpolate + tsdb.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + return sql, nil + } + endpoint, err := newMssqlQueryEndpoint(&models.DataSource{ JsonData: simplejson.New(), SecureJsonData: securejsondata.SecureJsonData{}, @@ -47,6 +52,7 @@ func TestMSSQL(t *testing.T) { Reset(func() { sess.Close() tsdb.NewXormEngine = origXormEngine + tsdb.Interpolate = origInterpolate }) Convey("Given a table with different native data types", func() { @@ -295,6 +301,40 @@ func TestMSSQL(t *testing.T) { }) + Convey("When doing a metric query using timeGroup and $__interval", func() { + mockInterpolate := tsdb.Interpolate + tsdb.Interpolate = origInterpolate + + Reset(func() { + tsdb.Interpolate = mockInterpolate + }) + + Convey("Should replace $__interval", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + DataSource: &models.DataSource{}, + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY $__timeGroup(time, $__interval) ORDER BY 1", + "format": "time_series", + }), + RefId: "A", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(30*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT FLOOR(DATEDIFF(second, '1970-01-01', time)/60)*60 AS time, avg(value) as value FROM metric GROUP BY FLOOR(DATEDIFF(second, '1970-01-01', time)/60)*60 ORDER BY 1") + }) + }) + Convey("When doing a metric query using timeGroup with float fill enabled", func() { query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index 0dabdd7c283..0f1c4fcaf2c 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -9,17 +9,17 @@ import ( "github.com/grafana/grafana/pkg/tsdb" ) -//const rsString = `(?:"([^"]*)")`; const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type mySqlMacroEngine struct { + *tsdb.SqlMacroEngineBase timeRange *tsdb.TimeRange query *tsdb.Query } func newMysqlMacroEngine() tsdb.SqlMacroEngine { - return &mySqlMacroEngine{} + return &mySqlMacroEngine{SqlMacroEngineBase: tsdb.NewSqlMacroEngineBase()} } func (m *mySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { @@ -28,7 +28,7 @@ func (m *mySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRa rExp, _ := regexp.Compile(sExpr) var macroError error - sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { + sql = m.ReplaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { args := strings.Split(groups[2], ",") for i, arg := range args { args[i] = strings.Trim(arg, " ") @@ -48,23 +48,6 @@ func (m *mySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRa return sql, nil } -func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { - result := "" - lastIndex := 0 - - for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { - groups := []string{} - for i := 0; i < len(v); i += 2 { - groups = append(groups, str[v[i]:v[i+1]]) - } - - result += str[lastIndex:v[0]] + repl(groups) - lastIndex = v[1] - } - - return result + str[lastIndex:] -} - func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { case "__timeEpoch", "__time": diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index ca6df8e360e..13d9040a738 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -42,6 +42,11 @@ func TestMySQL(t *testing.T) { return x, nil } + origInterpolate := tsdb.Interpolate + tsdb.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + return sql, nil + } + endpoint, err := newMysqlQueryEndpoint(&models.DataSource{ JsonData: simplejson.New(), SecureJsonData: securejsondata.SecureJsonData{}, @@ -54,6 +59,7 @@ func TestMySQL(t *testing.T) { Reset(func() { sess.Close() tsdb.NewXormEngine = origXormEngine + tsdb.Interpolate = origInterpolate }) Convey("Given a table with different native data types", func() { @@ -295,6 +301,40 @@ func TestMySQL(t *testing.T) { }) + Convey("When doing a metric query using timeGroup and $__interval", func() { + mockInterpolate := tsdb.Interpolate + tsdb.Interpolate = origInterpolate + + Reset(func() { + tsdb.Interpolate = mockInterpolate + }) + + Convey("Should replace $__interval", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + DataSource: &models.DataSource{}, + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series", + }), + RefId: "A", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(30*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT UNIX_TIMESTAMP(time) DIV 60 * 60 AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1") + }) + }) + Convey("When doing a metric query using timeGroup with value fill enabled", func() { query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go index 0a2ea1d2af6..16f4adb68a6 100644 --- a/pkg/tsdb/postgres/macros.go +++ b/pkg/tsdb/postgres/macros.go @@ -9,18 +9,21 @@ import ( "github.com/grafana/grafana/pkg/tsdb" ) -//const rsString = `(?:"([^"]*)")`; const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` type postgresMacroEngine struct { + *tsdb.SqlMacroEngineBase timeRange *tsdb.TimeRange query *tsdb.Query timescaledb bool } func newPostgresMacroEngine(timescaledb bool) tsdb.SqlMacroEngine { - return &postgresMacroEngine{timescaledb: timescaledb} + return &postgresMacroEngine{ + SqlMacroEngineBase: tsdb.NewSqlMacroEngineBase(), + timescaledb: timescaledb, + } } func (m *postgresMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { @@ -29,7 +32,7 @@ func (m *postgresMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.Tim rExp, _ := regexp.Compile(sExpr) var macroError error - sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { + sql = m.ReplaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { // detect if $__timeGroup is supposed to add AS time for pre 5.3 compatibility // if there is a ',' directly after the macro call $__timeGroup is probably used @@ -66,23 +69,6 @@ func (m *postgresMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.Tim return sql, nil } -func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { - result := "" - lastIndex := 0 - - for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { - groups := []string{} - for i := 0; i < len(v); i += 2 { - groups = append(groups, str[v[i]:v[i+1]]) - } - - result += str[lastIndex:v[0]] + repl(groups) - lastIndex = v[1] - } - - return result + str[lastIndex:] -} - func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { case "__time": diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go index 4e05f676682..fc1a5f34253 100644 --- a/pkg/tsdb/postgres/postgres_test.go +++ b/pkg/tsdb/postgres/postgres_test.go @@ -43,6 +43,11 @@ func TestPostgres(t *testing.T) { return x, nil } + origInterpolate := tsdb.Interpolate + tsdb.Interpolate = func(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) { + return sql, nil + } + endpoint, err := newPostgresQueryEndpoint(&models.DataSource{ JsonData: simplejson.New(), SecureJsonData: securejsondata.SecureJsonData{}, @@ -55,6 +60,7 @@ func TestPostgres(t *testing.T) { Reset(func() { sess.Close() tsdb.NewXormEngine = origXormEngine + tsdb.Interpolate = origInterpolate }) Convey("Given a table with different native data types", func() { @@ -222,6 +228,40 @@ func TestPostgres(t *testing.T) { } }) + Convey("When doing a metric query using timeGroup and $__interval", func() { + mockInterpolate := tsdb.Interpolate + tsdb.Interpolate = origInterpolate + + Reset(func() { + tsdb.Interpolate = mockInterpolate + }) + + Convey("Should replace $__interval", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + DataSource: &models.DataSource{}, + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT $__timeGroup(time, $__interval) AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1", + "format": "time_series", + }), + RefId: "A", + }, + }, + TimeRange: &tsdb.TimeRange{ + From: fmt.Sprintf("%v", fromStart.Unix()*1000), + To: fmt.Sprintf("%v", fromStart.Add(30*time.Minute).Unix()*1000), + }, + } + + resp, err := endpoint.Query(nil, nil, query) + So(err, ShouldBeNil) + queryResult := resp.Results["A"] + So(queryResult.Error, ShouldBeNil) + So(queryResult.Meta.Get("sql").MustString(), ShouldEqual, "SELECT floor(extract(epoch from time)/60)*60 AS time, avg(value) as value FROM metric GROUP BY 1 ORDER BY 1") + }) + }) + Convey("When doing a metric query using timeGroup with NULL fill enabled", func() { query := &tsdb.TsdbQuery{ Queries: []*tsdb.Query{ diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index bf9fe9f152c..83bb683fccf 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -92,12 +92,12 @@ func (e *PrometheusExecutor) Query(ctx context.Context, dsInfo *models.DataSourc return nil, err } - querys, err := parseQuery(dsInfo, tsdbQuery.Queries, tsdbQuery) + queries, err := parseQuery(dsInfo, tsdbQuery.Queries, tsdbQuery) if err != nil { return nil, err } - for _, query := range querys { + for _, query := range queries { timeRange := apiv1.Range{ Start: query.Start, End: query.End, diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sql_engine.go index 454853c7cc8..18e02e328d1 100644 --- a/pkg/tsdb/sql_engine.go +++ b/pkg/tsdb/sql_engine.go @@ -6,6 +6,7 @@ import ( "database/sql" "fmt" "math" + "regexp" "strconv" "strings" "sync" @@ -43,6 +44,8 @@ var engineCache = engineCacheType{ versions: make(map[int64]int), } +var sqlIntervalCalculator = NewIntervalCalculator(nil) + var NewXormEngine = func(driverName string, connectionString string) (*xorm.Engine, error) { return xorm.NewEngine(driverName, connectionString) } @@ -126,7 +129,15 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, queryResult := &QueryResult{Meta: simplejson.New(), RefId: query.RefId} result.Results[query.RefId] = queryResult - rawSQL, err := e.macroEngine.Interpolate(query, tsdbQuery.TimeRange, rawSQL) + // global substitutions + rawSQL, err := Interpolate(query, tsdbQuery.TimeRange, rawSQL) + if err != nil { + queryResult.Error = err + continue + } + + // datasource specific substitutions + rawSQL, err = e.macroEngine.Interpolate(query, tsdbQuery.TimeRange, rawSQL) if err != nil { queryResult.Error = err continue @@ -163,6 +174,20 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, return result, nil } +// global macros/substitutions for all sql datasources +var Interpolate = func(query *Query, timeRange *TimeRange, sql string) (string, error) { + minInterval, err := GetIntervalFrom(query.DataSource, query.Model, time.Second*60) + if err != nil { + return sql, nil + } + interval := sqlIntervalCalculator.Calculate(timeRange, minInterval) + + sql = strings.Replace(sql, "$__interval_ms", strconv.FormatInt(interval.Milliseconds(), 10), -1) + sql = strings.Replace(sql, "$__interval", interval.Text, -1) + + return sql, nil +} + func (e *sqlQueryEndpoint) transformToTable(query *Query, rows *core.Rows, result *QueryResult, tsdbQuery *TsdbQuery) error { columnNames, err := rows.Columns() columnCount := len(columnNames) @@ -589,3 +614,26 @@ func SetupFillmode(query *Query, interval time.Duration, fillmode string) error return nil } + +type SqlMacroEngineBase struct{} + +func NewSqlMacroEngineBase() *SqlMacroEngineBase { + return &SqlMacroEngineBase{} +} + +func (m *SqlMacroEngineBase) ReplaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { + result := "" + lastIndex := 0 + + for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { + groups := []string{} + for i := 0; i < len(v); i += 2 { + groups = append(groups, str[v[i]:v[i+1]]) + } + + result += str[lastIndex:v[0]] + repl(groups) + lastIndex = v[1] + } + + return result + str[lastIndex:] +} diff --git a/pkg/tsdb/sql_engine_test.go b/pkg/tsdb/sql_engine_test.go index 854734fac31..05b8a51ae6f 100644 --- a/pkg/tsdb/sql_engine_test.go +++ b/pkg/tsdb/sql_engine_test.go @@ -5,6 +5,8 @@ import ( "time" "github.com/grafana/grafana/pkg/components/null" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" ) @@ -14,6 +16,35 @@ func TestSqlEngine(t *testing.T) { dt := time.Date(2018, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) earlyDt := time.Date(1970, 3, 14, 21, 20, 6, int(527345*time.Microsecond), time.UTC) + Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() { + from := time.Date(2018, 4, 12, 18, 0, 0, 0, time.UTC) + to := from.Add(5 * time.Minute) + timeRange := NewFakeTimeRange("5m", "now", to) + query := &Query{DataSource: &models.DataSource{}, Model: simplejson.New()} + + Convey("interpolate $__interval", func() { + sql, err := Interpolate(query, timeRange, "select $__interval ") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select 1m ") + }) + + Convey("interpolate $__interval in $__timeGroup", func() { + sql, err := Interpolate(query, timeRange, "select $__timeGroupAlias(time,$__interval)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select $__timeGroupAlias(time,1m)") + }) + + Convey("interpolate $__interval_ms", func() { + sql, err := Interpolate(query, timeRange, "select $__interval_ms ") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select 60000 ") + }) + + }) + Convey("Given row values with time.Time as time columns", func() { var nilPointer *time.Time 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..b460de6cdbc --- /dev/null +++ b/pkg/tsdb/stackdriver/stackdriver_test.go @@ -0,0 +1,360 @@ +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) + if err != nil { + return data, err + } + return data, nil +} 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/pkg/util/md5_test.go b/pkg/util/md5_test.go index 1338d42bb51..43c685b8763 100644 --- a/pkg/util/md5_test.go +++ b/pkg/util/md5_test.go @@ -3,14 +3,14 @@ package util import "testing" func TestMd5Sum(t *testing.T) { - input := "dont hash passwords with md5" + input := "don't hash passwords with md5" have, err := Md5SumString(input) if err != nil { t.Fatal("expected err to be nil") } - want := "2d6a56c82d09d374643b926d3417afba" + want := "dd1f7fdb3466c0d09c2e839d1f1530f8" if have != want { t.Fatalf("expected: %s got: %s", want, have) } diff --git a/public/app/containers/AlertRuleList/AlertRuleList.test.tsx b/public/app/containers/AlertRuleList/AlertRuleList.test.tsx deleted file mode 100644 index f88ff4522d4..00000000000 --- a/public/app/containers/AlertRuleList/AlertRuleList.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import moment from 'moment'; -import { AlertRuleList } from './AlertRuleList'; -import { RootStore } from 'app/stores/RootStore/RootStore'; -import { backendSrv, createNavTree } from 'test/mocks/common'; -import { mount } from 'enzyme'; -import toJson from 'enzyme-to-json'; - -describe('AlertRuleList', () => { - let page, store; - - beforeAll(() => { - backendSrv.get.mockReturnValue( - Promise.resolve([ - { - id: 11, - dashboardId: 58, - panelId: 3, - name: 'Panel Title alert', - state: 'ok', - newStateDate: moment() - .subtract(5, 'minutes') - .format(), - evalData: {}, - executionError: '', - url: 'd/ufkcofof/my-goal', - canEdit: true, - }, - ]) - ); - - store = RootStore.create( - {}, - { - backendSrv: backendSrv, - navTree: createNavTree('alerting', 'alert-list'), - } - ); - - page = mount(); - }); - - it('should call api to get rules', () => { - expect(backendSrv.get.mock.calls[0][0]).toEqual('/api/alerts'); - }); - - it('should render 1 rule', () => { - page.update(); - const ruleNode = page.find('.alert-rule-item'); - expect(toJson(ruleNode)).toMatchSnapshot(); - }); - - it('toggle state should change pause rule if not paused', async () => { - backendSrv.post.mockReturnValue( - Promise.resolve({ - state: 'paused', - }) - ); - - page.find('.fa-pause').simulate('click'); - - // wait for api call to resolve - await Promise.resolve(); - page.update(); - - expect(store.alertList.rules[0].state).toBe('paused'); - expect(page.find('.fa-play')).toHaveLength(1); - }); -}); diff --git a/public/app/containers/AlertRuleList/AlertRuleList.tsx b/public/app/containers/AlertRuleList/AlertRuleList.tsx deleted file mode 100644 index 668136dee6f..00000000000 --- a/public/app/containers/AlertRuleList/AlertRuleList.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import classNames from 'classnames'; -import { inject, observer } from 'mobx-react'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import { AlertRule } from 'app/stores/AlertListStore/AlertListStore'; -import appEvents from 'app/core/app_events'; -import ContainerProps from 'app/containers/ContainerProps'; -import Highlighter from 'react-highlight-words'; - -@inject('view', 'nav', 'alertList') -@observer -export class AlertRuleList extends React.Component { - stateFilters = [ - { text: 'All', value: 'all' }, - { text: 'OK', value: 'ok' }, - { text: 'Not OK', value: 'not_ok' }, - { text: 'Alerting', value: 'alerting' }, - { text: 'No Data', value: 'no_data' }, - { text: 'Paused', value: 'paused' }, - ]; - - constructor(props) { - super(props); - - this.props.nav.load('alerting', 'alert-list'); - this.fetchRules(); - } - - onStateFilterChanged = evt => { - this.props.view.updateQuery({ state: evt.target.value }); - this.fetchRules(); - }; - - fetchRules() { - this.props.alertList.loadRules({ - state: this.props.view.query.get('state') || 'all', - }); - } - - onOpenHowTo = () => { - appEvents.emit('show-modal', { - src: 'public/app/features/alerting/partials/alert_howto.html', - modalClass: 'confirm-modal', - model: {}, - }); - }; - - onSearchQueryChange = evt => { - this.props.alertList.setSearchQuery(evt.target.value); - }; - - render() { - const { nav, alertList } = this.props; - - return ( -
- -
-
-
- -
-
- - -
- -
-
- - - -
-
    - {alertList.filteredRules.map(rule => ( - - ))} -
-
-
-
- ); - } -} - -function AlertStateFilterOption({ text, value }) { - return ( - - ); -} - -export interface AlertRuleItemProps { - rule: AlertRule; - search: string; -} - -@observer -export class AlertRuleItem extends React.Component { - toggleState = () => { - this.props.rule.togglePaused(); - }; - - renderText(text: string) { - return ( - - ); - } - - render() { - const { rule } = this.props; - - const stateClass = classNames({ - fa: true, - 'fa-play': rule.isPaused, - 'fa-pause': !rule.isPaused, - }); - - const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`; - - return ( -
  • - - - -
    -
    - -
    - {this.renderText(rule.stateText)} - for {rule.stateAge} -
    -
    - {rule.info &&
    {this.renderText(rule.info)}
    } -
    - -
    - - - - -
    -
  • - ); - } -} - -export default hot(module)(AlertRuleList); diff --git a/public/app/containers/ContainerProps.ts b/public/app/containers/ContainerProps.ts deleted file mode 100644 index 97889278fdc..00000000000 --- a/public/app/containers/ContainerProps.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { SearchStore } from './../stores/SearchStore/SearchStore'; -import { ServerStatsStore } from './../stores/ServerStatsStore/ServerStatsStore'; -import { NavStore } from './../stores/NavStore/NavStore'; -import { PermissionsStore } from './../stores/PermissionsStore/PermissionsStore'; -import { AlertListStore } from './../stores/AlertListStore/AlertListStore'; -import { ViewStore } from './../stores/ViewStore/ViewStore'; -import { FolderStore } from './../stores/FolderStore/FolderStore'; - -interface ContainerProps { - search: typeof SearchStore.Type; - serverStats: typeof ServerStatsStore.Type; - nav: typeof NavStore.Type; - alertList: typeof AlertListStore.Type; - permissions: typeof PermissionsStore.Type; - view: typeof ViewStore.Type; - folder: typeof FolderStore.Type; - backendSrv: any; -} - -export default ContainerProps; diff --git a/public/app/containers/Explore/Wrapper.tsx b/public/app/containers/Explore/Wrapper.tsx deleted file mode 100644 index 6bdbd7cc42f..00000000000 --- a/public/app/containers/Explore/Wrapper.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { PureComponent } from 'react'; - -import Explore from './Explore'; - -export default class Wrapper extends PureComponent { - state = { - initialState: null, - split: false, - }; - - handleChangeSplit = (split, initialState) => { - this.setState({ split, initialState }); - }; - - render() { - // State overrides for props from first Explore - const { initialState, split } = this.state; - return ( -
    - - {split ? ( - - ) : null} -
    - ); - } -} diff --git a/public/app/containers/Explore/slate-plugins/prism/index.tsx b/public/app/containers/Explore/slate-plugins/prism/index.tsx deleted file mode 100644 index d185518790f..00000000000 --- a/public/app/containers/Explore/slate-plugins/prism/index.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import Prism from 'prismjs'; - -const TOKEN_MARK = 'prism-token'; - -export function setPrismTokens(language, field, values, alias = 'variable') { - Prism.languages[language][field] = { - alias, - pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`), - }; -} - -/** - * Code-highlighting plugin based on Prism and - * https://github.com/ianstormtaylor/slate/blob/master/examples/code-highlighting/index.js - * - * (Adapted to handle nested grammar definitions.) - */ - -export default function PrismPlugin({ definition, language }) { - if (definition) { - // Don't override exising modified definitions - Prism.languages[language] = Prism.languages[language] || definition; - } - - return { - /** - * Render a Slate mark with appropiate CSS class names - * - * @param {Object} props - * @return {Element} - */ - - renderMark(props) { - const { children, mark } = props; - // Only apply spans to marks identified by this plugin - if (mark.type !== TOKEN_MARK) { - return undefined; - } - const className = `token ${mark.data.get('types')}`; - return {children}; - }, - - /** - * Decorate code blocks with Prism.js highlighting. - * - * @param {Node} node - * @return {Array} - */ - - decorateNode(node) { - if (node.type !== 'paragraph') { - return []; - } - - const texts = node.getTexts().toArray(); - const tstring = texts.map(t => t.text).join('\n'); - const grammar = Prism.languages[language]; - const tokens = Prism.tokenize(tstring, grammar); - const decorations = []; - let startText = texts.shift(); - let endText = startText; - let startOffset = 0; - let endOffset = 0; - let start = 0; - - function processToken(token, acc?) { - // Accumulate token types down the tree - const types = `${acc || ''} ${token.type || ''} ${token.alias || ''}`; - - // Add mark for token node - if (typeof token === 'string' || typeof token.content === 'string') { - startText = endText; - startOffset = endOffset; - - const content = typeof token === 'string' ? token : token.content; - const newlines = content.split('\n').length - 1; - const length = content.length - newlines; - const end = start + length; - - let available = startText.text.length - startOffset; - let remaining = length; - - endOffset = startOffset + remaining; - - while (available < remaining) { - endText = texts.shift(); - remaining = length - available; - available = endText.text.length; - endOffset = remaining; - } - - // Inject marks from up the tree (acc) as well - if (typeof token !== 'string' || acc) { - const range = { - anchorKey: startText.key, - anchorOffset: startOffset, - focusKey: endText.key, - focusOffset: endOffset, - marks: [{ type: TOKEN_MARK, data: { types } }], - }; - - decorations.push(range); - } - - start = end; - } else if (token.content && token.content.length) { - // Tokens can be nested - for (const subToken of token.content) { - processToken(subToken, types); - } - } - } - - // Process top-level tokens - for (const token of tokens) { - processToken(token); - } - - return decorations; - }, - }; -} diff --git a/public/app/containers/ManageDashboards/FolderPermissions.tsx b/public/app/containers/ManageDashboards/FolderPermissions.tsx deleted file mode 100644 index 072908d2b8e..00000000000 --- a/public/app/containers/ManageDashboards/FolderPermissions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { Component } from 'react'; -import { hot } from 'react-hot-loader'; -import { inject, observer } from 'mobx-react'; -import { toJS } from 'mobx'; -import ContainerProps from 'app/containers/ContainerProps'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import Permissions from 'app/core/components/Permissions/Permissions'; -import Tooltip from 'app/core/components/Tooltip/Tooltip'; -import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo'; -import AddPermissions from 'app/core/components/Permissions/AddPermissions'; -import SlideDown from 'app/core/components/Animations/SlideDown'; - -@inject('nav', 'folder', 'view', 'permissions') -@observer -export class FolderPermissions extends Component { - constructor(props) { - super(props); - this.handleAddPermission = this.handleAddPermission.bind(this); - } - - componentDidMount() { - this.loadStore(); - } - - componentWillUnmount() { - const { permissions } = this.props; - permissions.hideAddPermissions(); - } - - loadStore() { - const { nav, folder, view } = this.props; - return folder.load(view.routeParams.get('uid') as string).then(res => { - view.updatePathAndQuery(`${res.url}/permissions`, {}, {}); - return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions'); - }); - } - - handleAddPermission() { - const { permissions } = this.props; - permissions.toggleAddPermissions(); - } - - render() { - const { nav, folder, permissions, backendSrv } = this.props; - - if (!folder.folder || !nav.main) { - return

    Loading

    ; - } - - const dashboardId = folder.folder.id; - - return ( -
    - -
    -
    -

    Folder Permissions

    - - - -
    - -
    - - - - -
    -
    - ); - } -} - -export default hot(module)(FolderPermissions); diff --git a/public/app/containers/ManageDashboards/FolderSettings.test.tsx b/public/app/containers/ManageDashboards/FolderSettings.test.tsx deleted file mode 100644 index bed3d569bcc..00000000000 --- a/public/app/containers/ManageDashboards/FolderSettings.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import { FolderSettings } from './FolderSettings'; -import { RootStore } from 'app/stores/RootStore/RootStore'; -import { backendSrv } from 'test/mocks/common'; -import { shallow } from 'enzyme'; - -describe('FolderSettings', () => { - let wrapper; - let page; - - beforeAll(() => { - backendSrv.getFolderByUid.mockReturnValue( - Promise.resolve({ - id: 1, - uid: 'uid', - title: 'Folder Name', - url: '/dashboards/f/uid/folder-name', - canSave: true, - version: 1, - }) - ); - - const store = RootStore.create( - { - view: { - path: 'asd', - query: {}, - routeParams: { - uid: 'uid-str', - }, - }, - }, - { - backendSrv: backendSrv, - } - ); - - wrapper = shallow(); - page = wrapper.dive(); - return page - .instance() - .loadStore() - .then(() => { - page.update(); - }); - }); - - it('should set the title input field', () => { - const titleInput = page.find('.gf-form-input'); - expect(titleInput).toHaveLength(1); - expect(titleInput.prop('value')).toBe('Folder Name'); - }); - - it('should update title and enable save button when changed', () => { - const titleInput = page.find('.gf-form-input'); - const disabledSubmitButton = page.find('button[type="submit"]'); - expect(disabledSubmitButton.prop('disabled')).toBe(true); - - titleInput.simulate('change', { target: { value: 'New Title' } }); - - const updatedTitleInput = page.find('.gf-form-input'); - expect(updatedTitleInput.prop('value')).toBe('New Title'); - const enabledSubmitButton = page.find('button[type="submit"]'); - expect(enabledSubmitButton.prop('disabled')).toBe(false); - }); - - it('should disable save button if title is changed back to old title', () => { - const titleInput = page.find('.gf-form-input'); - - titleInput.simulate('change', { target: { value: 'Folder Name' } }); - - const enabledSubmitButton = page.find('button[type="submit"]'); - expect(enabledSubmitButton.prop('disabled')).toBe(true); - }); - - it('should disable save button if title is changed to empty string', () => { - const titleInput = page.find('.gf-form-input'); - - titleInput.simulate('change', { target: { value: '' } }); - - const enabledSubmitButton = page.find('button[type="submit"]'); - expect(enabledSubmitButton.prop('disabled')).toBe(true); - }); -}); diff --git a/public/app/containers/ManageDashboards/FolderSettings.tsx b/public/app/containers/ManageDashboards/FolderSettings.tsx deleted file mode 100644 index 88830356563..00000000000 --- a/public/app/containers/ManageDashboards/FolderSettings.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import { inject, observer } from 'mobx-react'; -import { toJS } from 'mobx'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import ContainerProps from 'app/containers/ContainerProps'; -import { getSnapshot } from 'mobx-state-tree'; -import appEvents from 'app/core/app_events'; - -@inject('nav', 'folder', 'view') -@observer -export class FolderSettings extends React.Component { - formSnapshot: any; - - componentDidMount() { - this.loadStore(); - } - - loadStore() { - const { nav, folder, view } = this.props; - - return folder.load(view.routeParams.get('uid') as string).then(res => { - this.formSnapshot = getSnapshot(folder); - view.updatePathAndQuery(`${res.url}/settings`, {}, {}); - - return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); - }); - } - - onTitleChange(evt) { - this.props.folder.setTitle(this.getFormSnapshot().folder.title, evt.target.value); - } - - getFormSnapshot() { - if (!this.formSnapshot) { - this.formSnapshot = getSnapshot(this.props.folder); - } - - return this.formSnapshot; - } - - save(evt) { - if (evt) { - evt.stopPropagation(); - evt.preventDefault(); - } - - const { nav, folder, view } = this.props; - - folder - .saveFolder({ overwrite: false }) - .then(newUrl => { - view.updatePathAndQuery(newUrl, {}, {}); - - appEvents.emit('dashboard-saved'); - appEvents.emit('alert-success', ['Folder saved']); - }) - .then(() => { - return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); - }) - .catch(this.handleSaveFolderError.bind(this)); - } - - delete(evt) { - if (evt) { - evt.stopPropagation(); - evt.preventDefault(); - } - - const { folder, view } = this.props; - const title = folder.folder.title; - - appEvents.emit('confirm-modal', { - title: 'Delete', - text: `Do you want to delete this folder and all its dashboards?`, - icon: 'fa-trash', - yesText: 'Delete', - onConfirm: () => { - return folder.deleteFolder().then(() => { - appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]); - view.updatePathAndQuery('dashboards', '', ''); - }); - }, - }); - } - - handleSaveFolderError(err) { - if (err.data && err.data.status === 'version-mismatch') { - err.isHandled = true; - - const { nav, folder, view } = this.props; - - appEvents.emit('confirm-modal', { - title: 'Conflict', - text: 'Someone else has updated this folder.', - text2: 'Would you still like to save this folder?', - yesText: 'Save & Overwrite', - icon: 'fa-warning', - onConfirm: () => { - folder - .saveFolder({ overwrite: true }) - .then(newUrl => { - view.updatePathAndQuery(newUrl, {}, {}); - - appEvents.emit('dashboard-saved'); - appEvents.emit('alert-success', ['Folder saved']); - }) - .then(() => { - return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings'); - }); - }, - }); - } - } - - render() { - const { nav, folder } = this.props; - - if (!folder.folder || !nav.main) { - return

    Loading

    ; - } - - return ( -
    - -
    -

    Folder Settings

    - -
    -
    -
    - - -
    -
    - - -
    - -
    -
    -
    - ); - } -} - -export default hot(module)(FolderSettings); diff --git a/public/app/containers/ServerStats/ServerStats.test.tsx b/public/app/containers/ServerStats/ServerStats.test.tsx deleted file mode 100644 index a329a47527d..00000000000 --- a/public/app/containers/ServerStats/ServerStats.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import { ServerStats } from './ServerStats'; -import { RootStore } from 'app/stores/RootStore/RootStore'; -import { backendSrv, createNavTree } from 'test/mocks/common'; - -describe('ServerStats', () => { - it('Should render table with stats', done => { - backendSrv.get.mockReturnValue( - Promise.resolve({ - dashboards: 10, - }) - ); - - const store = RootStore.create( - {}, - { - backendSrv: backendSrv, - navTree: createNavTree('cfg', 'admin', 'server-stats'), - } - ); - - const page = renderer.create(); - - setTimeout(() => { - expect(page.toJSON()).toMatchSnapshot(); - done(); - }); - }); -}); diff --git a/public/app/containers/ServerStats/ServerStats.tsx b/public/app/containers/ServerStats/ServerStats.tsx deleted file mode 100644 index 63e78996041..00000000000 --- a/public/app/containers/ServerStats/ServerStats.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import { inject, observer } from 'mobx-react'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import ContainerProps from 'app/containers/ContainerProps'; - -@inject('nav', 'serverStats') -@observer -export class ServerStats extends React.Component { - constructor(props) { - super(props); - const { nav, serverStats } = this.props; - - nav.load('cfg', 'admin', 'server-stats'); - serverStats.load(); - } - - render() { - const { nav, serverStats } = this.props; - return ( -
    - -
    - - - - - - - - {serverStats.stats.map(StatItem)} -
    NameValue
    -
    -
    - ); - } -} - -function StatItem(stat) { - return ( - - {stat.name} - {stat.value} - - ); -} - -export default hot(module)(ServerStats); diff --git a/public/app/containers/Teams/TeamPages.tsx b/public/app/containers/Teams/TeamPages.tsx deleted file mode 100644 index 2abc9c51535..00000000000 --- a/public/app/containers/Teams/TeamPages.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import _ from 'lodash'; -import { hot } from 'react-hot-loader'; -import { inject, observer } from 'mobx-react'; -import config from 'app/core/config'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import { NavStore } from 'app/stores/NavStore/NavStore'; -import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore'; -import { ViewStore } from 'app/stores/ViewStore/ViewStore'; -import TeamMembers from './TeamMembers'; -import TeamSettings from './TeamSettings'; -import TeamGroupSync from './TeamGroupSync'; - -interface Props { - nav: typeof NavStore.Type; - teams: typeof TeamsStore.Type; - view: typeof ViewStore.Type; -} - -@inject('nav', 'teams', 'view') -@observer -export class TeamPages extends React.Component { - isSyncEnabled: boolean; - currentPage: string; - - constructor(props) { - super(props); - - this.isSyncEnabled = config.buildInfo.isEnterprise; - this.currentPage = this.getCurrentPage(); - - this.loadTeam(); - } - - async loadTeam() { - const { teams, nav, view } = this.props; - - await teams.loadById(view.routeParams.get('id')); - - nav.initTeamPage(this.getCurrentTeam(), this.currentPage, this.isSyncEnabled); - } - - getCurrentTeam(): Team { - const { teams, view } = this.props; - return teams.map.get(view.routeParams.get('id')); - } - - getCurrentPage() { - const pages = ['members', 'settings', 'groupsync']; - const currentPage = this.props.view.routeParams.get('page'); - return _.includes(pages, currentPage) ? currentPage : pages[0]; - } - - render() { - const { nav } = this.props; - const currentTeam = this.getCurrentTeam(); - - if (!nav.main) { - return null; - } - - return ( -
    - - {currentTeam && ( -
    - {this.currentPage === 'members' && } - {this.currentPage === 'settings' && } - {this.currentPage === 'groupsync' && this.isSyncEnabled && } -
    - )} -
    - ); - } -} - -export default hot(module)(TeamPages); diff --git a/public/app/containers/Teams/TeamSettings.tsx b/public/app/containers/Teams/TeamSettings.tsx deleted file mode 100644 index 0de60a0b16c..00000000000 --- a/public/app/containers/Teams/TeamSettings.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import { observer } from 'mobx-react'; -import { Team } from 'app/stores/TeamsStore/TeamsStore'; -import { Label } from 'app/core/components/Forms/Forms'; - -interface Props { - team: Team; -} - -@observer -export class TeamSettings extends React.Component { - constructor(props) { - super(props); - } - - onChangeName = evt => { - this.props.team.setName(evt.target.value); - }; - - onChangeEmail = evt => { - this.props.team.setEmail(evt.target.value); - }; - - onUpdate = evt => { - evt.preventDefault(); - this.props.team.update(); - }; - - render() { - return ( -
    -

    Team Settings

    -
    -
    - - -
    -
    - - -
    - -
    - -
    -
    -
    - ); - } -} - -export default hot(module)(TeamSettings); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts new file mode 100644 index 00000000000..451a13dae99 --- /dev/null +++ b/public/app/core/actions/index.ts @@ -0,0 +1,4 @@ +import { updateLocation } from './location'; +import { updateNavIndex, UpdateNavIndexAction } from './navModel'; + +export { updateLocation, updateNavIndex, UpdateNavIndexAction }; diff --git a/public/app/core/actions/location.ts b/public/app/core/actions/location.ts new file mode 100644 index 00000000000..6f7ac67363e --- /dev/null +++ b/public/app/core/actions/location.ts @@ -0,0 +1,13 @@ +import { LocationUpdate } from 'app/types'; + +export type Action = UpdateLocationAction; + +export interface UpdateLocationAction { + type: 'UPDATE_LOCATION'; + payload: LocationUpdate; +} + +export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({ + type: 'UPDATE_LOCATION', + payload: location, +}); diff --git a/public/app/core/actions/navModel.ts b/public/app/core/actions/navModel.ts new file mode 100644 index 00000000000..a40a0e880ee --- /dev/null +++ b/public/app/core/actions/navModel.ts @@ -0,0 +1,17 @@ +import { NavModelItem } from '../../types'; + +export enum ActionTypes { + UpdateNavIndex = 'UPDATE_NAV_INDEX', +} + +export type Action = UpdateNavIndexAction; + +export interface UpdateNavIndexAction { + type: ActionTypes.UpdateNavIndex; + payload: NavModelItem; +} + +export const updateNavIndex = (item: NavModelItem): UpdateNavIndexAction => ({ + type: ActionTypes.UpdateNavIndex, + payload: item, +}); diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 18e9d8dbd84..6974d40aac8 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -5,7 +5,6 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA'; import { SearchResult } from './components/search/SearchResult'; import { TagFilter } from './components/TagFilter/TagFilter'; import { SideMenu } from './components/sidemenu/SideMenu'; -import DashboardPermissions from './components/Permissions/DashboardPermissions'; export function registerAngularDirectives() { react2AngularDirective('passwordStrength', PasswordStrength, ['password']); @@ -18,5 +17,4 @@ export function registerAngularDirectives() { ['onSelect', { watchDepth: 'reference' }], ['tagOptions', { watchDepth: 'reference' }], ]); - react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']); } diff --git a/public/app/core/components/CustomScrollbar/CustomScrollbar.test.tsx b/public/app/core/components/CustomScrollbar/CustomScrollbar.test.tsx new file mode 100644 index 00000000000..4edcf7313db --- /dev/null +++ b/public/app/core/components/CustomScrollbar/CustomScrollbar.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import CustomScrollbar from './CustomScrollbar'; + +describe('CustomScrollbar', () => { + it('renders correctly', () => { + const tree = renderer + .create( + +

    Scrollable content

    +
    + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx b/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx new file mode 100644 index 00000000000..9b9a9c4d02a --- /dev/null +++ b/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx @@ -0,0 +1,46 @@ +import React, { PureComponent } from 'react'; +import Scrollbars from 'react-custom-scrollbars'; + +interface Props { + customClassName?: string; + autoHide?: boolean; + autoHideTimeout?: number; + autoHideDuration?: number; + hideTracksWhenNotNeeded?: boolean; +} + +/** + * Wraps component into component from `react-custom-scrollbars` + */ +class CustomScrollbar extends PureComponent { + static defaultProps: Partial = { + customClassName: 'custom-scrollbars', + autoHide: true, + autoHideTimeout: 200, + autoHideDuration: 200, + hideTracksWhenNotNeeded: false, + }; + + render() { + const { customClassName, children, ...scrollProps } = this.props; + + return ( +
    } + renderTrackVertical={props =>
    } + renderThumbHorizontal={props =>
    } + renderThumbVertical={props =>
    } + renderView={props =>
    } + {...scrollProps} + > + {children} + + ); + } +} + +export default CustomScrollbar; diff --git a/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap new file mode 100644 index 00000000000..37d8cea45be --- /dev/null +++ b/public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomScrollbar renders correctly 1`] = ` +
    +
    +

    + Scrollable content +

    +
    +
    +
    +
    +
    +
    +
    +
    +`; 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/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index b7bef2495bb..c176095afa4 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,9 +1,7 @@ import React from 'react'; -import { observer } from 'mobx-react'; -import { NavModel, NavModelItem } from '../../nav_model_srv'; +import { NavModel, NavModelItem } from 'app/types'; import classNames from 'classnames'; import appEvents from 'app/core/app_events'; -import { toJS } from 'mobx'; export interface Props { model: NavModel; @@ -81,7 +79,6 @@ const Navigation = ({ main }: { main: NavModelItem }) => { ); }; -@observer export default class PageHeader extends React.Component { constructor(props) { super(props); @@ -148,7 +145,7 @@ export default class PageHeader extends React.Component { return null; } - const main = toJS(model.main); // Convert to JS if its a mobx observable + const main = model.main; return (
    diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx new file mode 100644 index 00000000000..fc062ce63e4 --- /dev/null +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -0,0 +1,150 @@ +import React, { Component } from 'react'; +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, + AclTarget, + PermissionLevel, + NewDashboardAclItem, + OrgRole, +} from 'app/types/acl'; + +export interface Props { + onAddPermission: (item: NewDashboardAclItem) => void; + onCancel: () => void; +} + +class AddPermissions extends Component { + constructor(props) { + super(props); + this.state = this.getCleanState(); + } + + getCleanState() { + return { + userId: 0, + teamId: 0, + type: AclTarget.Team, + permission: PermissionLevel.View, + }; + } + + onTypeChanged = evt => { + const type = evt.target.value as AclTarget; + + switch (type) { + case AclTarget.User: + case AclTarget.Team: + this.setState({ type: type, userId: 0, teamId: 0, role: undefined }); + break; + case AclTarget.Editor: + this.setState({ type: type, userId: 0, teamId: 0, role: OrgRole.Editor }); + break; + case AclTarget.Viewer: + this.setState({ type: type, userId: 0, teamId: 0, role: OrgRole.Viewer }); + break; + } + }; + + onUserSelected = (user: User) => { + this.setState({ userId: user ? user.id : 0 }); + }; + + onTeamSelected = (team: Team) => { + this.setState({ teamId: team ? team.id : 0 }); + }; + + onPermissionChanged = (permission: OptionWithDescription) => { + this.setState({ permission: permission.value }); + }; + + onSubmit = async evt => { + evt.preventDefault(); + await this.props.onAddPermission(this.state); + this.setState(this.getCleanState()); + }; + + isValid() { + switch (this.state.type) { + case AclTarget.Team: + return this.state.teamId > 0; + case AclTarget.User: + return this.state.userId > 0; + } + return true; + } + + render() { + const { onCancel } = this.props; + const newItem = this.state; + const pickerClassName = 'width-20'; + const isValid = this.isValid(); + + return ( +
    + +
    +
    Add Permission For
    +
    +
    +
    + +
    +
    + + {newItem.type === AclTarget.User ? ( +
    + +
    + ) : null} + + {newItem.type === AclTarget.Team ? ( +
    + +
    + ) : null} + +
    + +
    + +
    + +
    +
    +
    +
    + ); + } +} + +export default AddPermissions; diff --git a/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx similarity index 89% rename from public/app/core/components/Permissions/DisabledPermissionsListItem.tsx rename to public/app/core/components/PermissionList/DisabledPermissionListItem.tsx index d65595dae66..d648d06e414 100644 --- a/public/app/core/components/Permissions/DisabledPermissionsListItem.tsx +++ b/public/app/core/components/PermissionList/DisabledPermissionListItem.tsx @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; -import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; +import { dashboardPermissionLevels } from 'app/types/acl'; export interface Props { item: any; @@ -24,7 +24,7 @@ export default class DisabledPermissionListItem extends Component {
    {}} value={item.permission} disabled={true} diff --git a/public/app/core/components/Permissions/PermissionsList.tsx b/public/app/core/components/PermissionList/PermissionList.tsx similarity index 55% rename from public/app/core/components/Permissions/PermissionsList.tsx rename to public/app/core/components/PermissionList/PermissionList.tsx index 7e64de012e4..772baa0c274 100644 --- a/public/app/core/components/Permissions/PermissionsList.tsx +++ b/public/app/core/components/PermissionList/PermissionList.tsx @@ -1,21 +1,20 @@ -import React, { Component } from 'react'; -import PermissionsListItem from './PermissionsListItem'; -import DisabledPermissionsListItem from './DisabledPermissionsListItem'; -import { observer } from 'mobx-react'; -import { FolderInfo } from './FolderInfo'; +import React, { PureComponent } from 'react'; +import PermissionsListItem from './PermissionListItem'; +import DisabledPermissionsListItem from './DisabledPermissionListItem'; +import { FolderInfo } from 'app/types'; +import { DashboardAcl } from 'app/types/acl'; export interface Props { - permissions: any[]; - removeItem: any; - permissionChanged: any; - fetching: boolean; + items: DashboardAcl[]; + onRemoveItem: (item: DashboardAcl) => void; + onPermissionChanged: any; + isFetching: boolean; folderInfo?: FolderInfo; } -@observer -class PermissionsList extends Component { +class PermissionList extends PureComponent { render() { - const { permissions, removeItem, permissionChanged, fetching, folderInfo } = this.props; + const { items, onRemoveItem, onPermissionChanged, isFetching, folderInfo } = this.props; return ( @@ -28,19 +27,18 @@ class PermissionsList extends Component { icon: 'fa fa-fw fa-street-view', }} /> - {permissions.map((item, idx) => { + {items.map((item, idx) => { return ( ); })} - {fetching === true && permissions.length < 1 ? ( + {isFetching === true && items.length < 1 ? ( ) : null} - {fetching === false && permissions.length < 1 ? ( + {isFetching === false && items.length < 1 ? ( + + + + + + + + ); + } +} diff --git a/public/app/core/components/Permissions/PermissionsInfo.tsx b/public/app/core/components/PermissionList/PermissionsInfo.tsx similarity index 100% rename from public/app/core/components/Permissions/PermissionsInfo.tsx rename to public/app/core/components/PermissionList/PermissionsInfo.tsx diff --git a/public/app/core/components/Permissions/AddPermissions.test.tsx b/public/app/core/components/Permissions/AddPermissions.test.tsx deleted file mode 100644 index c6d1ab381b8..00000000000 --- a/public/app/core/components/Permissions/AddPermissions.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import AddPermissions from './AddPermissions'; -import { RootStore } from 'app/stores/RootStore/RootStore'; -import { getBackendSrv } from 'app/core/services/backend_srv'; - -jest.mock('app/core/services/backend_srv', () => ({ - getBackendSrv: () => { - return { - get: () => { - return Promise.resolve([ - { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' }, - { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' }, - ]); - }, - post: jest.fn(() => Promise.resolve({})), - }; - }, -})); - -describe('AddPermissions', () => { - let wrapper; - let store; - let instance; - const backendSrv: any = getBackendSrv(); - - beforeAll(() => { - store = RootStore.create({}, { backendSrv: backendSrv }); - wrapper = shallow(); - instance = wrapper.instance(); - return store.permissions.load(1, true, false); - }); - - describe('when permission for a user is added', () => { - it('should save permission to db', () => { - const evt = { - target: { - value: 'User', - }, - }; - const userItem = { - id: 2, - login: 'user2', - }; - - instance.onTypeChanged(evt); - instance.onUserSelected(userItem); - - wrapper.update(); - - expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false); - - wrapper.find('form').simulate('submit', { preventDefault() {} }); - - expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); - }); - }); - - describe('when permission for team is added', () => { - it('should save permission to db', () => { - const evt = { - target: { - value: 'Group', - }, - }; - - const teamItem = { - id: 2, - name: 'ug1', - }; - - instance.onTypeChanged(evt); - instance.onTeamSelected(teamItem); - - wrapper.update(); - - expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false); - - wrapper.find('form').simulate('submit', { preventDefault() {} }); - - expect(backendSrv.post.mock.calls.length).toBe(1); - expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions'); - }); - }); - - afterEach(() => { - backendSrv.post.mockClear(); - }); -}); diff --git a/public/app/core/components/Permissions/AddPermissions.tsx b/public/app/core/components/Permissions/AddPermissions.tsx deleted file mode 100644 index 289e27aa731..00000000000 --- a/public/app/core/components/Permissions/AddPermissions.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { Component } from 'react'; -import { observer } from 'mobx-react'; -import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore'; -import { UserPicker, User } 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 { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; - -export interface Props { - permissions: any; -} - -@observer -class AddPermissions extends Component { - constructor(props) { - super(props); - } - - componentWillMount() { - const { permissions } = this.props; - permissions.resetNewType(); - } - - onTypeChanged = evt => { - const { value } = evt.target; - const { permissions } = this.props; - - permissions.setNewType(value); - }; - - onUserSelected = (user: User) => { - const { permissions } = this.props; - if (!user) { - permissions.newItem.setUser(null, null); - return; - } - return permissions.newItem.setUser(user.id, user.login, user.avatarUrl); - }; - - onTeamSelected = (team: Team) => { - const { permissions } = this.props; - if (!team) { - permissions.newItem.setTeam(null, null); - return; - } - return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl); - }; - - onPermissionChanged = (permission: OptionWithDescription) => { - const { permissions } = this.props; - return permissions.newItem.setPermission(permission.value); - }; - - resetNewType() { - const { permissions } = this.props; - return permissions.resetNewType(); - } - - onSubmit = evt => { - evt.preventDefault(); - const { permissions } = this.props; - permissions.addStoreItem(); - }; - - render() { - const { permissions } = this.props; - const newItem = permissions.newItem; - const pickerClassName = 'width-20'; - - const isValid = newItem.isValid(); - - return ( -
    - -
    -
    Add Permission For
    -
    -
    -
    - -
    -
    - - {newItem.type === 'User' ? ( -
    - -
    - ) : null} - - {newItem.type === 'Group' ? ( -
    - -
    - ) : null} - -
    - -
    - -
    - -
    -
    - -
    - ); - } -} - -export default AddPermissions; diff --git a/public/app/core/components/Permissions/DashboardPermissions.tsx b/public/app/core/components/Permissions/DashboardPermissions.tsx deleted file mode 100644 index 38a646b2473..00000000000 --- a/public/app/core/components/Permissions/DashboardPermissions.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { Component } from 'react'; -import { observer } from 'mobx-react'; -import { store } from 'app/stores/store'; -import Permissions from 'app/core/components/Permissions/Permissions'; -import Tooltip from 'app/core/components/Tooltip/Tooltip'; -import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo'; -import AddPermissions from 'app/core/components/Permissions/AddPermissions'; -import SlideDown from 'app/core/components/Animations/SlideDown'; -import { FolderInfo } from './FolderInfo'; - -export interface Props { - dashboardId: number; - folder?: FolderInfo; - backendSrv: any; -} - -@observer -class DashboardPermissions extends Component { - permissions: any; - - constructor(props) { - super(props); - this.handleAddPermission = this.handleAddPermission.bind(this); - this.permissions = store.permissions; - } - - handleAddPermission() { - this.permissions.toggleAddPermissions(); - } - - componentWillUnmount() { - this.permissions.hideAddPermissions(); - } - - render() { - const { dashboardId, folder, backendSrv } = this.props; - - return ( -
    -
    -
    -

    Permissions

    - - - -
    - -
    -
    - - - - -
    - ); - } -} - -export default DashboardPermissions; diff --git a/public/app/core/components/Permissions/FolderInfo.ts b/public/app/core/components/Permissions/FolderInfo.ts deleted file mode 100644 index d4a6020bb71..00000000000 --- a/public/app/core/components/Permissions/FolderInfo.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface FolderInfo { - id: number; - title: string; - url: string; -} diff --git a/public/app/core/components/Permissions/Permissions.tsx b/public/app/core/components/Permissions/Permissions.tsx deleted file mode 100644 index d17899c891f..00000000000 --- a/public/app/core/components/Permissions/Permissions.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { Component } from 'react'; -import PermissionsList from './PermissionsList'; -import { observer } from 'mobx-react'; -import { FolderInfo } from './FolderInfo'; - -export interface DashboardAcl { - id?: number; - dashboardId?: number; - userId?: number; - userLogin?: string; - userEmail?: string; - teamId?: number; - team?: string; - permission?: number; - permissionName?: string; - role?: string; - icon?: string; - name?: string; - inherited?: boolean; - sortRank?: number; -} - -export interface Props { - dashboardId: number; - folderInfo?: FolderInfo; - permissions?: any; - isFolder: boolean; - backendSrv: any; -} - -@observer -class Permissions extends Component { - constructor(props) { - super(props); - const { dashboardId, isFolder, folderInfo } = this.props; - this.permissionChanged = this.permissionChanged.bind(this); - this.typeChanged = this.typeChanged.bind(this); - this.removeItem = this.removeItem.bind(this); - this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0); - } - - loadStore(dashboardId, isFolder, isInRoot = false) { - return this.props.permissions.load(dashboardId, isFolder, isInRoot); - } - - permissionChanged(index: number, permission: number, permissionName: string) { - const { permissions } = this.props; - permissions.updatePermissionOnIndex(index, permission, permissionName); - } - - removeItem(index: number) { - const { permissions } = this.props; - permissions.removeStoreItem(index); - } - - resetNewType() { - const { permissions } = this.props; - permissions.resetNewType(); - } - - typeChanged(evt) { - const { value } = evt.target; - const { permissions, dashboardId } = this.props; - - if (value === 'Viewer' || value === 'Editor') { - permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId); - this.resetNewType(); - return; - } - - permissions.setNewType(value); - } - - render() { - const { permissions, folderInfo } = this.props; - - return ( -
    - -
    - ); - } -} - -export default Permissions; diff --git a/public/app/core/components/Permissions/PermissionsListItem.tsx b/public/app/core/components/Permissions/PermissionsListItem.tsx deleted file mode 100644 index a17aa8c04df..00000000000 --- a/public/app/core/components/Permissions/PermissionsListItem.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react'; -import { observer } from 'mobx-react'; -import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; -import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore'; - -const setClassNameHelper = inherited => { - return inherited ? 'gf-form-disabled' : ''; -}; - -function ItemAvatar({ item }) { - if (item.userAvatarUrl) { - return ; - } - if (item.teamAvatarUrl) { - return ; - } - if (item.role === 'Editor') { - return ; - } - - return ; -} - -function ItemDescription({ item }) { - if (item.userId) { - return (User); - } - if (item.teamId) { - return (Team); - } - return (Role); -} - -export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => { - const handleRemoveItem = evt => { - evt.preventDefault(); - removeItem(itemIndex); - }; - - const handleChangePermission = permissionOption => { - permissionChanged(itemIndex, permissionOption.value, permissionOption.label); - }; - - const inheritedFromRoot = item.dashboardId === -1 && !item.inherited; - - return ( -
    - - - - - - - - ); -}); diff --git a/public/app/core/components/Picker/TeamPicker.tsx b/public/app/core/components/Picker/TeamPicker.tsx index 04f108ff8da..a177a13c45b 100644 --- a/public/app/core/components/Picker/TeamPicker.tsx +++ b/public/app/core/components/Picker/TeamPicker.tsx @@ -39,7 +39,7 @@ export class TeamPicker extends Component { const backendSrv = getBackendSrv(); this.setState({ isLoading: true }); - return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => { + return backendSrv.get(`/api/teams/search?perpage=50&page=1&query=${query}`).then(result => { const teams = result.teams.map(team => { return { id: team.id, 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/Picker/__snapshots__/TeamPicker.test.tsx.snap b/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap index 67232d0ea5b..9c21da5fdc4 100644 --- a/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap +++ b/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap @@ -6,7 +6,6 @@ exports[`TeamPicker renders correctly 1`] = ` >
    - @@ -36,14 +34,9 @@ exports[`TeamPicker renders correctly 1`] = ` >
    -
    +
    Loading permissions... @@ -48,7 +46,7 @@ class PermissionsList extends Component {
    No permissions are set. Will only be accessible by admins. @@ -61,4 +59,4 @@ class PermissionsList extends Component { } } -export default PermissionsList; +export default PermissionList; diff --git a/public/app/core/components/PermissionList/PermissionListItem.tsx b/public/app/core/components/PermissionList/PermissionListItem.tsx new file mode 100644 index 00000000000..b846f98a063 --- /dev/null +++ b/public/app/core/components/PermissionList/PermissionListItem.tsx @@ -0,0 +1,100 @@ +import React, { PureComponent } from 'react'; +import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker'; +import { dashboardPermissionLevels, DashboardAcl, PermissionLevel } from 'app/types/acl'; +import { FolderInfo } from 'app/types'; + +const setClassNameHelper = inherited => { + return inherited ? 'gf-form-disabled' : ''; +}; + +function ItemAvatar({ item }) { + if (item.userAvatarUrl) { + return ; + } + if (item.teamAvatarUrl) { + return ; + } + if (item.role === 'Editor') { + return ; + } + + return ; +} + +function ItemDescription({ item }) { + if (item.userId) { + return (User); + } + if (item.teamId) { + return (Team); + } + return (Role); +} + +interface Props { + item: DashboardAcl; + onRemoveItem: (item: DashboardAcl) => void; + onPermissionChanged: (item: DashboardAcl, level: PermissionLevel) => void; + folderInfo?: FolderInfo; +} + +export default class PermissionsListItem extends PureComponent { + onPermissionChanged = option => { + this.props.onPermissionChanged(this.props.item, option.value as PermissionLevel); + }; + + onRemoveItem = () => { + this.props.onRemoveItem(this.props.item); + }; + + render() { + const { item, folderInfo } = this.props; + const inheritedFromRoot = item.dashboardId === -1 && !item.inherited; + + return ( +
    + + + {item.name} + + {item.inherited && + folderInfo && ( + + Inherited from folder{' '} + + {folderInfo.title} + {' '} + + )} + {inheritedFromRoot && Default Permission} + Can +
    + +
    +
    + {!item.inherited ? ( + + + + ) : ( + + )} +
    - - - {item.name} - - {item.inherited && - folderInfo && ( - - Inherited from folder{' '} - - {folderInfo.title} - {' '} - - )} - {inheritedFromRoot && Default Permission} - Can -
    - -
    -
    - {!item.inherited ? ( - - - - ) : ( - - )} -
    + + + + + + + {stats.map(StatItem)} +
    NameValue
    +
    +
    + ); + } +} + +function StatItem(stat: ServerStat) { + return ( + + {stat.name} + {stat.value} + + ); +} + +const mapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'server-stats'), + getServerStats: getServerStats, +}); + +export default hot(module)(connect(mapStateToProps)(ServerStats)); diff --git a/public/app/features/styleguide/styleguide.ts b/public/app/features/admin/StyleGuideCtrl.ts similarity index 85% rename from public/app/features/styleguide/styleguide.ts rename to public/app/features/admin/StyleGuideCtrl.ts index 4aac194d950..7be3009634d 100644 --- a/public/app/features/styleguide/styleguide.ts +++ b/public/app/features/admin/StyleGuideCtrl.ts @@ -1,7 +1,6 @@ -import coreModule from 'app/core/core_module'; import config from 'app/core/config'; -class StyleGuideCtrl { +export default class StyleGuideCtrl { theme: string; buttonNames = ['primary', 'secondary', 'inverse', 'success', 'warning', 'danger']; buttonSizes = ['btn-small', '', 'btn-large']; @@ -27,4 +26,3 @@ class StyleGuideCtrl { } } -coreModule.controller('StyleGuideCtrl', StyleGuideCtrl); diff --git a/public/app/containers/ServerStats/__snapshots__/ServerStats.test.tsx.snap b/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap similarity index 59% rename from public/app/containers/ServerStats/__snapshots__/ServerStats.test.tsx.snap rename to public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap index eac793ca2ca..02e8784adc5 100644 --- a/public/app/containers/ServerStats/__snapshots__/ServerStats.test.tsx.snap +++ b/public/app/features/admin/__snapshots__/ServerStats.test.tsx.snap @@ -17,8 +17,9 @@ exports[`ServerStats Should render table with stats 1`] = ` - - +
    - admin-Text + Admin - +
    + subTitle +
    @@ -60,13 +65,12 @@ exports[`ServerStats Should render table with stats 1`] = ` > - server-stats-Text + Admin @@ -101,66 +105,10 @@ exports[`ServerStats Should render table with stats 1`] = ` - Total users + Total Users - 0 - - - - - Active users (seen last 30 days) - - - 0 - - - - - Total orgs - - - 0 - - - - - Total playlists - - - 0 - - - - - Total snapshots - - - 0 - - - - - Total dashboard tags - - - 0 - - - - - Total starred dashboards - - - 0 - - - - - Total alerts - - - 0 + 1 diff --git a/public/app/features/admin/admin.ts b/public/app/features/admin/index.ts similarity index 57% rename from public/app/features/admin/admin.ts rename to public/app/features/admin/index.ts index 00e98821779..7d06155b6f8 100644 --- a/public/app/features/admin/admin.ts +++ b/public/app/features/admin/index.ts @@ -1,7 +1,8 @@ -import AdminListUsersCtrl from './admin_list_users_ctrl'; -import './admin_list_orgs_ctrl'; -import './admin_edit_org_ctrl'; -import './admin_edit_user_ctrl'; +import AdminListUsersCtrl from './AdminListUsersCtrl'; +import AdminEditUserCtrl from './AdminEditUserCtrl'; +import AdminListOrgsCtrl from './AdminListOrgsCtrl'; +import AdminEditOrgCtrl from './AdminEditOrgCtrl'; +import StyleGuideCtrl from './StyleGuideCtrl'; import coreModule from 'app/core/core_module'; @@ -27,21 +28,10 @@ class AdminHomeCtrl { } } -export class AdminStatsCtrl { - stats: any; - navModel: any; - - /** @ngInject */ - constructor(backendSrv: any, navModelSrv) { - this.navModel = navModelSrv.getNav('cfg', 'admin', 'server-stats', 1); - - backendSrv.get('/api/admin/stats').then(stats => { - this.stats = stats; - }); - } -} - +coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl); +coreModule.controller('AdminEditUserCtrl', AdminEditUserCtrl); +coreModule.controller('AdminListOrgsCtrl', AdminListOrgsCtrl); +coreModule.controller('AdminEditOrgCtrl', AdminEditOrgCtrl); coreModule.controller('AdminSettingsCtrl', AdminSettingsCtrl); coreModule.controller('AdminHomeCtrl', AdminHomeCtrl); -coreModule.controller('AdminStatsCtrl', AdminStatsCtrl); -coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl); +coreModule.controller('StyleGuideCtrl', StyleGuideCtrl); diff --git a/public/app/features/styleguide/styleguide.html b/public/app/features/admin/partials/styleguide.html similarity index 100% rename from public/app/features/styleguide/styleguide.html rename to public/app/features/admin/partials/styleguide.html diff --git a/public/app/features/admin/state/apis.ts b/public/app/features/admin/state/apis.ts new file mode 100644 index 00000000000..d81fd299493 --- /dev/null +++ b/public/app/features/admin/state/apis.ts @@ -0,0 +1,26 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; + +export interface ServerStat { + name: string; + value: number; +} + +export const getServerStats = async (): Promise => { + try { + const res = await getBackendSrv().get('api/admin/stats'); + return [ + { name: 'Total users', value: res.users }, + { name: 'Total dashboards', value: res.dashboards }, + { name: 'Active users (seen last 30 days)', value: res.activeUsers }, + { name: 'Total orgs', value: res.orgs }, + { name: 'Total playlists', value: res.playlists }, + { name: 'Total snapshots', value: res.snapshots }, + { name: 'Total dashboard tags', value: res.tags }, + { name: 'Total starred dashboards', value: res.stars }, + { name: 'Total alerts', value: res.alerts }, + ]; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/public/app/features/alerting/AlertRuleItem.test.tsx b/public/app/features/alerting/AlertRuleItem.test.tsx new file mode 100644 index 00000000000..bd37e127c39 --- /dev/null +++ b/public/app/features/alerting/AlertRuleItem.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import AlertRuleItem, { Props } from './AlertRuleItem'; + +jest.mock('react-redux', () => ({ + connect: () => params => params, +})); + +const setup = (propOverrides?: object) => { + const props: Props = { + rule: { + id: 1, + dashboardId: 1, + panelId: 1, + name: 'Some rule', + state: 'Open', + stateText: 'state text', + stateIcon: 'icon', + stateClass: 'state class', + stateAge: 'age', + url: 'https://something.something.darkside', + }, + search: '', + onTogglePause: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/alerting/AlertRuleItem.tsx b/public/app/features/alerting/AlertRuleItem.tsx new file mode 100644 index 00000000000..f47a6348303 --- /dev/null +++ b/public/app/features/alerting/AlertRuleItem.tsx @@ -0,0 +1,69 @@ +import React, { PureComponent } from 'react'; +import Highlighter from 'react-highlight-words'; +import classNames from 'classnames/bind'; +import { AlertRule } from '../../types'; + +export interface Props { + rule: AlertRule; + search: string; + onTogglePause: () => void; +} + +class AlertRuleItem extends PureComponent { + renderText(text: string) { + return ( + + ); + } + + render() { + const { rule, onTogglePause } = this.props; + + const stateClass = classNames({ + fa: true, + 'fa-play': rule.state === 'paused', + 'fa-pause': rule.state !== 'paused', + }); + + const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`; + + return ( +
  • + + + +
    +
    + +
    + {this.renderText(rule.stateText)} + for {rule.stateAge} +
    +
    + {rule.info &&
    {this.renderText(rule.info)}
    } +
    + +
    + + + + +
    +
  • + ); + } +} + +export default AlertRuleItem; diff --git a/public/app/features/alerting/AlertRuleList.test.tsx b/public/app/features/alerting/AlertRuleList.test.tsx new file mode 100644 index 00000000000..2d1cf653540 --- /dev/null +++ b/public/app/features/alerting/AlertRuleList.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AlertRuleList, Props } from './AlertRuleList'; +import { AlertRule, NavModel } from '../../types'; +import appEvents from '../../core/app_events'; + +jest.mock('../../core/app_events', () => ({ + emit: jest.fn(), +})); + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + alertRules: [] as AlertRule[], + updateLocation: jest.fn(), + getAlertRulesAsync: jest.fn(), + setSearchQuery: jest.fn(), + togglePauseAlertRule: jest.fn(), + stateFilter: '', + search: '', + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + + return { + wrapper, + instance: wrapper.instance() as AlertRuleList, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render alert rules', () => { + const { wrapper } = setup({ + alertRules: [ + { + id: 1, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - Always OK', + state: 'ok', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: '', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + { + id: 3, + dashboardId: 7, + dashboardUid: 'ggHbN42mk', + dashboardSlug: 'alerting-with-testdata', + panelId: 3, + name: 'TestData - ok', + state: 'ok', + newStateDate: '2018-09-04T10:01:01+02:00', + evalDate: '0001-01-01T00:00:00Z', + evalData: {}, + executionError: 'error', + url: '/d/ggHbN42mk/alerting-with-testdata', + }, + ], + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Life cycle', () => { + describe('component did mount', () => { + it('should call fetchrules', () => { + const { instance } = setup(); + instance.fetchRules = jest.fn(); + instance.componentDidMount(); + expect(instance.fetchRules).toHaveBeenCalled(); + }); + }); + + describe('component did update', () => { + it('should call fetchrules if props differ', () => { + const { instance } = setup(); + instance.fetchRules = jest.fn(); + + instance.componentDidUpdate({ stateFilter: 'ok' } as Props); + + expect(instance.fetchRules).toHaveBeenCalled(); + }); + }); +}); + +describe('Functions', () => { + describe('Get state filter', () => { + it('should get all if prop is not set', () => { + const { instance } = setup(); + + const stateFilter = instance.getStateFilter(); + + expect(stateFilter).toEqual('all'); + }); + + it('should return state filter if set', () => { + const { instance } = setup({ + stateFilter: 'ok', + }); + + const stateFilter = instance.getStateFilter(); + + expect(stateFilter).toEqual('ok'); + }); + }); + + describe('State filter changed', () => { + it('should update location', () => { + const { instance } = setup(); + const mockEvent = { target: { value: 'alerting' } }; + + instance.onStateFilterChanged(mockEvent); + + expect(instance.props.updateLocation).toHaveBeenCalledWith({ query: { state: 'alerting' } }); + }); + }); + + describe('Open how to', () => { + it('should emit show-modal event', () => { + const { instance } = setup(); + + instance.onOpenHowTo(); + + expect(appEvents.emit).toHaveBeenCalledWith('show-modal', { + src: 'public/app/features/alerting/partials/alert_howto.html', + modalClass: 'confirm-modal', + model: {}, + }); + }); + }); + + describe('Search query change', () => { + it('should set search query', () => { + const { instance } = setup(); + const mockEvent = { target: { value: 'dashboard' } }; + + instance.onSearchQueryChange(mockEvent); + + expect(instance.props.setSearchQuery).toHaveBeenCalledWith('dashboard'); + }); + }); +}); diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx new file mode 100644 index 00000000000..d25fc659af5 --- /dev/null +++ b/public/app/features/alerting/AlertRuleList.tsx @@ -0,0 +1,153 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import AlertRuleItem from './AlertRuleItem'; +import appEvents from 'app/core/app_events'; +import { updateLocation } from 'app/core/actions'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { NavModel, StoreState, AlertRule } from 'app/types'; +import { getAlertRulesAsync, setSearchQuery, togglePauseAlertRule } from './state/actions'; +import { getAlertRuleItems, getSearchQuery } from './state/selectors'; + +export interface Props { + navModel: NavModel; + alertRules: AlertRule[]; + updateLocation: typeof updateLocation; + getAlertRulesAsync: typeof getAlertRulesAsync; + setSearchQuery: typeof setSearchQuery; + togglePauseAlertRule: typeof togglePauseAlertRule; + stateFilter: string; + search: string; +} + +export class AlertRuleList extends PureComponent { + stateFilters = [ + { text: 'All', value: 'all' }, + { text: 'OK', value: 'ok' }, + { text: 'Not OK', value: 'not_ok' }, + { text: 'Alerting', value: 'alerting' }, + { text: 'No Data', value: 'no_data' }, + { text: 'Paused', value: 'paused' }, + ]; + + componentDidMount() { + this.fetchRules(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.stateFilter !== this.props.stateFilter) { + this.fetchRules(); + } + } + + async fetchRules() { + await this.props.getAlertRulesAsync({ state: this.getStateFilter() }); + } + + getStateFilter(): string { + const { stateFilter } = this.props; + if (stateFilter) { + return stateFilter.toString(); + } + return 'all'; + } + + onStateFilterChanged = event => { + this.props.updateLocation({ + query: { state: event.target.value }, + }); + }; + + onOpenHowTo = () => { + appEvents.emit('show-modal', { + src: 'public/app/features/alerting/partials/alert_howto.html', + modalClass: 'confirm-modal', + model: {}, + }); + }; + + onSearchQueryChange = event => { + const { value } = event.target; + this.props.setSearchQuery(value); + }; + + onTogglePause = (rule: AlertRule) => { + this.props.togglePauseAlertRule(rule.id, { paused: rule.state !== 'paused' }); + }; + + alertStateFilterOption = ({ text, value }) => { + return ( + + ); + }; + + render() { + const { navModel, alertRules, search } = this.props; + + return ( +
    + +
    +
    +
    + +
    +
    + + +
    + +
    +
    + +
    +
      + {alertRules.map(rule => ( + this.onTogglePause(rule)} + /> + ))} +
    +
    +
    +
    + ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + navModel: getNavModel(state.navIndex, 'alert-list'), + alertRules: getAlertRuleItems(state.alertRules), + stateFilter: state.location.query.state, + search: getSearchQuery(state.alertRules), +}); + +const mapDispatchToProps = { + updateLocation, + getAlertRulesAsync, + setSearchQuery, + togglePauseAlertRule, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(AlertRuleList)); diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/AlertTabCtrl.ts similarity index 99% rename from public/app/features/alerting/alert_tab_ctrl.ts rename to public/app/features/alerting/AlertTabCtrl.ts index 53f7e57fd69..c91ff5cd6c3 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/AlertTabCtrl.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { ThresholdMapper } from './threshold_mapper'; +import { ThresholdMapper } from './state/ThresholdMapper'; import { QueryPart } from 'app/core/components/query_part/query_part'; -import alertDef from './alert_def'; +import alertDef from './state/alertDef'; import config from 'app/core/config'; import appEvents from 'app/core/app_events'; diff --git a/public/app/features/alerting/notification_edit_ctrl.ts b/public/app/features/alerting/NotificationsEditCtrl.ts similarity index 100% rename from public/app/features/alerting/notification_edit_ctrl.ts rename to public/app/features/alerting/NotificationsEditCtrl.ts diff --git a/public/app/features/alerting/notifications_list_ctrl.ts b/public/app/features/alerting/NotificationsListCtrl.ts similarity index 100% rename from public/app/features/alerting/notifications_list_ctrl.ts rename to public/app/features/alerting/NotificationsListCtrl.ts diff --git a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap similarity index 60% rename from public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap rename to public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap index f408f6409be..f686127ebf3 100644 --- a/public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.test.tsx.snap +++ b/public/app/features/alerting/__snapshots__/AlertRuleItem.test.tsx.snap @@ -1,14 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AlertRuleList should render 1 rule 1`] = ` +exports[`Render should render component 1`] = `
  • - - - OK - - - + textToHighlight="state text" + /> for - 5 minutes + age
  • @@ -82,7 +64,7 @@ exports[`AlertRuleList should render 1 rule 1`] = ` > +
    + + +
    + +
    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/ad_hoc_filters.ts b/public/app/features/dashboard/ad_hoc_filters.ts index 68b068152b5..0ceac9ddbba 100644 --- a/public/app/features/dashboard/ad_hoc_filters.ts +++ b/public/app/features/dashboard/ad_hoc_filters.ts @@ -59,10 +59,10 @@ export class AdHocFiltersCtrl { let promise = null; if (segment.type !== 'value') { - promise = ds.getTagKeys(); + promise = ds.getTagKeys ? ds.getTagKeys() : Promise.resolve([]); } else { options.key = this.segments[index - 2].value; - promise = ds.getTagValues(options); + promise = ds.getTagValues ? ds.getTagValues(options) : Promise.resolve([]); } return promise.then(results => { @@ -99,7 +99,7 @@ export class AdHocFiltersCtrl { this.segments.splice(index, 0, this.uiSegmentSrv.newCondition('AND')); } this.segments.push(this.uiSegmentSrv.newOperator('=')); - this.segments.push(this.uiSegmentSrv.newFake('select tag value', 'value', 'query-segment-value')); + this.segments.push(this.uiSegmentSrv.newFake('select value', 'value', 'query-segment-value')); segment.type = 'key'; segment.cssClass = 'query-segment-key'; } diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts index a8f491f3ddd..f75743513f1 100644 --- a/public/app/features/dashboard/all.ts +++ b/public/app/features/dashboard/all.ts @@ -27,16 +27,20 @@ import './dashgrid/RowOptions'; import './folder_picker/folder_picker'; import './move_to_folder_modal/move_to_folder'; import './settings/settings'; +import './panellinks/module'; +import './dashlinks/module'; + +// angular wrappers +import { react2AngularDirective } from 'app/core/utils/react2angular'; +import DashboardPermissions from './permissions/DashboardPermissions'; + +react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']); import coreModule from 'app/core/core_module'; -import { DashboardListCtrl } from './dashboard_list_ctrl'; import { FolderDashboardsCtrl } from './folder_dashboards_ctrl'; -import { FolderSettingsCtrl } from './folder_settings_ctrl'; import { DashboardImportCtrl } from './dashboard_import_ctrl'; import { CreateFolderCtrl } from './create_folder_ctrl'; -coreModule.controller('DashboardListCtrl', DashboardListCtrl); coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl); -coreModule.controller('FolderSettingsCtrl', FolderSettingsCtrl); coreModule.controller('DashboardImportCtrl', DashboardImportCtrl); coreModule.controller('CreateFolderCtrl', CreateFolderCtrl); diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 132e9b65a55..ab0d4969668 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -177,7 +177,8 @@ export class DashboardGrid extends React.Component { for (const panel of this.dashboard.panels) { const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen }); panelElements.push( -
    + /** panel-id is set for html bookmarks */ +
    ); diff --git a/public/app/features/dashlinks/editor.html b/public/app/features/dashboard/dashlinks/editor.html similarity index 100% rename from public/app/features/dashlinks/editor.html rename to public/app/features/dashboard/dashlinks/editor.html diff --git a/public/app/features/dashlinks/editor.ts b/public/app/features/dashboard/dashlinks/editor.ts similarity index 95% rename from public/app/features/dashlinks/editor.ts rename to public/app/features/dashboard/dashlinks/editor.ts index a4ba9ea209a..482052469db 100644 --- a/public/app/features/dashlinks/editor.ts +++ b/public/app/features/dashboard/dashlinks/editor.ts @@ -66,7 +66,7 @@ function dashLinksEditor() { return { restrict: 'E', controller: DashLinkEditorCtrl, - templateUrl: 'public/app/features/dashlinks/editor.html', + templateUrl: 'public/app/features/dashboard/dashlinks/editor.html', bindToController: true, controllerAs: 'ctrl', scope: { diff --git a/public/app/features/dashlinks/module.ts b/public/app/features/dashboard/dashlinks/module.ts similarity index 100% rename from public/app/features/dashlinks/module.ts rename to public/app/features/dashboard/dashlinks/module.ts diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/folder_picker/folder_picker.ts index 352b29d27a0..80651fecb7e 100644 --- a/public/app/features/dashboard/folder_picker/folder_picker.ts +++ b/public/app/features/dashboard/folder_picker/folder_picker.ts @@ -131,6 +131,7 @@ export class FolderPickerCtrl { private loadInitialValue() { const resetFolder = { text: this.initialTitle, value: null }; const rootFolder = { text: this.rootName, value: 0 }; + this.getOptions('').then(result => { let folder; if (this.initialFolderId) { @@ -150,7 +151,7 @@ export class FolderPickerCtrl { this.folder = folder; // if this is not the same as our initial value notify parent - if (this.folder.id !== this.initialFolderId) { + if (this.folder.value !== this.initialFolderId) { this.onChange({ $folder: { id: this.folder.value, title: this.folder.text } }); } }); diff --git a/public/app/features/dashboard/folder_settings_ctrl.ts b/public/app/features/dashboard/folder_settings_ctrl.ts deleted file mode 100644 index a847c29ac56..00000000000 --- a/public/app/features/dashboard/folder_settings_ctrl.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { FolderPageLoader } from './folder_page_loader'; -import appEvents from 'app/core/app_events'; - -export class FolderSettingsCtrl { - folderPageLoader: FolderPageLoader; - navModel: any; - folderId: number; - uid: string; - canSave = false; - folder: any; - title: string; - hasChanged: boolean; - - /** @ngInject */ - constructor(private backendSrv, navModelSrv, private $routeParams, private $location) { - if (this.$routeParams.uid) { - this.uid = $routeParams.uid; - - this.folderPageLoader = new FolderPageLoader(this.backendSrv); - this.folderPageLoader.load(this, this.uid, 'manage-folder-settings').then(folder => { - if ($location.path() !== folder.meta.url) { - $location.path(`${folder.meta.url}/settings`).replace(); - } - - this.folder = folder; - this.canSave = this.folder.canSave; - this.title = this.folder.title; - }); - } - } - - save() { - this.titleChanged(); - - if (!this.hasChanged) { - return; - } - - this.folder.title = this.title.trim(); - - return this.backendSrv - .updateFolder(this.folder) - .then(result => { - if (result.url !== this.$location.path()) { - this.$location.url(result.url + '/settings'); - } - - appEvents.emit('dashboard-saved'); - appEvents.emit('alert-success', ['Folder saved']); - }) - .catch(this.handleSaveFolderError); - } - - titleChanged() { - this.hasChanged = this.folder.title.toLowerCase() !== this.title.trim().toLowerCase(); - } - - delete(evt) { - if (evt) { - evt.stopPropagation(); - evt.preventDefault(); - } - - appEvents.emit('confirm-modal', { - title: 'Delete', - text: `Do you want to delete this folder and all its dashboards?`, - icon: 'fa-trash', - yesText: 'Delete', - onConfirm: () => { - return this.backendSrv.deleteFolder(this.uid).then(() => { - appEvents.emit('alert-success', ['Folder Deleted', `${this.folder.title} has been deleted`]); - this.$location.url('dashboards'); - }); - }, - }); - } - - handleSaveFolderError(err) { - if (err.data && err.data.status === 'version-mismatch') { - err.isHandled = true; - - appEvents.emit('confirm-modal', { - title: 'Conflict', - text: 'Someone else has updated this folder.', - text2: 'Would you still like to save this folder?', - yesText: 'Save & Overwrite', - icon: 'fa-warning', - onConfirm: () => { - this.backendSrv.updateFolder(this.folder, { overwrite: true }); - }, - }); - } - } -} diff --git a/public/app/features/panellinks/link_srv.ts b/public/app/features/dashboard/panellinks/link_srv.ts similarity index 100% rename from public/app/features/panellinks/link_srv.ts rename to public/app/features/dashboard/panellinks/link_srv.ts diff --git a/public/app/features/panellinks/module.html b/public/app/features/dashboard/panellinks/module.html similarity index 100% rename from public/app/features/panellinks/module.html rename to public/app/features/dashboard/panellinks/module.html diff --git a/public/app/features/panellinks/module.ts b/public/app/features/dashboard/panellinks/module.ts similarity index 95% rename from public/app/features/panellinks/module.ts rename to public/app/features/dashboard/panellinks/module.ts index a5605c1d641..c10e4ab224e 100644 --- a/public/app/features/panellinks/module.ts +++ b/public/app/features/dashboard/panellinks/module.ts @@ -9,7 +9,7 @@ function panelLinksEditor() { }, restrict: 'E', controller: 'PanelLinksEditorCtrl', - templateUrl: 'public/app/features/panellinks/module.html', + templateUrl: 'public/app/features/dashboard/panellinks/module.html', link: () => {}, }; } diff --git a/public/app/features/panellinks/specs/link_srv.test.ts b/public/app/features/dashboard/panellinks/specs/link_srv.test.ts similarity index 100% rename from public/app/features/panellinks/specs/link_srv.test.ts rename to public/app/features/dashboard/panellinks/specs/link_srv.test.ts diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/permissions/DashboardPermissions.tsx new file mode 100644 index 00000000000..5651242a485 --- /dev/null +++ b/public/app/features/dashboard/permissions/DashboardPermissions.tsx @@ -0,0 +1,116 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import Tooltip from 'app/core/components/Tooltip/Tooltip'; +import SlideDown from 'app/core/components/Animations/SlideDown'; +import { StoreState, FolderInfo } from 'app/types'; +import { DashboardAcl, PermissionLevel, NewDashboardAclItem } from 'app/types/acl'; +import { + getDashboardPermissions, + addDashboardPermission, + removeDashboardPermission, + updateDashboardPermission, +} from '../state/actions'; +import PermissionList from 'app/core/components/PermissionList/PermissionList'; +import AddPermission from 'app/core/components/PermissionList/AddPermission'; +import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; +import { store } from 'app/store/configureStore'; + +export interface Props { + dashboardId: number; + folder?: FolderInfo; + permissions: DashboardAcl[]; + getDashboardPermissions: typeof getDashboardPermissions; + updateDashboardPermission: typeof updateDashboardPermission; + removeDashboardPermission: typeof removeDashboardPermission; + addDashboardPermission: typeof addDashboardPermission; +} + +export interface State { + isAdding: boolean; +} + +export class DashboardPermissions extends PureComponent { + constructor(props) { + super(props); + + this.state = { + isAdding: false, + }; + } + + componentDidMount() { + this.props.getDashboardPermissions(this.props.dashboardId); + } + + onOpenAddPermissions = () => { + this.setState({ isAdding: true }); + }; + + onRemoveItem = (item: DashboardAcl) => { + this.props.removeDashboardPermission(this.props.dashboardId, item); + }; + + onPermissionChanged = (item: DashboardAcl, level: PermissionLevel) => { + this.props.updateDashboardPermission(this.props.dashboardId, item, level); + }; + + onAddPermission = (newItem: NewDashboardAclItem) => { + return this.props.addDashboardPermission(this.props.dashboardId, newItem); + }; + + onCancelAddPermission = () => { + this.setState({ isAdding: false }); + }; + + render() { + const { permissions, folder } = this.props; + const { isAdding } = this.state; + + return ( +
    +
    +
    +

    Permissions

    + + + +
    + +
    +
    + + + + +
    + ); + } +} + +function connectWithStore(WrappedComponent, ...args) { + const ConnectedWrappedComponent = connect(...args)(WrappedComponent); + return props => { + return ; + }; +} + +const mapStateToProps = (state: StoreState) => ({ + permissions: state.dashboard.permissions, +}); + +const mapDispatchToProps = { + getDashboardPermissions, + addDashboardPermission, + removeDashboardPermission, + updateDashboardPermission, +}; + +export default connectWithStore(DashboardPermissions, mapStateToProps, mapDispatchToProps); diff --git a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts b/public/app/features/dashboard/specs/share_modal_ctrl.test.ts index 8a8d94fdddb..70d301ed5ff 100644 --- a/public/app/features/dashboard/specs/share_modal_ctrl.test.ts +++ b/public/app/features/dashboard/specs/share_modal_ctrl.test.ts @@ -1,7 +1,7 @@ import '../shareModalCtrl'; import { ShareModalCtrl } from '../shareModalCtrl'; import config from 'app/core/config'; -import { LinkSrv } from 'app/features/panellinks/link_srv'; +import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv'; describe('ShareModalCtrl', () => { const ctx = { 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/state/actions.ts b/public/app/features/dashboard/state/actions.ts new file mode 100644 index 00000000000..82333817b2b --- /dev/null +++ b/public/app/features/dashboard/state/actions.ts @@ -0,0 +1,115 @@ +import { StoreState } from 'app/types'; +import { ThunkAction } from 'redux-thunk'; +import { getBackendSrv } from 'app/core/services/backend_srv'; + +import { + DashboardAcl, + DashboardAclDTO, + PermissionLevel, + DashboardAclUpdateDTO, + NewDashboardAclItem, +} from 'app/types/acl'; + +export enum ActionTypes { + LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS', +} + +export interface LoadDashboardPermissionsAction { + type: ActionTypes.LoadDashboardPermissions; + payload: DashboardAcl[]; +} + +export type Action = LoadDashboardPermissionsAction; + +type ThunkResult = ThunkAction; + +export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({ + type: ActionTypes.LoadDashboardPermissions, + payload: items, +}); + +export function getDashboardPermissions(id: number): ThunkResult { + return async dispatch => { + const permissions = await getBackendSrv().get(`/api/dashboards/id/${id}/permissions`); + dispatch(loadDashboardPermissions(permissions)); + }; +} + +function toUpdateItem(item: DashboardAcl): DashboardAclUpdateDTO { + return { + userId: item.userId, + teamId: item.teamId, + role: item.role, + permission: item.permission, + }; +} + +export function updateDashboardPermission( + dashboardId: number, + itemToUpdate: DashboardAcl, + level: PermissionLevel +): ThunkResult { + return async (dispatch, getStore) => { + const { dashboard } = getStore(); + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited) { + continue; + } + + const updated = toUpdateItem(itemToUpdate); + + // if this is the item we want to update, update it's permisssion + if (itemToUpdate === item) { + updated.permission = level; + } + + itemsToUpdate.push(updated); + } + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function removeDashboardPermission(dashboardId: number, itemToDelete: DashboardAcl): ThunkResult { + return async (dispatch, getStore) => { + const dashboard = getStore().dashboard; + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited || item === itemToDelete) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} + +export function addDashboardPermission(dashboardId: number, newItem: NewDashboardAclItem): ThunkResult { + return async (dispatch, getStore) => { + const { dashboard } = getStore(); + const itemsToUpdate = []; + + for (const item of dashboard.permissions) { + if (item.inherited) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + itemsToUpdate.push({ + userId: newItem.userId, + teamId: newItem.teamId, + role: newItem.role, + permission: newItem.permission, + }); + + await getBackendSrv().post(`/api/dashboards/id/${dashboardId}/permissions`, { items: itemsToUpdate }); + await dispatch(getDashboardPermissions(dashboardId)); + }; +} diff --git a/public/app/features/dashboard/state/reducers.test.ts b/public/app/features/dashboard/state/reducers.test.ts new file mode 100644 index 00000000000..c5b67f58ac9 --- /dev/null +++ b/public/app/features/dashboard/state/reducers.test.ts @@ -0,0 +1,24 @@ +import { Action, ActionTypes } from './actions'; +import { OrgRole, PermissionLevel, DashboardState } from 'app/types'; +import { inititalState, dashboardReducer } from './reducers'; + +describe('dashboard reducer', () => { + describe('loadDashboardPermissions', () => { + let state: DashboardState; + + beforeEach(() => { + const action: Action = { + type: ActionTypes.LoadDashboardPermissions, + payload: [ + { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, + { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, + ], + }; + state = dashboardReducer(inititalState, action); + }); + + it('should add permissions to state', async () => { + expect(state.permissions.length).toBe(2); + }); + }); +}); diff --git a/public/app/features/dashboard/state/reducers.ts b/public/app/features/dashboard/state/reducers.ts new file mode 100644 index 00000000000..5100529d973 --- /dev/null +++ b/public/app/features/dashboard/state/reducers.ts @@ -0,0 +1,22 @@ +import { DashboardState } from 'app/types'; +import { Action, ActionTypes } from './actions'; +import { processAclItems } from 'app/core/utils/acl'; + +export const inititalState: DashboardState = { + permissions: [], +}; + +export const dashboardReducer = (state = inititalState, action: Action): DashboardState => { + switch (action.type) { + case ActionTypes.LoadDashboardPermissions: + return { + ...state, + permissions: processAclItems(action.payload), + }; + } + return state; +}; + +export default { + dashboard: dashboardReducer, +}; 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/containers/Explore/ElapsedTime.tsx b/public/app/features/explore/ElapsedTime.tsx similarity index 100% rename from public/app/containers/Explore/ElapsedTime.tsx rename to public/app/features/explore/ElapsedTime.tsx diff --git a/public/app/containers/Explore/Explore.tsx b/public/app/features/explore/Explore.tsx similarity index 87% rename from public/app/containers/Explore/Explore.tsx rename to public/app/features/explore/Explore.tsx index 16175747a06..66e1fc0ff6b 100644 --- a/public/app/containers/Explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { hot } from 'react-hot-loader'; import Select from 'react-select'; +import { Query, Range, 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 ElapsedTime from './ElapsedTime'; @@ -47,37 +47,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 { +export interface ExploreState { datasource: any; datasourceError: any; datasourceLoading: boolean | null; datasourceMissing: boolean; + datasourceName?: string; graphResult: any; history: any[]; - initialDatasource?: string; latency: number; loading: any; logsResult: any; - queries: any[]; + queries: Query[]; queryErrors: any[]; queryHints: any[]; - range: any; + range: Range; requestOptions: any; showingGraph: boolean; showingLogs: boolean; @@ -88,20 +83,21 @@ interface ExploreState { 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 +114,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 +129,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 +184,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 +221,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 +261,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 +297,7 @@ export class Explore extends React.Component { state.queries = state.queries.map(({ edited, ...rest }) => rest); if (onChangeSplit) { onChangeSplit(true, state); + this.saveState(); } }; @@ -349,6 +356,7 @@ export class Explore extends React.Component { if (showingLogs && supportsLogs) { this.runLogsQuery(); } + this.saveState(); }; onQuerySuccess(datasourceId: string, queries: any[]): void { @@ -471,6 +479,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 +541,11 @@ export class Explore extends React.Component { {!datasourceMissing ? (
    +
    +
    + + +
    + +
    +
    +
    + ); + } +} + +const mapStateToProps = (state: StoreState) => { + const uid = state.location.routeParams.uid; + + return { + navModel: getNavModel(state.navIndex, `folder-settings-${uid}`, getLoadingNav(2)), + folderUid: uid, + folder: state.folder, + }; +}; + +const mapDispatchToProps = { + getFolderByUid, + saveFolder, + setFolderTitle, + deleteFolder, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(FolderSettingsPage)); diff --git a/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap b/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap new file mode 100644 index 00000000000..2de0c193d27 --- /dev/null +++ b/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should enable save button 1`] = ` +
    + +
    +

    + Folder Settings +

    +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +`; + +exports[`Render should render component 1`] = ` +
    + +
    +

    + Folder Settings +

    +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +`; diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts new file mode 100644 index 00000000000..df81acb91eb --- /dev/null +++ b/public/app/features/folders/state/actions.ts @@ -0,0 +1,167 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { StoreState } from 'app/types'; +import { ThunkAction } from 'redux-thunk'; +import { FolderDTO, FolderState } from 'app/types'; +import { + DashboardAcl, + DashboardAclDTO, + PermissionLevel, + DashboardAclUpdateDTO, + NewDashboardAclItem, +} from 'app/types/acl'; + +import { updateNavIndex, updateLocation } from 'app/core/actions'; +import { buildNavModel } from './navModel'; +import appEvents from 'app/core/app_events'; + +export enum ActionTypes { + LoadFolder = 'LOAD_FOLDER', + SetFolderTitle = 'SET_FOLDER_TITLE', + SaveFolder = 'SAVE_FOLDER', + LoadFolderPermissions = 'LOAD_FOLDER_PERMISSONS', +} + +export interface LoadFolderAction { + type: ActionTypes.LoadFolder; + payload: FolderDTO; +} + +export interface SetFolderTitleAction { + type: ActionTypes.SetFolderTitle; + payload: string; +} + +export interface LoadFolderPermissionsAction { + type: ActionTypes.LoadFolderPermissions; + payload: DashboardAcl[]; +} + +export type Action = LoadFolderAction | SetFolderTitleAction | LoadFolderPermissionsAction; + +type ThunkResult = ThunkAction; + +export const loadFolder = (folder: FolderDTO): LoadFolderAction => ({ + type: ActionTypes.LoadFolder, + payload: folder, +}); + +export const setFolderTitle = (newTitle: string): SetFolderTitleAction => ({ + type: ActionTypes.SetFolderTitle, + payload: newTitle, +}); + +export const loadFolderPermissions = (items: DashboardAclDTO[]): LoadFolderPermissionsAction => ({ + type: ActionTypes.LoadFolderPermissions, + payload: items, +}); + +export function getFolderByUid(uid: string): ThunkResult { + return async dispatch => { + const folder = await getBackendSrv().getFolderByUid(uid); + dispatch(loadFolder(folder)); + dispatch(updateNavIndex(buildNavModel(folder))); + }; +} + +export function saveFolder(folder: FolderState): ThunkResult { + return async dispatch => { + const res = await getBackendSrv().put(`/api/folders/${folder.uid}`, { + title: folder.title, + version: folder.version, + }); + + // this should be redux action at some point + appEvents.emit('alert-success', ['Folder saved']); + + dispatch(updateLocation({ path: `${res.url}/settings` })); + }; +} + +export function deleteFolder(uid: string): ThunkResult { + return async dispatch => { + await getBackendSrv().deleteFolder(uid, true); + dispatch(updateLocation({ path: `dashboards` })); + }; +} + +export function getFolderPermissions(uid: string): ThunkResult { + return async dispatch => { + const permissions = await getBackendSrv().get(`/api/folders/${uid}/permissions`); + dispatch(loadFolderPermissions(permissions)); + }; +} + +function toUpdateItem(item: DashboardAcl): DashboardAclUpdateDTO { + return { + userId: item.userId, + teamId: item.teamId, + role: item.role, + permission: item.permission, + }; +} + +export function updateFolderPermission(itemToUpdate: DashboardAcl, level: PermissionLevel): ThunkResult { + return async (dispatch, getStore) => { + const folder = getStore().folder; + const itemsToUpdate = []; + + for (const item of folder.permissions) { + if (item.inherited) { + continue; + } + + const updated = toUpdateItem(itemToUpdate); + + // if this is the item we want to update, update it's permisssion + if (itemToUpdate === item) { + updated.permission = level; + } + + itemsToUpdate.push(updated); + } + + await getBackendSrv().post(`/api/folders/${folder.uid}/permissions`, { items: itemsToUpdate }); + await dispatch(getFolderPermissions(folder.uid)); + }; +} + +export function removeFolderPermission(itemToDelete: DashboardAcl): ThunkResult { + return async (dispatch, getStore) => { + const folder = getStore().folder; + const itemsToUpdate = []; + + for (const item of folder.permissions) { + if (item.inherited || item === itemToDelete) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + await getBackendSrv().post(`/api/folders/${folder.uid}/permissions`, { items: itemsToUpdate }); + await dispatch(getFolderPermissions(folder.uid)); + }; +} + +export function addFolderPermission(newItem: NewDashboardAclItem): ThunkResult { + return async (dispatch, getStore) => { + const folder = getStore().folder; + const itemsToUpdate = []; + + for (const item of folder.permissions) { + if (item.inherited) { + continue; + } + itemsToUpdate.push(toUpdateItem(item)); + } + + itemsToUpdate.push({ + userId: newItem.userId, + teamId: newItem.teamId, + role: newItem.role, + permission: newItem.permission, + }); + + await getBackendSrv().post(`/api/folders/${folder.uid}/permissions`, { items: itemsToUpdate }); + await dispatch(getFolderPermissions(folder.uid)); + }; +} diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts new file mode 100644 index 00000000000..e6ef763d019 --- /dev/null +++ b/public/app/features/folders/state/navModel.ts @@ -0,0 +1,53 @@ +import { FolderDTO, NavModelItem, NavModel } from 'app/types'; + +export function buildNavModel(folder: FolderDTO): NavModelItem { + return { + icon: 'fa fa-folder-open', + id: 'manage-folder', + subTitle: 'Manage folder dashboards & permissions', + url: '', + text: folder.title, + breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }], + children: [ + { + active: false, + icon: 'fa fa-fw fa-th-large', + id: `folder-dashboards-${folder.uid}`, + text: 'Dashboards', + url: folder.url, + }, + { + active: false, + icon: 'fa fa-fw fa-lock', + id: `folder-permissions-${folder.uid}`, + text: 'Permissions', + url: `${folder.url}/permissions`, + }, + { + active: false, + icon: 'fa fa-fw fa-cog', + id: `folder-settings-${folder.uid}`, + text: 'Settings', + url: `${folder.url}/settings`, + }, + ], + }; +} + +export function getLoadingNav(tabIndex: number): NavModel { + const main = buildNavModel({ + id: 1, + uid: 'loading', + title: 'Loading', + url: 'url', + canSave: false, + version: 0, + }); + + main.children[tabIndex].active = true; + + return { + main: main, + node: main.children[tabIndex], + }; +} diff --git a/public/app/features/folders/state/reducers.test.ts b/public/app/features/folders/state/reducers.test.ts new file mode 100644 index 00000000000..72e97f39562 --- /dev/null +++ b/public/app/features/folders/state/reducers.test.ts @@ -0,0 +1,97 @@ +import { Action, ActionTypes } from './actions'; +import { FolderDTO, OrgRole, PermissionLevel, FolderState } from 'app/types'; +import { inititalState, folderReducer } from './reducers'; + +function getTestFolder(): FolderDTO { + return { + id: 1, + title: 'test folder', + uid: 'asd', + url: 'url', + canSave: true, + version: 0, + }; +} + +describe('folder reducer', () => { + describe('loadFolder', () => { + it('should load folder and set hasChanged to false', () => { + const folder = getTestFolder(); + + const action: Action = { + type: ActionTypes.LoadFolder, + payload: folder, + }; + + const state = folderReducer(inititalState, action); + + expect(state.hasChanged).toEqual(false); + expect(state.title).toEqual('test folder'); + }); + }); + + describe('detFolderTitle', () => { + it('should set title', () => { + const action: Action = { + type: ActionTypes.SetFolderTitle, + payload: 'new title', + }; + + const state = folderReducer(inititalState, action); + + expect(state.hasChanged).toEqual(true); + expect(state.title).toEqual('new title'); + }); + }); + + describe('loadFolderPermissions', () => { + let state: FolderState; + + beforeEach(() => { + const action: Action = { + type: ActionTypes.LoadFolderPermissions, + payload: [ + { id: 2, dashboardId: 1, role: OrgRole.Viewer, permission: PermissionLevel.View }, + { id: 3, dashboardId: 1, role: OrgRole.Editor, permission: PermissionLevel.Edit }, + { + id: 4, + dashboardId: 10, + permission: PermissionLevel.View, + teamId: 1, + team: 'MyTestTeam', + inherited: true, + }, + { + id: 5, + dashboardId: 1, + permission: PermissionLevel.View, + userId: 1, + userLogin: 'MyTestUser', + }, + { + id: 6, + dashboardId: 1, + permission: PermissionLevel.Edit, + teamId: 2, + team: 'MyTestTeam2', + }, + ], + }; + + state = folderReducer(inititalState, action); + }); + + it('should add permissions to state', async () => { + expect(state.permissions.length).toBe(5); + }); + + it('should be sorted by sort rank and alphabetically', async () => { + expect(state.permissions[0].name).toBe('MyTestTeam'); + expect(state.permissions[0].dashboardId).toBe(10); + expect(state.permissions[1].name).toBe('Editor'); + expect(state.permissions[2].name).toBe('Viewer'); + expect(state.permissions[3].name).toBe('MyTestTeam2'); + expect(state.permissions[4].name).toBe('MyTestUser'); + }); + }); +}); diff --git a/public/app/features/folders/state/reducers.ts b/public/app/features/folders/state/reducers.ts new file mode 100644 index 00000000000..4560c999659 --- /dev/null +++ b/public/app/features/folders/state/reducers.ts @@ -0,0 +1,41 @@ +import { FolderState } from 'app/types'; +import { Action, ActionTypes } from './actions'; +import { processAclItems } from 'app/core/utils/acl'; + +export const inititalState: FolderState = { + id: 0, + uid: 'loading', + title: 'loading', + url: '', + canSave: false, + hasChanged: false, + version: 1, + permissions: [], +}; + +export const folderReducer = (state = inititalState, action: Action): FolderState => { + switch (action.type) { + case ActionTypes.LoadFolder: + return { + ...state, + ...action.payload, + hasChanged: false, + }; + case ActionTypes.SetFolderTitle: + return { + ...state, + title: action.payload, + hasChanged: action.payload.trim().length > 0, + }; + case ActionTypes.LoadFolderPermissions: + return { + ...state, + permissions: processAclItems(action.payload), + }; + } + return state; +}; + +export default { + folder: folderReducer, +}; diff --git a/public/app/features/dashboard/dashboard_list_ctrl.ts b/public/app/features/manage-dashboards/DashboardListCtrl.ts similarity index 100% rename from public/app/features/dashboard/dashboard_list_ctrl.ts rename to public/app/features/manage-dashboards/DashboardListCtrl.ts diff --git a/public/app/features/snapshot/snapshot_ctrl.ts b/public/app/features/manage-dashboards/SnapshotListCtrl.ts similarity index 86% rename from public/app/features/snapshot/snapshot_ctrl.ts rename to public/app/features/manage-dashboards/SnapshotListCtrl.ts index 1dde4876cd5..2ff53e7aed5 100644 --- a/public/app/features/snapshot/snapshot_ctrl.ts +++ b/public/app/features/manage-dashboards/SnapshotListCtrl.ts @@ -1,7 +1,6 @@ -import angular from 'angular'; import _ from 'lodash'; -export class SnapshotsCtrl { +export class SnapshotListCtrl { navModel: any; snapshots: any; @@ -35,5 +34,3 @@ export class SnapshotsCtrl { }); } } - -angular.module('grafana.controllers').controller('SnapshotsCtrl', SnapshotsCtrl); diff --git a/public/app/features/manage-dashboards/index.ts b/public/app/features/manage-dashboards/index.ts new file mode 100644 index 00000000000..046740904e1 --- /dev/null +++ b/public/app/features/manage-dashboards/index.ts @@ -0,0 +1,7 @@ +import coreModule from 'app/core/core_module'; + +import { DashboardListCtrl } from './DashboardListCtrl'; +import { SnapshotListCtrl } from './SnapshotListCtrl'; + +coreModule.controller('DashboardListCtrl', DashboardListCtrl); +coreModule.controller('SnapshotListCtrl', SnapshotListCtrl); diff --git a/public/app/features/dashboard/partials/dashboard_list.html b/public/app/features/manage-dashboards/partials/dashboard_list.html similarity index 100% rename from public/app/features/dashboard/partials/dashboard_list.html rename to public/app/features/manage-dashboards/partials/dashboard_list.html diff --git a/public/app/features/snapshot/partials/snapshots.html b/public/app/features/manage-dashboards/partials/snapshot_list.html similarity index 100% rename from public/app/features/snapshot/partials/snapshots.html rename to public/app/features/manage-dashboards/partials/snapshot_list.html diff --git a/public/app/features/org/all.ts b/public/app/features/org/all.ts index 8872450e3ab..1cbca483138 100644 --- a/public/app/features/org/all.ts +++ b/public/app/features/org/all.ts @@ -6,6 +6,5 @@ import './change_password_ctrl'; import './new_org_ctrl'; import './user_invite_ctrl'; import './create_team_ctrl'; -import './org_api_keys_ctrl'; import './org_details_ctrl'; import './prefs_control'; diff --git a/public/app/features/org/org_api_keys_ctrl.ts b/public/app/features/org/org_api_keys_ctrl.ts deleted file mode 100644 index 1ead0a350b9..00000000000 --- a/public/app/features/org/org_api_keys_ctrl.ts +++ /dev/null @@ -1,44 +0,0 @@ -import angular from 'angular'; - -export class OrgApiKeysCtrl { - /** @ngInject */ - constructor($scope, $http, backendSrv, navModelSrv) { - $scope.navModel = navModelSrv.getNav('cfg', 'apikeys', 0); - - $scope.roleTypes = ['Viewer', 'Editor', 'Admin']; - $scope.token = { role: 'Viewer' }; - - $scope.init = () => { - $scope.getTokens(); - }; - - $scope.getTokens = () => { - backendSrv.get('/api/auth/keys').then(tokens => { - $scope.tokens = tokens; - }); - }; - - $scope.removeToken = id => { - backendSrv.delete('/api/auth/keys/' + id).then($scope.getTokens); - }; - - $scope.addToken = () => { - backendSrv.post('/api/auth/keys', $scope.token).then(result => { - const modalScope = $scope.$new(true); - modalScope.key = result.key; - modalScope.rootPath = window.location.origin + $scope.$root.appSubUrl; - - $scope.appEvent('show-modal', { - src: 'public/app/features/org/partials/apikeyModal.html', - scope: modalScope, - }); - - $scope.getTokens(); - }); - }; - - $scope.init(); - } -} - -angular.module('grafana.controllers').controller('OrgApiKeysCtrl', OrgApiKeysCtrl); diff --git a/public/app/features/org/partials/apikeyModal.html b/public/app/features/org/partials/apikeyModal.html deleted file mode 100644 index eeefcafc634..00000000000 --- a/public/app/features/org/partials/apikeyModal.html +++ /dev/null @@ -1,37 +0,0 @@ - - diff --git a/public/app/features/org/partials/orgApiKeys.html b/public/app/features/org/partials/orgApiKeys.html deleted file mode 100644 index a2b4ceb6670..00000000000 --- a/public/app/features/org/partials/orgApiKeys.html +++ /dev/null @@ -1,49 +0,0 @@ - - -
    - -

    Add new

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