diff --git a/.bra.toml b/.bra.toml index 54019208984..36421a3c5a6 100644 --- a/.bra.toml +++ b/.bra.toml @@ -1,7 +1,7 @@ [run] init_cmds = [ ["go", "build", "-o", "./bin/grafana-server", "./pkg/cmd/grafana-server"], - ["./bin/grafana-server"] + ["./bin/grafana-server", "cfg:app_mode=development"] ] watch_all = true watch_dirs = [ @@ -9,9 +9,9 @@ watch_dirs = [ "$WORKDIR/public/views", "$WORKDIR/conf", ] -watch_exts = [".go", ".ini", ".toml", ".html"] +watch_exts = [".go", ".ini", ".toml"] build_delay = 1500 cmds = [ ["go", "build", "-o", "./bin/grafana-server", "./pkg/cmd/grafana-server"], - ["./bin/grafana-server"] + ["./bin/grafana-server", "cfg:app_mode=development"] ] diff --git a/.editorconfig b/.editorconfig index 386c27fceb8..146224e7330 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,13 +1,6 @@ # http://editorconfig.org root = true -[*.go] -indent_style = tab -indent_size = 4 -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - [*] indent_style = space indent_size = 2 @@ -15,5 +8,12 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +[*.go] +indent_style = tab +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + [*.md] trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index f14b48eb248..fb43f933731 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ coverage/ .aws-config.json awsconfig /dist +/public/build +/public/views/index.html /emails/dist /public_gen /public/vendor/npm @@ -39,6 +41,14 @@ profile.cov .notouch /pkg/cmd/grafana-cli/grafana-cli /pkg/cmd/grafana-server/grafana-server +/pkg/cmd/grafana-server/debug /examples/*/dist /packaging/**/*.rpm /packaging/**/*.deb + +/vendor/**/*.py +/vendor/**/*.xml +/vendor/**/*.yml +/vendor/**/*_test.go +/vendor/**/.editorconfig +/vendor/**/appengine* diff --git a/.jshintrc b/.jshintrc index 510726ee46b..3fb6501c2f9 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1,6 +1,6 @@ { "browser": true, - + "esversion": 6, "bitwise":false, "curly": true, "eqnull": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index e01a3e20070..f715a54de68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,69 @@ - UX changes to nav & side menu - New dashboard grid layout system -# 4.6.0 (unreleased) +# 4.7.0 (unreleased) ## New Features +* **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson) + +## Fixes +* **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand) + +# 4.6.0-beta3 (unreleased) +* **Prometheus**: Fix for browser crash for short time ranges. [#9575](https://github.com/grafana/grafana/issues/9575) +* **Heatmap**: Fix for y-axis not showing. [#9576](https://github.com/grafana/grafana/issues/9576) + +# 4.6.0-beta2 (2017-10-17) + +## Fixes +* **ColorPicker**: Fix for color picker not showing [#9549](https://github.com/grafana/grafana/issues/9549) +* **Alerting**: Fix for broken test rule button in alert tab [#9539](https://github.com/grafana/grafana/issues/9539) +* **Cloudwatch**: Provide error message when failing to add cloudwatch datasource [#9534](https://github.com/grafana/grafana/pull/9534), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: Fix unused period parameter [#9536](https://github.com/grafana/grafana/pull/9536), thx [@mtanda](https://github.com/mtanda) +* **CSV Export**: Fix for broken CSV export [#9525](https://github.com/grafana/grafana/issues/9525) +* **Text panel**: Fix for issue with break lines in Firefox [#9491](https://github.com/grafana/grafana/issues/9491) +* **Annotations**: Fix for issue saving annotation event in MySQL DB [#9550](https://github.com/grafana/grafana/issues/9550), thanks [@krise3k](https://github.com/krise3k) + + +# 4.6.0-beta1 (2017-10-13) + +## New Features +* **Annotations**: Add support for creating annotations from graph panel [#8197](https://github.com/grafana/grafana/pull/8197) * **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) * **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) * **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) * **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) -* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/6710) -* **Cli**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) +* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) +* **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) +* **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: Add support for alerting using the cloudwatch datasource [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) +* **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse) +* **Timezone**: Time ranges like Today & Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide) +* **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) +* **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) +* **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +* **Datasources**: Make datasource HTTP requests verify TLS by default. closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) +* **OAuth**: Verify TLS during OAuth callback [#9373](https://github.com/grafana/grafana/issues/9373), thx [@mattbostock](https://github.com/mattbostock) -## Breaking changes -* **Metrics**: The metric structure for internal metrics about Grafana published to graphite has changed. This might break dashboards for internal metrics. +## Minor +* **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319) +* **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250) +* **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367) +* **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739) +* **Slack**: Allow images to be uploaded to slack when Token is precent [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8) +* **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) +* **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) +* **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) +* **Kafka**: Add support for sending alert notifications to kafka [#7104](https://github.com/grafana/grafana/issues/7104), thx [@utkarshcmu](https://github.com/utkarshcmu) +* **Alerting**: add count_non_null as series reducer [#9516](https://github.com/grafana/grafana/issues/9516) + +## Tech +* **Go**: Grafana is now built using golang 1.9 +* **Webpack**: Changed from systemjs to webpack (see readme or building from source guide for new build instructions). Systemjs is still used to load plugins but now plugins can only import a limited set of dependencies. See [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) for more details on how this can effect some plugins. # 4.5.2 (2017-09-22) -## Fixes +## Fixes * **Graphite**: Fix for issues with jsonData & graphiteVersion null errors [#9258](https://github.com/grafana/grafana/issues/9258) * **Graphite**: Fix for Grafana internal metrics to Graphite sending NaN values [#9279](https://github.com/grafana/grafana/issues/9279) * **HTTP API**: Fix for HEAD method requests [#9307](https://github.com/grafana/grafana/issues/9307) @@ -34,6 +81,9 @@ ## Fixes * **MySQL**: Fixed issue with query editor not showing [#9247](https://github.com/grafana/grafana/issues/9247) +## Breaking changes +* **Metrics**: The metric structure for internal metrics about Grafana published to graphite has changed. This might break dashboards for internal metrics. + # 4.5.0 (2017-09-14) ## Fixes & Enhancements since beta1 @@ -61,7 +111,7 @@ ### Breaking change * **InfluxDB/Elasticsearch**: The panel & data source option named "Group by time interval" is now named "Min time interval" and does now always define a lower limit for the auto group by time. Without having to use `>` prefix (that prefix still works). This should in theory have close to zero actual impact on existing dashboards. It does mean that if you used this setting to define a hard group by time interval of, say "1d", if you zoomed to a time range wide enough the time range could increase above the "1d" range as the setting is now always considered a lower limit. -* **Elasticsearch**: Elasticsearch metric queries without date histogram now return table formated data making table panel much easier to use for this use case. Should not break/change existing dashboards with stock panels but external panel plugins can be affected. +* **Elasticsearch**: Elasticsearch metric queries without date histogram now return table formated data making table panel much easier to use for this use case. Should not break/change existing dashboards with stock panels but external panel plugins can be affected. ## Changes diff --git a/Gruntfile.js b/Gruntfile.js index 50ac332d894..335d8691677 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -31,7 +31,7 @@ module.exports = function (grunt) { require('load-grunt-tasks')(grunt); // load task definitions - grunt.loadTasks('tasks'); + grunt.loadTasks('./scripts/grunt'); // Utility function to load plugin settings into config function loadConfig(config,path) { @@ -46,7 +46,7 @@ module.exports = function (grunt) { } // Merge that object with what with whatever we have here - loadConfig(config,'./tasks/options/'); + loadConfig(config,'./scripts/grunt/options/'); // pass the config to grunt grunt.initConfig(config); }; diff --git a/PLUGIN_DEV.md b/PLUGIN_DEV.md new file mode 100644 index 00000000000..9d831a95697 --- /dev/null +++ b/PLUGIN_DEV.md @@ -0,0 +1,28 @@ +# Plugin Development + +This document is not meant as complete guide for developing plugins but more as a changelog for changes in +Grafana that can impact plugin development. When ever you as plugin author encounter an issue with your plugin after +upgrading Grafana please check here before creating an issue. + +## Links + +- [Datasource plugin written in typescript](https://github.com/grafana/typescript-template-datasource) +- [Simple json dataource plugin](https://github.com/grafana/simple-json-datasource) +- [Plugin development guide](http://docs.grafana.org/plugins/developing/development/) + +## Changes in v4.6 + +This version of Grafana has big changes that will impact a limited set of plugins. We moved from systemjs to webpack +for built-in plugins & everything internal. External plugins still use systemjs but now with a limited +set of Grafana components they can import. Plugins can depend on libs like lodash & moment and internal components +like before using the same import paths. However since everything in Grafana is no longer accessible, a few plugins could encounter issues when importing a Grafana dependency. + +[List of exposed components plugins can import/require](https://github.com/grafana/grafana/blob/master/public/app/features/plugins/plugin_loader.ts#L48) + +If you think we missed exposing a crucial lib or Grafana component let us know by opening an issue. + +### Deprecated components + +The angular directive `` is now deprecated (will still work for a version more) but we recommend plugin authors +to upgrade to new `` + diff --git a/README.md b/README.md index a61640b096a..28f8bfc1ba2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ the latest master builds [here](https://grafana.com/grafana/download) ### Dependencies -- Go 1.8.1 +- Go 1.9 - NodeJS LTS ### Building the backend @@ -37,8 +37,7 @@ go run build.go build ### Building frontend assets -To build less to css for the frontend you will need a recent version of **node (v6+)**, -npm (v2.5.0) and grunt (v0.4.5). Run the following: +For this you need nodejs (v.6+). ```bash npm install -g yarn @@ -46,13 +45,24 @@ yarn install --pure-lockfile npm run build ``` -To build the frontend assets only on changes: +To rebuild frontend assets (typescript, sass etc) as you change them start the watcher via. ```bash -npm run dev +npm run watch +``` + +Run tests +```bash +npm run test +``` + +Run tests in watch mode +```bash +npm run watch-test ``` ### Recompile backend on source change + To rebuild on source change. ```bash go get github.com/Unknwon/bra @@ -69,11 +79,20 @@ You only need to add the options you want to override. Config files are applied 1. grafana.ini 1. custom.ini +In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = development`. + ## Contribute + If you have any idea for an improvement or found a bug do not hesitate to open an issue. And if you have time clone this repo and submit a pull request and help me make Grafana the kickass metrics & devops dashboard we all dream about! +## Plugin development + +Checkout the [Plugin Development Guide](http://docs.grafana.org/plugins/developing/development/) and checkout the [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) file for changes in Grafana that relate to +plugin development. + ## License + Grafana is distributed under Apache 2.0 License. -Work in progress Grafana 2.0 (with included Grafana backend) + diff --git a/ROADMAP.md b/ROADMAP.md index 62e6719c4f0..3ce0c33f088 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,7 @@ But it will give you an idea of our current vision and plan. ### Long term - Backend plugins to support more Auth options, Alerting data sources & notifications -- Universial time series transformations for any data source (meta queries) +- Universal time series transformations for any data source (meta queries) - Reporting - Web socket & live data streams - Migrate to Angular2 or react diff --git a/appveyor.yml b/appveyor.yml index 9f8e9a26622..19de1d3a793 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana environment: nodejs_version: "6" GOPATH: c:\gopath - GOVERSION: 1.8 + GOVERSION: 1.9.1 install: - rmdir c:\go /s /q diff --git a/circle.yml b/circle.yml index b8767b79b8b..ba44172ddf3 100644 --- a/circle.yml +++ b/circle.yml @@ -9,7 +9,7 @@ machine: GOPATH: "/home/ubuntu/.go_workspace" ORG_PATH: "github.com/grafana" REPO_PATH: "${ORG_PATH}/grafana" - GODIST: "go1.8.linux-amd64.tar.gz" + GODIST: "go1.9.1.linux-amd64.tar.gz" post: - mkdir -p ~/download - mkdir -p ~/docker diff --git a/conf/defaults.ini b/conf/defaults.ini index 5abd27e5c51..14e77449241 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -318,6 +318,7 @@ key_file = skip_verify = false from_address = admin@grafana.localhost from_name = Grafana +ehlo_identity = [emails] welcome_email_on_sign_up = false @@ -476,6 +477,9 @@ provider = [external_image_storage.s3] bucket_url = +bucket = +region = +path = access_key = secret_key = diff --git a/conf/sample.ini b/conf/sample.ini index 076b9306d84..1aedfbf6532 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -295,6 +295,8 @@ ;skip_verify = false ;from_address = admin@grafana.localhost ;from_name = Grafana +# EHLO identity in SMTP dialog (defaults to instance_name) +;ehlo_identity = dashboard.example.com [emails] ;welcome_email_on_sign_up = false @@ -420,7 +422,9 @@ ;provider = [external_image_storage.s3] -;bucket_url = +;bucket = +;region = +;path = ;access_key = ;secret_key = diff --git a/docker/blocks/prometheus/prometheus.yml b/docker/blocks/prometheus/prometheus.yml index 0afb7968ce5..ae40dfdf067 100644 --- a/docker/blocks/prometheus/prometheus.yml +++ b/docker/blocks/prometheus/prometheus.yml @@ -17,20 +17,19 @@ alerting: - targets: - "127.0.0.1:9093" - -# A scrape configuration containing exactly one endpoint to scrape: -# Here it's Prometheus itself. scrape_configs: - # The job name is added as a label `job=` to any timeseries scraped from this config. - job_name: 'prometheus' - - # Override the global default and scrape targets from this job every 5 seconds. - scrape_interval: 10s - scrape_timeout: 10s - - # metrics_path defaults to '/metrics' - # scheme defaults to 'http'. - static_configs: - #- targets: ['localhost:9090', '172.17.0.1:9091', '172.17.0.1:9100', '172.17.0.1:9150'] - - targets: ['localhost:9090', '127.0.0.1:9091', '127.0.0.1:9100', '127.0.0.1:9150'] + - targets: ['localhost:9090'] + + - job_name: 'node_exporter' + static_configs: + - targets: ['127.0.0.1:9100'] + + - job_name: 'fake-data-gen' + static_configs: + - targets: ['127.0.0.1:9091'] + + - job_name: 'grafana' + static_configs: + - targets: ['127.0.0.1:3000'] diff --git a/docs/sources/administration/cli.md b/docs/sources/administration/cli.md index 645f75ab412..2be1881cfab 100644 --- a/docs/sources/administration/cli.md +++ b/docs/sources/administration/cli.md @@ -41,7 +41,7 @@ then there are two flags that can be used to set homepath and the config file pa If you have not lost the admin password then it is better to set in the Grafana UI. If you need to set the password in a script then the [Grafana API](http://docs.grafana.org/http_api/user/#change-password) can be used. Here is an example with curl using basic auth: -``` +```bash curl -X PUT -H "Content-Type: application/json" -d '{ "oldPassword": "admin", "newPassword": "newpass", diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 359315650d9..102134f34dd 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -48,12 +48,15 @@ external image destination if available or fallback to attaching the image in th To set up slack you need to configure an incoming webhook url at slack. You can follow their guide for how to do that https://api.slack.com/incoming-webhooks If you want to include screenshots of the firing alerts -in the slack messages you have to configure the [external image destination](#external-image-store) in Grafana. +in the slack messages you have to configure either the [external image destination](#external-image-store) in Grafana, +or a bot integration via Slack Apps. Follow Slack's guide to set up a bot integration and use the token provided +https://api.slack.com/bot-users, which starts with "xoxb". Setting | Description ---------- | ----------- Recipient | allows you to override the slack recipient. Mention | make it possible to include a mention in the slack notification sent by Grafana. Ex @here or @channel +Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination. ### PagerDuty @@ -112,6 +115,17 @@ In DingTalk PC Client: Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `text` message type is supported. +### Kafka + +Notifications can be sent to a Kafka topic from Grafana using [Kafka REST Proxy](https://docs.confluent.io/1.0/kafka-rest/docs/index.html). +There are couple of configurations options which need to be set in Grafana UI under Kafka Settings: + +1. Kafka REST Proxy endpoint. + +2. Kafka Topic. + +Once these two properties are set, you can send the alerts to Kafka for further processing or throttling them. + ### Other Supported Notification Channels Grafana also supports the following Notification Channels: diff --git a/docs/sources/archive.md b/docs/sources/archive.md index 1d656880286..aa36e1ee620 100644 --- a/docs/sources/archive.md +++ b/docs/sources/archive.md @@ -13,6 +13,7 @@ Here you can find links to older versions of the documentation that might be bet of Grafana. - [Latest](http://docs.grafana.org) +- [Version 4.5](http://docs.grafana.org/v4.5) - [Version 4.4](http://docs.grafana.org/v4.4) - [Version 4.3](http://docs.grafana.org/v4.3) - [Version 4.2](http://docs.grafana.org/v4.2) diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index 61dd90d2881..1d98b2d16d1 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -29,7 +29,7 @@ Name | Description ------------ | ------------- *Name* | The data source name. This is how you refer to the data source in panels & queries. *Default* | Default data source means that it will be pre-selected for new panels. -*Credentials* profile name | Specify the name of the profile to use (if you use `~/aws/credentials` file), leave blank for default. +*Credentials* profile name | Specify the name of the profile to use (if you use `~/.aws/credentials` file), leave blank for default. *Default Region* | Used in query editor to set region (can be changed on per query basis) *Custom Metrics namespace* | Specify the CloudWatch namespace of Custom metrics *Assume Role Arn* | Specify the ARN of the role to assume @@ -50,11 +50,12 @@ Create a file at `~/.aws/credentials`. That is the `HOME` path for user running Example content: - [default] - aws_access_key_id = asdsadasdasdasd - aws_secret_access_key = dasdasdsadasdasdasdsa - region = us-west-2 - +```bash +[default] +aws_access_key_id = asdsadasdasdasd +aws_secret_access_key = dasdasdsadasdasdasdsa +region = us-west-2 +``` ## Metric Query Editor @@ -117,7 +118,9 @@ Filters syntax: Example `ec2_instance_attribute()` query - ec2_instance_attribute(us-east-1, InstanceId, { "tag:Environment": [ "production" ] }) +```javascript +ec2_instance_attribute(us-east-1, InstanceId, { "tag:Environment": [ "production" ] }) +``` ### Selecting Attributes @@ -156,7 +159,9 @@ Tags can be selected by prepending the tag name with `Tags.` Example `ec2_instance_attribute()` query - ec2_instance_attribute(us-east-1, Tags.Name, { "tag:Team": [ "sysops" ] }) +```javascript +ec2_instance_attribute(us-east-1, Tags.Name, { "tag:Team": [ "sysops" ] }) +``` ## Cost diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index 25cdb98c8c5..6ce17113a9b 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -38,8 +38,10 @@ Proxy access means that the Grafana backend will proxy all requests from the bro If you select direct access you must update your Elasticsearch configuration to allow other domains to access Elasticsearch from the browser. You do this by specifying these to options in your **elasticsearch.yml** config file. - http.cors.enabled: true - http.cors.allow-origin: "*" +```bash +http.cors.enabled: true +http.cors.allow-origin: "*" +``` ### Index settings @@ -133,6 +135,5 @@ Name | Description ------------ | ------------- Query | You can leave the search query blank or specify a lucene query Time | The name of the time field, needs to be date field. -Title | The name of the field to use for the event title. +Text | Event description field. Tags | Optional field name to use for event tags (can be an array or a CSV string). -Text | Optional field name to use event text body. diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md new file mode 100644 index 00000000000..2f170ba40be --- /dev/null +++ b/docs/sources/features/datasources/postgres.md @@ -0,0 +1,186 @@ ++++ +title = "Using PostgreSQL in Grafana" +description = "Guide for using PostgreSQL in Grafana" +keywords = ["grafana", "postgresql", "guide"] +type = "docs" +[menu.docs] +name = "PostgreSQL" +parent = "datasources" +weight = 7 ++++ + +# Using PostgreSQL in Grafana + +Grafana ships with a built-in PostgreSQL data source plugin that allows you to query and visualize data from a PostgreSQL compatible database. + +## 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`. +3. Click the `+ Add data source` button in the top header. +4. Select *PostgreSQL* from the *Type* dropdown. + +### Database User Permissions (Important!) + +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. + +Example: + +```sql + CREATE USER grafanareader WITH PASSWORD 'password'; + GRANT USAGE ON SCHEMA schema TO grafanareader; + GRANT SELECT ON schema.table TO grafanareader; +``` + +Make sure the user does not get any unwanted privileges from the public role. + +## Macros + +To simplify syntax and to allow for dynamic parts, like date range filters, the query can contain macros. + +Macro example | Description +------------ | ------------- +*$__time(dateColumn)* | Will be replaced by an expression to rename the column to `time`. For example, *dateColumn as time* +*$__timeSec(dateColumn)* | Will be replaced by an expression to rename the column to `time` and converting the value to unix timestamp. For example, *extract(epoch from dateColumn) as time* +*$__timeFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name. For example, *dateColumn > to_timestamp(1494410783) AND dateColumn < to_timestamp(1494497183)* +*$__timeFrom()* | Will be replaced by the start of the currently active time selection. For example, *to_timestamp(1494410783)* +*$__timeTo()* | Will be replaced by the end of the currently active time selection. For example, *to_timestamp(1494497183)* +*$__timeGroup(dateColumn,'5m')* | Will be replaced by an expression usable in GROUP BY clause. For example, *(extract(epoch from "dateColumn")/extract(epoch from '5m'::interval))::int* +*$__unixEpochFilter(dateColumn)* | Will be replaced by a time range filter using the specified column name with times represented as unix timestamp. For example, *dateColumn > 1494410783 AND dateColumn < 1494497183* +*$__unixEpochFrom()* | Will be replaced by the start of the currently active time selection as unix timestamp. For example, *1494410783* +*$__unixEpochTo()* | Will be replaced by the end of the currently active time selection as unix timestamp. For example, *1494497183* + +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. + +Query editor with example query: + +![](/img/docs/v46/postgres_table_query.png) + + +The query: + +```sql +SELECT + title as "Title", + "user".login as "Created By", + dashboard.created as "Created On" +FROM dashboard +INNER JOIN "user" on "user".id = dashboard.created_by +WHERE $__timeFilter(dashboard.created) +``` + +You can control the name of the Table panel columns by using regular `as ` SQL column selection syntax. + +The resulting table panel: + +![](/img/docs/v46/postgres_table.png) + +### 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 in seconds. +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. + +Example with `metric` column + +```sql +SELECT + min(time_date_time) as time, + min(value_double), + 'min' as metric +FROM test_data +WHERE $__timeFilter(time_date_time) +GROUP BY metric1, (extract(epoch from time_date_time)/extract(epoch from $__interval::interval))::int +ORDER BY time asc +``` + +Example with multiple columns: + +```sql +SELECT + min(time_date_time) as time, + min(value_double) as min_value, + max(value_double) as max_value +FROM test_data +WHERE $__timeFilter(time_date_time) +GROUP BY metric1, (extract(epoch from time_date_time)/extract(epoch from $__interval::interval))::int +ORDER BY time asc +``` + +## 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 + +If you add a template variable of the type `Query`, you can write a PostgreSQL query that can +return things like measurement names, key names or key values that are shown as a dropdown select box. + +For example, you can have a variable that contains all values for the `hostname` column in a table if you specify a query like this in the templating variable *Query* setting. + +```sql +SELECT hostname FROM host +``` + +A query can return multiple columns and Grafana will automatically create a list from them. For example, the query below will return a list with values from `hostname` and `hostname2`. + +```sql +SELECT host.hostname, other_host.hostname2 FROM host JOIN other_host ON host.city = other_host.city +``` + +Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value: + +```sql +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 +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 +SELECT hostname FROM host WHERE region IN($region) +``` + +### Using Variables in Queries + +Template variables are quoted automatically so if it is a string value do not wrap them in quotes in where clauses. If the variable is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values. + +There are two syntaxes: + +`$` Example with a template variable named `hostname`: + +```sql +SELECT + atimestamp as time, + aint as value +FROM table +WHERE $__timeFilter(atimestamp) and hostname in($hostname) +ORDER BY atimestamp ASC +``` + +`[[varname]]` Example with a template variable named `hostname`: + +```sql +SELECT + atimestamp as time, + aint as value +FROM table +WHERE $__timeFilter(atimestamp) and hostname in([[hostname]]) +ORDER BY atimestamp ASC +``` + +## Alerting + +Time series queries should work in alerting conditions. Table formatted queries is not yet supported in alert rule +conditions. diff --git a/docs/sources/features/panels/alertlist.md b/docs/sources/features/panels/alertlist.md new file mode 100644 index 00000000000..9307bb71391 --- /dev/null +++ b/docs/sources/features/panels/alertlist.md @@ -0,0 +1,27 @@ ++++ +title = "Alert List" +keywords = ["grafana", "alert list", "documentation", "panel", "alertlist"] +type = "docs" +aliases = ["/reference/alertlist/"] +[menu.docs] +name = "Alert list" +parent = "panels" +weight = 4 ++++ + + +# Alert List Panel + +{{< docs-imagebox img="/img/docs/v45/alert-list-panel.png" max-width="850px" >}} + +The alert list panel allows you to display your dashbords alerts. The list can be configured to show current state or recent state changes. You can read more about alerts [here](http://docs.grafana.org/alerting/rules). + +## Alert List Options + +{{< docs-imagebox img="/img/docs/v45/alert-list-options.png" max-width="600px" class="docs-image--no-shadow docs-image--right" >}} + +1. **Show**: Lets you choose between current state or recent state changes. +2. **Max Items**: Max items set the maximum of items in a list. +3. **Sort Order**: Lets you sort your list alphabeticaly(asc/desc) or by importance. +4. **Alerts From** This Dashboard`: Shows alerts only from the dashboard the alert list is in. +5. **State Filter**: Here you can filter your list by one or more parameters. diff --git a/docs/sources/features/panels/dashlist.md b/docs/sources/features/panels/dashlist.md index 15d1e0920dd..8a4ed60875d 100644 --- a/docs/sources/features/panels/dashlist.md +++ b/docs/sources/features/panels/dashlist.md @@ -12,7 +12,7 @@ weight = 4 # Dashboard List Panel - +{{< docs-imagebox img="/img/docs/v45/dashboard-list-panels.png" max-width="850px">}} The dashboard list panel allows you to display dynamic links to other dashboards. The list can be configured to use starred dashboards, recently viewed dashboards, a search query and/or dashboard tags. @@ -20,15 +20,17 @@ The dashboard list panel allows you to display dynamic links to other dashboards ## Dashboard List Options -{{< docs-imagebox img="/img/docs/v45/dashboard-list-options.png" max-width="600px" class="docs-image--no-shadow">}} +{{< docs-imagebox img="/img/docs/v45/dashboard-list-options.png" class="docs-image--no-shadow docs-image--right">}} -1. `Starred`: The starred dashboard selection displays starred dashboards in alphabetical order. -2. `Recently Viewed`: The recently viewed dashboard selection displays recently viewed dashboards in alphabetical order. -3. `Search`: The search dashboard selection displays dashboards by search query or tag(s). -4. `Show Headings`: When show headings is ticked the choosen list selection(Starred, Recently Viewed, Search) is shown as a heading. -5. `Max Items`: Max items set the maximum of items in a list. -6. `Query`: Here is where you enter your query you want to search by. Queries are case-insensitive, and partial values are accepted. -7. `Tags`: Here is where you enter your tag(s) you want to search by. Note that existing tags will not appear as you type, and *are* case sensitive. To see a list of existing tags, you can always return to the dashboard, open the Dashboard Picker at the top and click `tags` link in the search bar. +1. **Starred**: The starred dashboard selection displays starred dashboards in alphabetical order. +2. **Recently Viewed**: The recently viewed dashboard selection displays recently viewed dashboards in alphabetical order. +3. **Search**: The search dashboard selection displays dashboards by search query or tag(s). +4. **Show Headings**: When show headings is ticked the choosen list selection(Starred, Recently Viewed, Search) is shown as a heading. +5. **Max Items**: Max items set the maximum of items in a list. +6. **Query**: Here is where you enter your query you want to search by. Queries are case-insensitive, and partial values are accepted. +7. **Tags**: Here is where you enter your tag(s) you want to search by. Note that existing tags will not appear as you type, and *are* case sensitive. To see a list of existing tags, you can always return to the dashboard, open the Dashboard Picker at the top and click `tags` link in the search bar. + +
> When multiple tags and strings appear, the dashboard list will display those matching ALL conditions. diff --git a/docs/sources/features/panels/graph.md b/docs/sources/features/panels/graph.md index 0267ffaf79c..c3b0260c98b 100644 --- a/docs/sources/features/panels/graph.md +++ b/docs/sources/features/panels/graph.md @@ -11,9 +11,9 @@ weight = 1 # Graph Panel -The main panel in Grafana is simply named Graph. It provides a very rich set of graphing options. +{{< docs-imagebox img="/img/docs/v45/graph_overview.png" class="docs-image--no-shadow" max-width="850px" >}} - +The main panel in Grafana is simply named Graph. It provides a very rich set of graphing options. 1. Clicking the title for a panel exposes a menu. The `edit` option opens additional configuration options for the panel. @@ -22,15 +22,15 @@ options for the panel. ## General -![](/img/docs/v43/graph_general.png) +{{< docs-imagebox img="/img/docs/v43/graph_general.png" max-width= "900px" >}} The general tab allows customization of a panel's appearance and menu options. ### General Options -- ``Title`` - The panel title on the dashboard -- ``Span`` - The panel width in columns -- ``Height`` - The panel contents height in pixels +- **Title** - The panel title on the dashboard +- **Span** - The panel width in columns +- **Height** - The panel contents height in pixels ### Drilldown / detail link @@ -54,46 +54,48 @@ options. ## Axes -![](/img/docs/v43/graph_axes_grid_options.png) +{{< docs-imagebox img="/img/docs/v43/graph_axes_grid_options.png" max-width= "900px" >}} -The Axes tab controls the display of axes, grids and legend. The ``Left Y`` and ``Right Y`` can be customized using: +The Axes tab controls the display of axes, grids and legend. The **Left Y** and **Right Y** can be customized using: -- ``Unit`` - The display unit for the Y value -- ``Grid Max`` - The maximum Y value. (default auto) -- ``Grid Min`` - The minimum Y value. (default auto) -- ``Label`` - The Y axis label (default "") +- **Unit** - The display unit for the Y value +- **Scale** - +- **Y-Min** - The minimum Y value. (default auto) +- **Y-Max** - The maximum Y value. (default auto) +- **Label** - The Y axis label (default "") -Axes can also be hidden by unchecking the appropriate box from `Show Axis`. +Axes can also be hidden by unchecking the appropriate box from **Show**. ### X-Axis Mode There are three options: -- The default option is `Time` and means the x-axis represents time and that the data is grouped by time (for example, by hour or by minute). +- The default option is **Time** and means the x-axis represents time and that the data is grouped by time (for example, by hour or by minute). -- The `Series` option means that the data is grouped by series and not by time. The y-axis still represents the value. +- The **Series** option means that the data is grouped by series and not by time. The y-axis still represents the value. - + {{< docs-imagebox img="/img/docs/v45/graph-x-axis-mode-series.png" max-width="700px">}} -- The `Histogram` option converts the graph into a histogram. A Histogram is a kind of bar chart that groups numbers into ranges, often called buckets or bins. Taller bars show that more data falls in that range. Histograms and buckets are described in more detail [here](http://docs.grafana.org/features/panels/heatmap/#histograms-and-buckets). +- The **Histogram** option converts the graph into a histogram. A Histogram is a kind of bar chart that groups numbers into ranges, often called buckets or bins. Taller bars show that more data falls in that range. Histograms and buckets are described in more detail [here](http://docs.grafana.org/features/panels/heatmap/#histograms-and-buckets). ### Legend -The legend hand be hidden by checking the ``Show`` checkbox. If it's shown, it can be -displayed as a table of values by checking the ``Table`` checkbox. Series with no -values can be hidden from the legend using the ``Hide empty`` checkbox. +The legend hand be hidden by checking the **Show** checkbox. If it's shown, it can be +displayed as a table of values by checking the **Table** checkbox. Series with no +values can be hidden from the legend using the **Hide empty** checkbox. ### Legend Values Additional values can be shown along-side the legend names: -- ``Total`` - Sum of all values returned from metric query -- ``Current`` - Last value returned from the metric query -- ``Min`` - Minimum of all values returned from metric query -- ``Max`` - Maximum of all values returned from the metric query -- ``Avg`` - Average of all values returned from metric query -- ``Decimals`` - Controls how many decimals are displayed for legend values (and graph hover tooltips) + +- **Total** - Sum of all values returned from metric query +- **Current** - Last value returned from the metric query +- **Min** - Minimum of all values returned from metric query +- **Max** - Maximum of all values returned from the metric query +- **Avg** - Average of all values returned from metric query +- **Decimals** - Controls how many decimals are displayed for legend values (and graph hover tooltips) The legend values are calculated client side by Grafana and depend on what type of aggregation or point consolidation your metric query is using. All the above legend values cannot @@ -103,7 +105,7 @@ It is just the sum of all data points received by Grafana. ## Display styles -![](/img/docs/v43/graph_display_styles.png) +{{< docs-imagebox img="/img/docs/v43/graph_display_styles.png" max-width= "900px" >}} Display styles control visual properties of the graph. @@ -115,23 +117,23 @@ the graph crosses a particular threshold. ### Chart Options -- ``Bar`` - Display values as a bar chart -- ``Lines`` - Display values as a line graph -- ``Points`` - Display points for values +- **Bar** - Display values as a bar chart +- **Lines** - Display values as a line graph +- **Points** - Display points for values ### Line Options -- ``Line Fill`` - Amount of color fill for a series. 0 is none. -- ``Line Width`` - The width of the line for a series. -- ``Null point mode`` - How null values are displayed -- ``Staircase line`` - Draws adjacent points as staircase +- **Line Fill** - Amount of color fill for a series. 0 is none. +- **Line Width** - The width of the line for a series. +- **Null point mode** - How null values are displayed +- **Staircase line** - Draws adjacent points as staircase ### Multiple Series If there are multiple series, they can be displayed as a group. -- ``Stack`` - Each series is stacked on top of another -- ``Percent`` - Each series is drawn as a percentage of the total of all series +- **Stack** - Each series is stacked on top of another +- **Percent** - Each series is drawn as a percentage of the total of all series If you have stack enabled, you can select what the mouse hover feature should show. @@ -140,12 +142,12 @@ If you have stack enabled, you can select what the mouse hover feature should sh ### Rendering -- ``Flot`` - Render the graphs in the browser using Flot (default) -- ``Graphite PNG`` - Render the graph on the server using graphite's render API. +- **Flot** - Render the graphs in the browser using Flot (default) +- **Graphite PNG** - Render the graph on the server using graphite's render API. ### Tooltip -- ``All series`` - Show all series on the same tooltip and a x crosshairs to help follow all series +- **All series** - Show all series on the same tooltip and a x crosshairs to help follow all series ### Series Specific Overrides @@ -158,4 +160,6 @@ There is an option under Series overrides to draw lines as dashes. Set Dashes to ## Time Range - +The time range tab allows you to override the dashboard time range and specify a panel specific time. Either through a relative from now time option or through a timeshift. + +{{< docs-imagebox img="/img/docs/v45/graph-time-range.png" max-width= "900px" >}} diff --git a/docs/sources/features/panels/singlestat.md b/docs/sources/features/panels/singlestat.md index ffdd415b030..5e2cb36600b 100644 --- a/docs/sources/features/panels/singlestat.md +++ b/docs/sources/features/panels/singlestat.md @@ -12,30 +12,30 @@ weight = 2 # Singlestat Panel -{{< docs-imagebox img="/img/docs/v45/singlestat-panel.png" class="docs-image--no-shadow">}} +{{< docs-imagebox img="/img/docs/v45/singlestat-panel.png" class="docs-image--no-shadow" max-width="900px" >}} The Singlestat Panel allows you to show the one main summary stat of a SINGLE series. It reduces the series into a single number (by looking at the max, min, average, or sum of values in the series). Singlestat also provides thresholds to color the stat or the Panel background. It can also translate the single number into a text value, and show a sparkline summary of the series. ### Singlestat Panel Configuration -The singlestat panel has a normal query editor to allow you define your exact metric queries like many other Panels. Through the Options tab, you can access the Singlestat-specific functionality. +The singlestat panel has a normal query editor to allow you define your exact metric queries like many other Panels. In the Options tab, you can access the Singlestat-specific functionality. - +{{< docs-imagebox img="/img/docs/v45/singlestat-value-options.png" class="docs-image--no-shadow" max-width="900px" >}} -1. `Stats`: The Stats field let you set the function (min, max, average, current, total, first, delta, range) that your entire query is reduced into a single value with. This reduces the entire query into a single summary value that is displayed. - * `min` - The smallest value in the series - * `max` - The largest value in the series - * `avg` - The average of all the non-null values in the series - * `current` - The last value in the series. If the series ends on null the previous value will be used. - * `total` - The sum of all the non-null values in the series - * `first` - The first value in the series - * `delta` - The total incremental increase (of a counter) in the series. An attempt is made to account for counter resets, but this will only be accurate for single instance metrics. Used to show total counter increase in time series. - * `diff` - The difference betwen 'current' (last value) and 'first'. - * `range` - The difference between 'min' and 'max'. Useful the show the range of change for a gauge. -2. `Prefix/Postfix`: The Prefix/Postfix fields let you define a custom label to appear *before/after* the value. The `$__name` variable can be used here to use the series name or alias from the metric query. -3. `Units`: Units are appended to the the Singlestat within the panel, and will respect the color and threshold settings for the value. -4. `Decimals`: The Decimal field allows you to override the automatic decimal precision, and set it explicitly. -5. `Font Size`: You can use this section to select the font size of the different texts in the Singlestat Panel, i.e. prefix, value and postfix. +1. **Stats**: The Stats field let you set the function (min, max, average, current, total, first, delta, range) that your entire query is reduced into a single value with. This reduces the entire query into a single summary value that is displayed. + * **min** - The smallest value in the series + * **max** - The largest value in the series + * **avg** - The average of all the non-null values in the series + * **current** - The last value in the series. If the series ends on null the previous value will be used. + * **total** - The sum of all the non-null values in the series + * **first** - The first value in the series + * **delta** - The total incremental increase (of a counter) in the series. An attempt is made to account for counter resets, but this will only be accurate for single instance metrics. Used to show total counter increase in time series. + * **diff** - The difference betwen 'current' (last value) and 'first'. + * **range** - The difference between 'min' and 'max'. Useful the show the range of change for a gauge. +2. **Prefix/Postfix**: The Prefix/Postfix fields let you define a custom label to appear *before/after* the value. The `$__name` variable can be used here to use the series name or alias from the metric query. +3. **Units**: Units are appended to the the Singlestat within the panel, and will respect the color and threshold settings for the value. +4. **Decimals**: The Decimal field allows you to override the automatic decimal precision, and set it explicitly. +5. **Font Size**: You can use this section to select the font size of the different texts in the Singlestat Panel, i.e. prefix, value and postfix. ### Coloring @@ -43,11 +43,11 @@ The coloring options of the Singlestat Panel config allow you to dynamically cha {{< docs-imagebox img="/img/docs/v45/singlestat-color-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}} -1. `Background`: This checkbox applies the configured thresholds and colors to the entirety of the Singlestat Panel background. -2. `Thresholds`: Change the background and value colors dynamically within the panel, depending on the Singlestat value. The threshold field accepts **2 comma-separated** values which represent 3 ranges that correspond to the three colors directly to the right. For example: if the thresholds are 70, 90 then the first color represents < 70, the second color represents between 70 and 90 and the third color represents > 90. -3. `Colors`: Select a color and opacity -4. `Value`: This checkbox applies the configured thresholds and colors to the summary stat. -5. `Invert order`: This link toggles the threshold color order.
For example: Green, Orange, Red () will become Red, Orange, Green (). +1. **Background**: This checkbox applies the configured thresholds and colors to the entirety of the Singlestat Panel background. +2. **Thresholds**: Change the background and value colors dynamically within the panel, depending on the Singlestat value. The threshold field accepts **2 comma-separated** values which represent 3 ranges that correspond to the three colors directly to the right. For example: if the thresholds are 70, 90 then the first color represents < 70, the second color represents between 70 and 90 and the third color represents > 90. +3. **Colors**: Select a color and opacity +4. **Value**: This checkbox applies the configured thresholds and colors to the summary stat. +5. **Invert order**: This link toggles the threshold color order.
For example: Green, Orange, Red () will become Red, Orange, Green (). ### Spark Lines @@ -55,10 +55,12 @@ Sparklines are a great way of seeing the historical data related to the summary {{< docs-imagebox img="/img/docs/v45/singlestat-spark-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}} -1. `Show`: The show checkbox will toggle whether the spark line is shown in the Panel. When unselected, only the Singlestat value will appear. -2. `Full Height`: Check if you want the sparklines to take up the full panel height, or uncheck if they should be below the main Singlestat value. -3. `Line Color`: This color selection applies to the color of the sparkline itself. -4. `Fill Color`: This color selection applies to the area below the sparkline. +1. **Show**: The show checkbox will toggle whether the spark line is shown in the Panel. When unselected, only the Singlestat value will appear. +2. **Full Height**: Check if you want the sparklines to take up the full panel height, or uncheck if they should be below the main Singlestat value. +3. **Line Color**: This color selection applies to the color of the sparkline itself. +4. **Fill Color**: This color selection applies to the area below the sparkline. + +
> ***Pro-tip:*** Reduce the opacity on fill colors for nice looking panels. @@ -68,10 +70,12 @@ Gauges gives a clear picture of how high a value is in it's context. It's a grea {{< docs-imagebox img="/img/docs/v45/singlestat-gauge-options.png" max-width="500px" class="docs-image--right docs-image--no-shadow">}} -1. `Show`: The show checkbox will toggle wether the gauge is shown in the panel. When unselected, only the Singlestat value will appear. -2. `Min/Max`: This sets the start and end point for the gauge. -3. `Threshold Labels`: Check if you want to show the threshold labels. Thresholds are set in the color options. -4. `Threshold Markers`: Check if you want to have a second meter showing the thresholds. +1. **Show**: The show checkbox will toggle wether the gauge is shown in the panel. When unselected, only the Singlestat value will appear. +2. **Min/Max**: This sets the start and end point for the gauge. +3. **Threshold Labels**: Check if you want to show the threshold labels. Thresholds are set in the color options. +4. **Threshold Markers**: Check if you want to have a second meter showing the thresholds. + +
### Value to text mapping @@ -79,6 +83,8 @@ Gauges gives a clear picture of how high a value is in it's context. It's a grea Value to text mapping allows you to translate the value of the summary stat into explicit text. The text will respect all styling, thresholds and customization defined for the value. This can be useful to translate the number of the main Singlestat value into a context-specific human-readable word or message. +
+ ## Troubleshooting ### Multiple Series Error diff --git a/docs/sources/features/panels/table_panel.md b/docs/sources/features/panels/table_panel.md index 9c112c81265..a3e56c72960 100644 --- a/docs/sources/features/panels/table_panel.md +++ b/docs/sources/features/panels/table_panel.md @@ -25,8 +25,8 @@ The table panel has many ways to manipulate your data for optimal presentation. {{< docs-imagebox img="/img/docs/v45/table_options.png" class="docs-image--no-shadow" max-width= "500px" >}} -1. `Data`: Control how your query is transformed into a table. -2. `Paging`: Table display options. +1. **Data**: Control how your query is transformed into a table. +2. **Paging**: Table display options. ## Data to Table @@ -43,20 +43,20 @@ you want in the table. Only applicable for some transforms. {{< docs-imagebox img="/img/docs/v45/table_ts_to_rows.png" >}} -In the most simple mode you can turn time series to rows. This means you get a `Time`, `Metric` and a `Value` column. Where `Metric` is the name of the time series. +In the most simple mode you can turn time series to rows. This means you get a **Time**, **Metric** and a **Value** column. Where **Metric** is the name of the time series. ### Time series to columns {{< docs-imagebox img="/img/docs/v45/table_ts_to_columns.png" >}} -This transform allows you to take multiple time series and group them by time. Which will result in the primary column being `Time` and a column for each time series. +This transform allows you to take multiple time series and group them by time. Which will result in the primary column being **Time** and a column for each time series. ### Time series aggregations {{< docs-imagebox img="/img/docs/v45/table_ts_to_aggregations.png" >}} -This table transformation will lay out your table into rows by metric, allowing columns of `Avg`, `Min`, `Max`, `Total`, `Current` and `Count`. More than one column can be added. +This table transformation will lay out your table into rows by metric, allowing columns of **Avg**, **Min**, **Max**, **Total**, **Current** and **Count**. More than one column can be added. ### Annotations @@ -70,7 +70,7 @@ mode then any queries you have in the metrics tab will be ignored. {{< docs-imagebox img="/img/docs/v45/table_json_data.png" max-width="500px" >}} -If you have an Elasticsearch **Raw Document** query or an Elasticsearch query without a `date histogram` use this +If you have an Elasticsearch **Raw Document** query or an Elasticsearch query without a **date histogram** use this transform mode and pick the columns using the **Columns** section. @@ -80,9 +80,9 @@ transform mode and pick the columns using the **Columns** section. {{< docs-imagebox img="/img/docs/v45/table_paging.png" class="docs-image--no-shadow docs-image--right" max-width="350px" >}} -1. `Pagination (Page Size)`: The table display fields allow you to control The `Pagination` (page size) is the threshold at which the table rows will be broken into pages. For example, if your table had 95 records with a pagination value of 10, your table would be split across 9 pages. -2. `Scroll`: The `scroll bar` checkbox toggles the ability to scroll within the panel, when unchecked, the panel height will grow to display all rows. -3. `Font Size`: The `font size` field allows you to increase or decrease the size for the panel, relative to the default font size. +1. **Rows Per Page**: The table display fields allow you to control how many rows per page there should be. For example, if your table had 95 records with a rows per page value of 10, your table would be split across 10 pages. +2. **Scroll**: The scroll bar checkbox toggles the ability to scroll within the panel, when unchecked, the panel height will grow to display all rows. +3. **Font Size**: The font size field allows you to increase or decrease the size for the panel, relative to the default font size. ## Column Styles @@ -91,9 +91,9 @@ The column styles allow you control how dates and numbers are formatted. {{< docs-imagebox img="/img/docs/v45/table_column_styles.png" class="docs-image--no-shadow" >}} -1. `Name or regex`: The Name or Regex field controls what columns the rule should be applied to. The regex or name filter will be matched against the column name not against column values. -2. `Column Header`: Title for the column, when using a Regex the title can include replacement strings like `$1`. -3. `Add column style rule`: Add new column rule. -4. `Thresholds` and `Coloring`: Specify color mode and thresholds limits. -5. `Type`: The three supported types of types are `Number`, `String` and `Date`. `Unit` and `Decimals`: Specify unit and decimal precision for numbers.`Format`: Specify date format for dates. +1. **Name or regex**: The Name or Regex field controls what columns the rule should be applied to. The regex or name filter will be matched against the column name not against column values. +2. **Column Header**: Title for the column, when using a Regex the title can include replacement strings like `$1`. +3. **Add column style rule**: Add new column rule. +4. **Thresholds and Coloring**: Specify color mode and thresholds limits. +5. **Type**: The three supported types of types are **Number**, **String** and **Date**. **Unit** and **Decimals**: Specify unit and decimal precision for numbers. **Format**: Specify date format for dates. diff --git a/docs/sources/features/panels/text.md b/docs/sources/features/panels/text.md new file mode 100644 index 00000000000..cc8269793bd --- /dev/null +++ b/docs/sources/features/panels/text.md @@ -0,0 +1,23 @@ ++++ +title = "Text" +keywords = ["grafana", "text", "documentation", "panel"] +type = "docs" +aliases = ["/reference/alertlist/"] +[menu.docs] +name = "Text" +parent = "panels" +weight = 4 ++++ + + +# Text Panel + +The text panel lets you make information and description panels etc. for your dashboards. There are three modes you can write in: markdown, HTML or text. + +## Text Options + +{{< docs-imagebox img="/img/docs/v45/text-options.png" max-width="600px" class="docs-image--no-shadow">}} + +1. **Mode**: Here you can choose between markdown, HTML or text. +2. **Content**: Here you write your content. + diff --git a/docs/sources/guides/whats-new-in-v4-6.md b/docs/sources/guides/whats-new-in-v4-6.md new file mode 100644 index 00000000000..3bc80799afc --- /dev/null +++ b/docs/sources/guides/whats-new-in-v4-6.md @@ -0,0 +1,74 @@ ++++ +title = "What's New in Grafana v4.6" +description = "Feature & improvement highlights for Grafana v4.6" +keywords = ["grafana", "new", "documentation", "4.6"] +type = "docs" +[menu.docs] +name = "Version 4.6" +identifier = "v4.6" +parent = "whatsnew" +weight = -5 ++++ + +# What's New in Grafana v4.6 + +Grafana v4.6 brings many enhancements to Annotations, Cloudwatch & Prometheus. It also adds support for Postgres as metric & table data source! + +### Annotations + +{{< docs-imagebox img="/img/docs/v46/add_annotation_region.png" max-width= "800px" >}} + +You can now add annotation events and regions right from the graph panel! Just hold CTRL/CMD + click or drag region to open the **Add Annotation** view. The +[Annotations]({{< relref "reference/annotations.md" >}}) documentation is updated to include details on this new exciting feature. + +### Cloudwatch + +Cloudwatch now supports alerting. Setup alert rules for any Cloudwatch metric! + +{{< docs-imagebox img="/img/docs/v46/cloudwatch_alerting.png" max-width= "800px" >}} + +### Postgres + +Grafana v4.6 now ships with a built-in datasource plugin for Postgres. Have logs or metric data in Postgres? You can now visualize that data and +define alert rules on it like any of our other data sources. + +{{< docs-imagebox img="/img/docs/v46/postgres_table_query.png" max-width= "800px" >}} + +### Prometheus + +New enhancements include support for **instant queries** and improvements to query editor in the form of autocomplete for label names and label values. +This makes exploring and filtering Prometheus data much easier. + +## Changelog + +### New Features + +* **GCS**: Adds support for Google Cloud Storage [#8370](https://github.com/grafana/grafana/issues/8370) thx [@chuhlomin](https://github.com/chuhlomin) +* **Prometheus**: Adds /metrics endpoint for exposing Grafana metrics. [#9187](https://github.com/grafana/grafana/pull/9187) +* **Graph**: Add support for local formating in axis. [#1395](https://github.com/grafana/grafana/issues/1395), thx [@m0nhawk](https://github.com/m0nhawk) +* **Jaeger**: Add support for open tracing using jaeger in Grafana. [#9213](https://github.com/grafana/grafana/pull/9213) +* **Unit types**: New date & time unit types added, useful in singlestat to show dates & times. [#3678](https://github.com/grafana/grafana/issues/3678), [#6710](https://github.com/grafana/grafana/issues/6710), [#2764](https://github.com/grafana/grafana/issues/2764) +* **CLI**: Make it possible to install plugins from any url [#5873](https://github.com/grafana/grafana/issues/5873) +* **Prometheus**: Add support for instant queries [#5765](https://github.com/grafana/grafana/issues/5765), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: Add support for alerting using the cloudwatch datasource [#8050](https://github.com/grafana/grafana/pull/8050), thx [@mtanda](https://github.com/mtanda) +* **Pagerduty**: Include triggering series in pagerduty notification [#8479](https://github.com/grafana/grafana/issues/8479), thx [@rickymoorhouse](https://github.com/rickymoorhouse) +* **Timezone**: Time ranges like Today & Yesterday now work correctly when timezone setting is set to UTC [#8916](https://github.com/grafana/grafana/issues/8916), thx [@ctide](https://github.com/ctide) +* **Prometheus**: Align $__interval with the step parameters. [#9226](https://github.com/grafana/grafana/pull/9226), thx [@alin-amana](https://github.com/alin-amana) +* **Prometheus**: Autocomplete for label name and label value [#9208](https://github.com/grafana/grafana/pull/9208), thx [@mtanda](https://github.com/mtanda) +* **Postgres**: New Postgres data source [#9209](https://github.com/grafana/grafana/pull/9209), thx [@svenklemm](https://github.com/svenklemm) +* **Datasources**: closes [#9371](https://github.com/grafana/grafana/issues/9371), [#5334](https://github.com/grafana/grafana/issues/5334), [#8812](https://github.com/grafana/grafana/issues/8812), thx [@mattbostock](https://github.com/mattbostock) + +### Minor Changes + +* **SMTP**: Make it possible to set specific EHLO for smtp client. [#9319](https://github.com/grafana/grafana/issues/9319) +* **Dataproxy**: Allow grafan to renegotiate tls connection [#9250](https://github.com/grafana/grafana/issues/9250) +* **HTTP**: set net.Dialer.DualStack to true for all http clients [#9367](https://github.com/grafana/grafana/pull/9367) +* **Alerting**: Add diff and percent diff as series reducers [#9386](https://github.com/grafana/grafana/pull/9386), thx [@shanhuhai5739](https://github.com/shanhuhai5739) +* **Slack**: Allow images to be uploaded to slack when Token is precent [#7175](https://github.com/grafana/grafana/issues/7175), thx [@xginn8](https://github.com/xginn8) +* **Opsgenie**: Use their latest API instead of old version [#9399](https://github.com/grafana/grafana/pull/9399), thx [@cglrkn](https://github.com/cglrkn) +* **Table**: Add support for displaying the timestamp with milliseconds [#9429](https://github.com/grafana/grafana/pull/9429), thx [@s1061123](https://github.com/s1061123) +* **Hipchat**: Add metrics, message and image to hipchat notifications [#9110](https://github.com/grafana/grafana/issues/9110), thx [@eloo](https://github.com/eloo) + +### Tech +* **Go**: Grafana is now built using golang 1.9 + diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index d7e5d3f8b7c..3ef5fb1136a 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -23,157 +23,162 @@ Only works with Basic Authentication (username and password). See [introduction] **Example Request**: - GET /api/admin/settings - Accept: application/json - Content-Type: application/json +```bash +GET /api/admin/settings +Accept: application/json +Content-Type: application/json +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - { - "DEFAULT": - { - "app_mode":"production"}, - "analytics": - { - "google_analytics_ua_id":"", - "reporting_enabled":"false" - }, - "auth.anonymous":{ - "enabled":"true", - "org_name":"Main Org.", - "org_role":"Viewer" - }, - "auth.basic":{ - "enabled":"false" - }, - "auth.github":{ - "allow_sign_up":"false", - "allowed_domains":"", - "allowed_organizations":"", - "api_url":"https://api.github.com/user", - "auth_url":"https://github.com/login/oauth/authorize", - "client_id":"some_id", - "client_secret":"************", - "enabled":"false", - "scopes":"user:email", - "team_ids":"", - "token_url":"https://github.com/login/oauth/access_token" - }, - "auth.google":{ - "allow_sign_up":"false","allowed_domains":"", - "api_url":"https://www.googleapis.com/oauth2/v1/userinfo", - "auth_url":"https://accounts.google.com/o/oauth2/auth", - "client_id":"some_client_id", - "client_secret":"************", - "enabled":"false", - "scopes":"https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", - "token_url":"https://accounts.google.com/o/oauth2/token" - }, - "auth.ldap":{ - "config_file":"/etc/grafana/ldap.toml", - "enabled":"false" - }, - "auth.proxy":{ - "auto_sign_up":"true", - "enabled":"false", - "header_name":"X-WEBAUTH-USER", - "header_property":"username" - }, - "dashboards.json":{ - "enabled":"false", - "path":"/var/lib/grafana/dashboards" - }, - "database":{ - "host":"127.0.0.1:0000", - "name":"grafana", - "password":"************", - "path":"grafana.db", - "ssl_mode":"disable", - "type":"sqlite3", - "user":"root" - }, - "emails":{ - "templates_pattern":"emails/*.html", - "welcome_email_on_sign_up":"false" - }, - "event_publisher":{ - "enabled":"false", - "exchange":"grafana_events", - "rabbitmq_url":"amqp://localhost/" - }, - "log":{ - "buffer_len":"10000", - "level":"Info", - "mode":"file" - }, - "log.console":{ - "level":"" - }, - "log.file":{ - "daily_rotate":"true", - "file_name":"", - "level":"", - "log_rotate":"true", - "max_days":"7", - "max_lines":"1000000", - "max_lines_shift":"28", - "max_size_shift":"" - }, - "paths":{ - "data":"/tsdb/grafana", - "logs":"/logs/apps/grafana"}, - "security":{ - "admin_password":"************", - "admin_user":"admin", - "cookie_remember_name":"grafana_remember", - "cookie_username":"grafana_user", - "disable_gravatar":"false", - "login_remember_days":"7", - "secret_key":"************" - }, - "server":{ - "cert_file":"", - "cert_key":"", - "domain":"mygraf.com", - "enable_gzip":"false", - "enforce_domain":"false", - "http_addr":"127.0.0.1", - "http_port":"0000", - "protocol":"http", - "root_url":"%(protocol)s://%(domain)s:%(http_port)s/", - "router_logging":"true", - "data_proxy_logging":"true", - "static_root_path":"public" - }, - "session":{ - "cookie_name":"grafana_sess", - "cookie_secure":"false", - "gc_interval_time":"", - "provider":"file", - "provider_config":"sessions", - "session_life_time":"86400" - }, - "smtp":{ - "cert_file":"", - "enabled":"false", - "from_address":"admin@grafana.localhost", - "from_name":"Grafana", - "host":"localhost:25", - "key_file":"", - "password":"************", - "skip_verify":"false", - "user":""}, - "users":{ - "allow_org_create":"true", - "allow_sign_up":"false", - "auto_assign_org":"true", - "auto_assign_org_role":"Viewer" - } - } +```bash +HTTP/1.1 200 +Content-Type: application/json +{ +"DEFAULT": +{ + "app_mode":"production"}, + "analytics": + { + "google_analytics_ua_id":"", + "reporting_enabled":"false" + }, + "auth.anonymous":{ + "enabled":"true", + "org_name":"Main Org.", + "org_role":"Viewer" + }, + "auth.basic":{ + "enabled":"false" + }, + "auth.github":{ + "allow_sign_up":"false", + "allowed_domains":"", + "allowed_organizations":"", + "api_url":"https://api.github.com/user", + "auth_url":"https://github.com/login/oauth/authorize", + "client_id":"some_id", + "client_secret":"************", + "enabled":"false", + "scopes":"user:email", + "team_ids":"", + "token_url":"https://github.com/login/oauth/access_token" + }, + "auth.google":{ + "allow_sign_up":"false","allowed_domains":"", + "api_url":"https://www.googleapis.com/oauth2/v1/userinfo", + "auth_url":"https://accounts.google.com/o/oauth2/auth", + "client_id":"some_client_id", + "client_secret":"************", + "enabled":"false", + "scopes":"https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", + "token_url":"https://accounts.google.com/o/oauth2/token" + }, + "auth.ldap":{ + "config_file":"/etc/grafana/ldap.toml", + "enabled":"false" + }, + "auth.proxy":{ + "auto_sign_up":"true", + "enabled":"false", + "header_name":"X-WEBAUTH-USER", + "header_property":"username" + }, + "dashboards.json":{ + "enabled":"false", + "path":"/var/lib/grafana/dashboards" + }, + "database":{ + "host":"127.0.0.1:0000", + "name":"grafana", + "password":"************", + "path":"grafana.db", + "ssl_mode":"disable", + "type":"sqlite3", + "user":"root" + }, + "emails":{ + "templates_pattern":"emails/*.html", + "welcome_email_on_sign_up":"false" + }, + "event_publisher":{ + "enabled":"false", + "exchange":"grafana_events", + "rabbitmq_url":"amqp://localhost/" + }, + "log":{ + "buffer_len":"10000", + "level":"Info", + "mode":"file" + }, + "log.console":{ + "level":"" + }, + "log.file":{ + "daily_rotate":"true", + "file_name":"", + "level":"", + "log_rotate":"true", + "max_days":"7", + "max_lines":"1000000", + "max_lines_shift":"28", + "max_size_shift":"" + }, + "paths":{ + "data":"/tsdb/grafana", + "logs":"/logs/apps/grafana"}, + "security":{ + "admin_password":"************", + "admin_user":"admin", + "cookie_remember_name":"grafana_remember", + "cookie_username":"grafana_user", + "disable_gravatar":"false", + "login_remember_days":"7", + "secret_key":"************" + }, + "server":{ + "cert_file":"", + "cert_key":"", + "domain":"mygraf.com", + "enable_gzip":"false", + "enforce_domain":"false", + "http_addr":"127.0.0.1", + "http_port":"0000", + "protocol":"http", + "root_url":"%(protocol)s://%(domain)s:%(http_port)s/", + "router_logging":"true", + "data_proxy_logging":"true", + "static_root_path":"public" + }, + "session":{ + "cookie_name":"grafana_sess", + "cookie_secure":"false", + "gc_interval_time":"", + "provider":"file", + "provider_config":"sessions", + "session_life_time":"86400" + }, + "smtp":{ + "cert_file":"", + "enabled":"false", + "from_address":"admin@grafana.localhost", + "from_name":"Grafana", + "ehlo_identity":"dashboard.example.com", + "host":"localhost:25", + "key_file":"", + "password":"************", + "skip_verify":"false", + "user":"" + }, + "users":{ + "allow_org_create":"true", + "allow_sign_up":"false", + "auto_assign_org":"true", + "auto_assign_org_role":"Viewer" + } +} +``` ## Grafana Stats `GET /api/admin/stats` @@ -182,26 +187,30 @@ Only works with Basic Authentication (username and password). See [introduction] **Example Request**: - GET /api/admin/stats - Accept: application/json - Content-Type: application/json +```bash +GET /api/admin/stats +Accept: application/json +Content-Type: application/json +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```json +HTTP/1.1 200 +Content-Type: application/json - { - "user_count":2, - "org_count":1, - "dashboard_count":4, - "db_snapshot_count":2, - "db_tag_count":6, - "data_source_count":1, - "playlist_count":1, - "starred_db_count":2, - "grafana_admin_count":2 - } +{ + "user_count":2, + "org_count":1, + "dashboard_count":4, + "db_snapshot_count":2, + "db_tag_count":6, + "data_source_count":1, + "playlist_count":1, + "starred_db_count":2, + "grafana_admin_count":2 +} +``` ## Global Users @@ -210,24 +219,28 @@ Only works with Basic Authentication (username and password). See [introduction] Create new user. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. **Example Request**: +```json - POST /api/admin/users HTTP/1.1 - Accept: application/json - Content-Type: application/json +POST /api/admin/users HTTP/1.1 +Accept: application/json +Content-Type: application/json - { - "name":"User", - "email":"user@graf.com", - "login":"user", - "password":"userpassword" - } +{ + "name":"User", + "email":"user@graf.com", + "login":"user", + "password":"userpassword" +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```json +HTTP/1.1 200 +Content-Type: application/json - {"id":5,"message":"User created"} +{"id":5,"message":"User created"} +``` ## Password for User @@ -238,18 +251,22 @@ Change password for a specific user. **Example Request**: - PUT /api/admin/users/2/password HTTP/1.1 - Accept: application/json - Content-Type: application/json +```json +PUT /api/admin/users/2/password HTTP/1.1 +Accept: application/json +Content-Type: application/json - {"password":"userpassword"} +{"password":"userpassword"} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```json +HTTP/1.1 200 +Content-Type: application/json - {"message": "User password updated"} +{"message": "User password updated"} +``` ## Permissions @@ -259,18 +276,22 @@ Only works with Basic Authentication (username and password). See [introduction] **Example Request**: - PUT /api/admin/users/2/permissions HTTP/1.1 - Accept: application/json - Content-Type: application/json +```json +PUT /api/admin/users/2/permissions HTTP/1.1 +Accept: application/json +Content-Type: application/json - {"isGrafanaAdmin": true} +{"isGrafanaAdmin": true} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```json +HTTP/1.1 200 +Content-Type: application/json - {message: "User permissions updated"} +{message: "User permissions updated"} +``` ## Delete global User @@ -280,16 +301,20 @@ Only works with Basic Authentication (username and password). See [introduction] **Example Request**: - DELETE /api/admin/users/2 HTTP/1.1 - Accept: application/json - Content-Type: application/json +```json +DELETE /api/admin/users/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```json +HTTP/1.1 200 +Content-Type: application/json - {message: "User deleted"} +{message: "User deleted"} +``` ## Pause all alerts @@ -299,13 +324,15 @@ Only works with Basic Authentication (username and password). See [introduction] **Example Request**: - POST /api/admin/pause-all-alerts HTTP/1.1 - Accept: application/json - Content-Type: application/json +```json +POST /api/admin/pause-all-alerts HTTP/1.1 +Accept: application/json +Content-Type: application/json - { - "paused": true - } +{ + "paused": true +} +``` JSON Body schema: @@ -313,7 +340,9 @@ JSON Body schema: **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```json +HTTP/1.1 200 +Content-Type: application/json - {state: "new state", message: "alerts pause/un paused", "alertsAffected": 100} +{state: "new state", message: "alerts pause/un paused", "alertsAffected": 100} +``` \ No newline at end of file diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md index 1aab7253373..c5172c64203 100644 --- a/docs/sources/http_api/alerting.md +++ b/docs/sources/http_api/alerting.md @@ -23,11 +23,12 @@ This API can also be used to create, update and delete alert notifications. **Example Request**: - GET /api/alerts HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - +```http +GET /api/alerts HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` Querystring Parameters: These parameters are used as querystring parameters. For example: @@ -41,28 +42,30 @@ This API can also be used to create, update and delete alert notifications. **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - [ +```http +HTTP/1.1 200 +Content-Type: application/json +[ + { + "id": 1, + "dashboardId": 1, + "panelId": 1, + "name": "fire place sensor", + "message": "Someone is trying to break in through the fire place", + "state": "alerting", + "evalDate": "0001-01-01T00:00:00Z", + "evalData": [ { - "id": 1, - "dashboardId": 1, - "panelId": 1, - "name": "fire place sensor", - "message": "Someone is trying to break in through the fire place", - "state": "alerting", - "evalDate": "0001-01-01T00:00:00Z", - "evalData": [ - { - "metric": "fire", - "tags": null, - "value": 5.349999999999999 - } - "newStateDate": "2016-12-25", - "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" + "metric": "fire", + "tags": null, + "value": 5.349999999999999 } - ] + "newStateDate": "2016-12-25", + "executionError": "", + "dashboardUri": "http://grafana.com/dashboard/db/sensors" + } +] +``` ## Get one alert @@ -70,26 +73,30 @@ This API can also be used to create, update and delete alert notifications. **Example Request**: - GET /api/alerts/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/alerts/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - { - "id": 1, - "dashboardId": 1, - "panelId": 1, - "name": "fire place sensor", - "message": "Someone is trying to break in through the fire place", - "state": "alerting", - "newStateDate": "2016-12-25", - "executionError": "", - "dashboardUri": "http://grafana.com/dashboard/db/sensors" - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "id": 1, + "dashboardId": 1, + "panelId": 1, + "name": "fire place sensor", + "message": "Someone is trying to break in through the fire place", + "state": "alerting", + "newStateDate": "2016-12-25", + "executionError": "", + "dashboardUri": "http://grafana.com/dashboard/db/sensors" +} +``` ## Pause alert @@ -97,14 +104,16 @@ This API can also be used to create, update and delete alert notifications. **Example Request**: - POST /api/alerts/1/pause HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +POST /api/alerts/1/pause HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "paused": true - } +{ + "paused": true +} +``` The :id query parameter is the id of the alert to be paused or unpaused. @@ -114,13 +123,15 @@ JSON Body Schema: **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - { - "alertId": 1, - "state": "Paused", - "message": "alert paused" - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "alertId": 1, + "state": "Paused", + "message": "alert paused" +} +``` ## Get alert notifications @@ -128,26 +139,29 @@ JSON Body Schema: **Example Request**: - GET /api/alert-notifications HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - +```http +GET /api/alert-notifications HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "id": 1, - "name": "Team A", - "type": "email", - "isDefault": true, - "created": "2017-01-01 12:45", - "updated": "2017-01-01 12:45" - } +{ + "id": 1, + "name": "Team A", + "type": "email", + "isDefault": true, + "created": "2017-01-01 12:45", + "updated": "2017-01-01 12:45" +} +``` ## Create alert notification @@ -155,34 +169,37 @@ JSON Body Schema: **Example Request**: - POST /api/alert-notifications HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "name": "new alert notification", //Required - "type": "email", //Required - "isDefault": false, - "settings": { - "addresses": "carl@grafana.com;dev@grafana.com" - } - } +```http +POST /api/alert-notifications HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "name": "new alert notification", //Required + "type": "email", //Required + "isDefault": false, + "settings": { + "addresses": "carl@grafana.com;dev@grafana.com" + } +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - { - "id": 1, - "name": "new alert notification", - "type": "email", - "isDefault": false, - "settings": { addresses: "carl@grafana.com;dev@grafana.com"} } - "created": "2017-01-01 12:34", - "updated": "2017-01-01 12:34" - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "id": 1, + "name": "new alert notification", + "type": "email", + "isDefault": false, + "settings": { addresses: "carl@grafana.com;dev@grafana.com"} } + "created": "2017-01-01 12:34", + "updated": "2017-01-01 12:34" +} +``` ## Update alert notification @@ -190,35 +207,38 @@ JSON Body Schema: **Example Request**: - PUT /api/alert-notifications/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "id": 1, - "name": "new alert notification", //Required - "type": "email", //Required - "isDefault": false, - "settings": { - "addresses: "carl@grafana.com;dev@grafana.com" - } - } +```http +PUT /api/alert-notifications/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "id": 1, + "name": "new alert notification", //Required + "type": "email", //Required + "isDefault": false, + "settings": { + "addresses: "carl@grafana.com;dev@grafana.com" + } +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - { - "id": 1, - "name": "new alert notification", - "type": "email", - "isDefault": false, - "settings": { addresses: "carl@grafana.com;dev@grafana.com"} } - "created": "2017-01-01 12:34", - "updated": "2017-01-01 12:34" - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "id": 1, + "name": "new alert notification", + "type": "email", + "isDefault": false, + "settings": { addresses: "carl@grafana.com;dev@grafana.com"} } + "created": "2017-01-01 12:34", + "updated": "2017-01-01 12:34" +} +``` ## Delete alert notification @@ -226,15 +246,19 @@ JSON Body Schema: **Example Request**: - DELETE /api/alert-notifications/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +DELETE /api/alert-notifications/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - { - "message": "Notification deleted" - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "message": "Notification deleted" +} +``` \ No newline at end of file diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md new file mode 100644 index 00000000000..7aab127cb0c --- /dev/null +++ b/docs/sources/http_api/annotations.md @@ -0,0 +1,220 @@ ++++ +title = "Annotations HTTP API " +description = "Grafana Annotations HTTP API" +keywords = ["grafana", "http", "documentation", "api", "annotation", "annotations", "comment"] +aliases = ["/http_api/annotations/"] +type = "docs" +[menu.docs] +name = "Annotations" +identifier = "annotationshttp" +parent = "http_api" ++++ + +# Annotations resources / actions + +This is the API documentation for the new Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel. + +## Find Annotations + +`GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100` + +**Example Request**: + +```http +GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + + +Query Parameters: + +- `from`: epoch datetime in milliseconds. Optional. +- `to`: epoch datetime in milliseconds. Optional. +- `limit`: number. Optional - default is 10. Max limit for results returned. +- `alertId`: number. Optional. Find annotations for a specified alert. +- `dashboardId`: number. Optional. Find annotations that are scoped to a specific dashboard +- `panelId`: number. Optional. Find annotations that are scoped to a specific panel +- `tags`: string. Optional. Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. `tags=tag1&tags=tag2`. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json +[ + { + "id": 1124, + "alertId": 0, + "dashboardId": 468, + "panelId": 2, + "userId": 1, + "userName": "", + "newState": "", + "prevState": "", + "time": 1507266395000, + "text": "test", + "metric": "", + "regionId": 1123, + "type": "event", + "tags": [ + "tag1", + "tag2" + ], + "data": {} + }, + { + "id": 1123, + "alertId": 0, + "dashboardId": 468, + "panelId": 2, + "userId": 1, + "userName": "", + "newState": "", + "prevState": "", + "time": 1507265111000, + "text": "test", + "metric": "", + "regionId": 1123, + "type": "event", + "tags": [ + "tag1", + "tag2" + ], + "data": {} + } +] +``` + +## Create Annotation + +Creates an annotation in the Grafana database. The `dashboardId` and `panelId` fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. + +`POST /api/annotations` + +**Example Request**: + +```json +POST /api/annotations HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "dashboardId":468, + "panelId":1, + "time":1507037197339, + "isRegion":true, + "timeEnd":1507180805056, + "tags":["tag1","tag2"], + "text":"Annotation Description" +} +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Annotation added"} +``` + +## Create Annotation in Graphite format + +Creates an annotation by using Graphite-compatible event format. The `when` and `data` fields are optional. If `when` is not specified then the current time will be used as annotation's timestamp. The `tags` field can also be in prior to Graphite `0.10.0` +format (string with multiple tags being separated by a space). + +`POST /api/annotations/graphite` + +**Example Request**: + +```json +POST /api/annotations/graphite HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "what": "Event - deploy", + "tags": ["deploy", "production"], + "when": 1467844481, + "data": "deploy of master branch happened at Wed Jul 6 22:34:41 UTC 2016" +} +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Graphite annotation added"} +``` + +## Update Annotation + +`PUT /api/annotations/:id` + +**Example Request**: + +```json +PUT /api/annotations/1141 HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "time":1507037197339, + "isRegion":true, + "timeEnd":1507180805056, + "text":"Annotation Description", + "tags":["tag3","tag4","tag5"] +} +``` + +## Delete Annotation By Id + +`DELETE /api/annotation/:id` + +Deletes the annotation that matches the specified id. + +**Example Request**: + +```http +DELETE /api/annotation/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Annotation deleted"} +``` + +## Delete Annotation By RegionId + +`DELETE /api/annotation/region/:id` + +Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id. + +**Example Request**: + +```http +DELETE /api/annotation/region/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Annotation region deleted"} +``` diff --git a/docs/sources/http_api/auth.md b/docs/sources/http_api/auth.md index d8ded124ac5..b526031fdeb 100644 --- a/docs/sources/http_api/auth.md +++ b/docs/sources/http_api/auth.md @@ -21,7 +21,7 @@ If basic auth is enabled (it is enabled by default) you can authenticate your HT standard basic auth. Basic auth will also authenticate LDAP users. curl example: -``` +```bash ?curl http://admin:admin@localhost:3000/api/org {"id":1,"name":"Main Org."} ``` @@ -36,9 +36,11 @@ You use the token in all requests in the `Authorization` header, like this: **Example**: - GET http://your.grafana.com/api/dashboards/db/mydash HTTP/1.1 - Accept: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET http://your.grafana.com/api/dashboards/db/mydash HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` The `Authorization` header value should be `Bearer `. @@ -50,28 +52,32 @@ The `Authorization` header value should be `Bearer `. **Example Request**: - GET /api/auth/keys HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/auth/keys HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "id": 3, - "name": "API", - "role": "Admin" - }, - { - "id": 1, - "name": "TestAdmin", - "role": "Admin" - } - ] +[ + { + "id": 3, + "name": "API", + "role": "Admin" + }, + { + "id": 1, + "name": "TestAdmin", + "role": "Admin" + } +] +``` ## Create API Key @@ -79,15 +85,17 @@ The `Authorization` header value should be `Bearer `. **Example Request**: - POST /api/auth/keys HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +POST /api/auth/keys HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "name": "mykey", - "role": "Admin" - } +{ + "name": "mykey", + "role": "Admin" +} +``` JSON Body schema: @@ -96,10 +104,12 @@ JSON Body schema: **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"name":"mykey","key":"eyJrIjoiWHZiSWd3NzdCYUZnNUtibE9obUpESmE3bzJYNDRIc0UiLCJuIjoibXlrZXkiLCJpZCI6MX1="} +{"name":"mykey","key":"eyJrIjoiWHZiSWd3NzdCYUZnNUtibE9obUpESmE3bzJYNDRIc0UiLCJuIjoibXlrZXkiLCJpZCI6MX1="} +``` ## Delete API Key @@ -107,14 +117,17 @@ JSON Body schema: **Example Request**: - DELETE /api/auth/keys/3 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - +```http +DELETE /api/auth/keys/3 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"API key deleted"} +{"message":"API key deleted"} +``` \ No newline at end of file diff --git a/docs/sources/http_api/dashboard.md b/docs/sources/http_api/dashboard.md index 899c76ce6e0..300e5613db4 100644 --- a/docs/sources/http_api/dashboard.md +++ b/docs/sources/http_api/dashboard.md @@ -158,53 +158,57 @@ Will return the home dashboard. **Example Request**: - GET /api/dashboards/home HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/dashboards/home HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "meta": { + "isHome":true, + "canSave":false, + "canEdit":false, + "canStar":false, + "slug":"", + "expires":"0001-01-01T00:00:00Z", + "created":"0001-01-01T00:00:00Z" + }, + "dashboard": { + "editable":false, + "hideControls":true, + "nav":[ { - "meta": { - "isHome":true, - "canSave":false, - "canEdit":false, - "canStar":false, - "slug":"", - "expires":"0001-01-01T00:00:00Z", - "created":"0001-01-01T00:00:00Z" - }, - "dashboard": { - "editable":false, - "hideControls":true, - "nav":[ - { - "enable":false, - "type":"timepicker" - } - ], - "rows": [ - { - - } - ], - "style":"dark", - "tags":[], - "templating":{ - "list":[ - ] - }, - "time":{ - }, - "timezone":"browser", - "title":"Home", - "version":5 - } + "enable":false, + "type":"timepicker" } + ], + "rows": [ + { + + } + ], + "style":"dark", + "tags":[], + "templating":{ + "list":[ + ] + }, + "time":{ + }, + "timezone":"browser", + "title":"Home", + "version":5 + } +} +``` ## Tags for Dashboard @@ -215,26 +219,30 @@ Get all tags of dashboards **Example Request**: - GET /api/dashboards/tags HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/dashboards/tags HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "term":"tag1", - "count":1 - }, - { - "term":"tag2", - "count":4 - } - ] +[ + { + "term":"tag1", + "count":1 + }, + { + "term":"tag2", + "count":4 + } +] +``` ## Search Dashboards @@ -249,23 +257,27 @@ Query parameters: **Example Request**: - GET /api/search?query=MyDashboard&starred=true&tag=prod HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/search?query=MyDashboard&starred=true&tag=prod HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "id":1, - "title":"Production Overview", - "uri":"db/production-overview", - "type":"dash-db", - "tags":[], - "isStarred":false - } - ] +[ + { + "id":1, + "title":"Production Overview", + "uri":"db/production-overview", + "type":"dash-db", + "tags":[], + "isStarred":false + } +] +``` \ No newline at end of file diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 62b09fb5d2c..364b55b0cfc 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -18,34 +18,38 @@ parent = "http_api" **Example Request**: - GET /api/datasources HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/datasources HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "id":1, - "orgId":1, - "name":"datasource_elastic", - "type":"elasticsearch", - "access":"proxy", - "url":"http://mydatasource.com", - "password":"", - "user":"", - "database":"grafana-dash", - "basicAuth":false, - "basicAuthUser":"", - "basicAuthPassword":"", - "isDefault":false, - "jsonData":null - } - ] +[ + { + "id":1, + "orgId":1, + "name":"datasource_elastic", + "type":"elasticsearch", + "access":"proxy", + "url":"http://mydatasource.com", + "password":"", + "user":"", + "database":"grafana-dash", + "basicAuth":false, + "basicAuthUser":"", + "basicAuthPassword":"", + "isDefault":false, + "jsonData":null + } +] +``` ## Get a single data sources by Id @@ -53,32 +57,36 @@ parent = "http_api" **Example Request**: - GET /api/datasources/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/datasources/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "id":1, - "orgId":1, - "name":"test_datasource", - "type":"graphite", - "access":"proxy", - "url":"http://mydatasource.com", - "password":"", - "user":"", - "database":"", - "basicAuth":false, - "basicAuthUser":"", - "basicAuthPassword":"", - "isDefault":false, - "jsonData":null - } +{ + "id":1, + "orgId":1, + "name":"test_datasource", + "type":"graphite", + "access":"proxy", + "url":"http://mydatasource.com", + "password":"", + "user":"", + "database":"", + "basicAuth":false, + "basicAuthUser":"", + "basicAuthPassword":"", + "isDefault":false, + "jsonData":null +} +``` ## Get a single data source by Name @@ -86,32 +94,36 @@ parent = "http_api" **Example Request**: - GET /api/datasources/name/test_datasource HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/datasources/name/test_datasource HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "id":1, - "orgId":1, - "name":"test_datasource", - "type":"graphite", - "access":"proxy", - "url":"http://mydatasource.com", - "password":"", - "user":"", - "database":"", - "basicAuth":false, - "basicAuthUser":"", - "basicAuthPassword":"", - "isDefault":false, - "jsonData":null - } +{ + "id":1, + "orgId":1, + "name":"test_datasource", + "type":"graphite", + "access":"proxy", + "url":"http://mydatasource.com", + "password":"", + "user":"", + "database":"", + "basicAuth":false, + "basicAuthUser":"", + "basicAuthPassword":"", + "isDefault":false, + "jsonData":null +} +``` ## Get data source Id by Name @@ -119,19 +131,23 @@ parent = "http_api" **Example Request**: - GET /api/datasources/id/test_datasource HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/datasources/id/test_datasource HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "id":1 - } +{ + "id":1 +} +``` ## Create data source @@ -139,48 +155,53 @@ parent = "http_api" **Example Graphite Request**: - POST /api/datasources HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +POST /api/datasources HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "name":"test_datasource", - "type":"graphite", - "url":"http://mydatasource.com", - "access":"proxy", - "basicAuth":false - } +{ + "name":"test_datasource", + "type":"graphite", + "url":"http://mydatasource.com", + "access":"proxy", + "basicAuth":false +} +``` **Example CloudWatch Request**: - ``` - POST /api/datasources HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "name": "test_datasource", - "type": "cloudwatch", - "url": "http://monitoring.us-west-1.amazonaws.com", - "access": "proxy", - "jsonData": { - "authType": "keys", - "defaultRegion": "us-west-1" - }, - "secureJsonData": { - "accessKey": "Ol4pIDpeKSA6XikgOl4p", - "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" - } + +```http +POST /api/datasources HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "name": "test_datasource", + "type": "cloudwatch", + "url": "http://monitoring.us-west-1.amazonaws.com", + "access": "proxy", + "jsonData": { + "authType": "keys", + "defaultRegion": "us-west-1" + }, + "secureJsonData": { + "accessKey": "Ol4pIDpeKSA6XikgOl4p", + "secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" } - ``` +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"id":1,"message":"Datasource added", "name": "test_datasource"} +{"id":1,"message":"Datasource added", "name": "test_datasource"} +``` ## Update an existing data source @@ -188,34 +209,38 @@ parent = "http_api" **Example Request**: - PUT /api/datasources/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +PUT /api/datasources/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "id":1, - "orgId":1, - "name":"test_datasource", - "type":"graphite", - "access":"proxy", - "url":"http://mydatasource.com", - "password":"", - "user":"", - "database":"", - "basicAuth":true, - "basicAuthUser":"basicuser", - "basicAuthPassword":"basicuser", - "isDefault":false, - "jsonData":null - } +{ + "id":1, + "orgId":1, + "name":"test_datasource", + "type":"graphite", + "access":"proxy", + "url":"http://mydatasource.com", + "password":"", + "user":"", + "database":"", + "basicAuth":true, + "basicAuthUser":"basicuser", + "basicAuthPassword":"basicuser", + "isDefault":false, + "jsonData":null +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Datasource updated", "id": 1, "name": "test_datasource"} +{"message":"Datasource updated", "id": 1, "name": "test_datasource"} +``` ## Delete an existing data source by id @@ -223,17 +248,21 @@ parent = "http_api" **Example Request**: - DELETE /api/datasources/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +DELETE /api/datasources/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Data source deleted"} +{"message":"Data source deleted"} +``` ## Delete an existing data source by name @@ -241,17 +270,21 @@ parent = "http_api" **Example Request**: - DELETE /api/datasources/name/test_datasource HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +DELETE /api/datasources/name/test_datasource HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Data source deleted"} +{"message":"Data source deleted"} +``` ## Data source proxy calls diff --git a/docs/sources/http_api/org.md b/docs/sources/http_api/org.md index 72c995adedf..6542f00fd81 100644 --- a/docs/sources/http_api/org.md +++ b/docs/sources/http_api/org.md @@ -18,20 +18,24 @@ parent = "http_api" **Example Request**: - GET /api/org HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/org HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "id":1, - "name":"Main Org." - } +{ + "id":1, + "name":"Main Org." +} +``` ## Get Organisation by Id @@ -39,57 +43,64 @@ parent = "http_api" **Example Request**: - GET /api/orgs/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/orgs/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - { - "id":1, - "name":"Main Org.", - "address":{ - "address1":"", - "address2":"", - "city":"", - "zipCode":"", - "state":"", - "country":"" - } - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "id":1, + "name":"Main Org.", + "address":{ + "address1":"", + "address2":"", + "city":"", + "zipCode":"", + "state":"", + "country":"" + } +} +``` ## Get Organisation by Name `GET /api/orgs/name/:orgName` **Example Request**: - GET /api/orgs/name/Main%20Org%2E HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/orgs/name/Main%20Org%2E HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "id":1, - "name":"Main Org.", - "address":{ - "address1":"", - "address2":"", - "city":"", - "zipCode":"", - "state":"", - "country":"" - } - } +{ + "id":1, + "name":"Main Org.", + "address":{ + "address1":"", + "address2":"", + "city":"", + "zipCode":"", + "state":"", + "country":"" + } +} +``` ## Create Organisation @@ -97,26 +108,28 @@ parent = "http_api" **Example Request**: - POST /api/orgs HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "name":"New Org." - } +```http +POST /api/orgs HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "name":"New Org." +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - { - "orgId":"1", - "message":"Organization created" - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "orgId":"1", + "message":"Organization created" +} +``` ## Update current Organisation @@ -125,23 +138,25 @@ parent = "http_api" **Example Request**: - PUT /api/org HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "name":"Main Org." - } +```http +PUT /api/org HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "name":"Main Org." +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - {"message":"Organization updated"} +```http +HTTP/1.1 200 +Content-Type: application/json +{"message":"Organization updated"} +``` ## Get all users within the actual organisation @@ -149,25 +164,29 @@ parent = "http_api" **Example Request**: - GET /api/org/users HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/org/users HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "orgId":1, - "userId":1, - "email":"admin@mygraf.com", - "login":"admin", - "role":"Admin" - } - ] +[ + { + "orgId":1, + "userId":1, + "email":"admin@mygraf.com", + "login":"admin", + "role":"Admin" + } +] +``` ## Add a new user to the actual organisation @@ -177,23 +196,26 @@ Adds a global user to the actual organisation. **Example Request**: - POST /api/org/users HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "role": "Admin", - "loginOrEmail": "admin" - } +```http +POST /api/org/users HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "role": "Admin", + "loginOrEmail": "admin" +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"User added to organization"} +{"message":"User added to organization"} +``` ## Updates the given user @@ -201,23 +223,25 @@ Adds a global user to the actual organisation. **Example Request**: - PATCH /api/org/users/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "role": "Viewer", - } +```http +PATCH /api/org/users/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "role": "Viewer", +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - {"message":"Organization user updated"} +```http +HTTP/1.1 200 +Content-Type: application/json +{"message":"Organization user updated"} +``` ## Delete user in actual organisation @@ -225,18 +249,21 @@ Adds a global user to the actual organisation. **Example Request**: - DELETE /api/org/users/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +DELETE /api/org/users/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - {"message":"User removed from organization"} +```http +HTTP/1.1 200 +Content-Type: application/json +{"message":"User removed from organization"} +``` # Organisations @@ -246,22 +273,26 @@ Adds a global user to the actual organisation. **Example Request**: - GET /api/orgs HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/orgs HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "id":1, - "name":"Main Org." - } - ] +[ + { + "id":1, + "name":"Main Org." + } +] +``` ## Update Organisation @@ -271,22 +302,25 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y **Example Request**: - PUT /api/orgs/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - - { - "name":"Main Org 2." - } +```http +PUT /api/orgs/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "name":"Main Org 2." +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Organization updated"} +{"message":"Organization updated"} +``` ## Get Users in Organisation @@ -294,24 +328,28 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y **Example Request**: - GET /api/orgs/1/users HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/orgs/1/users HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - [ - { - "orgId":1, - "userId":1, - "email":"admin@mygraf.com", - "login":"admin", - "role":"Admin" - } - ] +```http +HTTP/1.1 200 +Content-Type: application/json +[ + { + "orgId":1, + "userId":1, + "email":"admin@mygraf.com", + "login":"admin", + "role":"Admin" + } +] +``` ## Add User in Organisation @@ -319,22 +357,26 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y **Example Request**: - POST /api/orgs/1/users HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +POST /api/orgs/1/users HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "loginOrEmail":"user", - "role":"Viewer" - } +{ + "loginOrEmail":"user", + "role":"Viewer" +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"User added to organization"} +{"message":"User added to organization"} +``` ## Update Users in Organisation @@ -342,21 +384,25 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y **Example Request**: - PATCH /api/orgs/1/users/2 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +PATCH /api/orgs/1/users/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "role":"Admin" - } +{ + "role":"Admin" +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Organization user updated"} +{"message":"Organization user updated"} +``` ## Delete User in Organisation @@ -364,14 +410,18 @@ Update Organisation, fields *Adress 1*, *Adress 2*, *City* are not implemented y **Example Request**: - DELETE /api/orgs/1/users/2 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +DELETE /api/orgs/1/users/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"User removed from organization"} +{"message":"User removed from organization"} +``` \ No newline at end of file diff --git a/docs/sources/http_api/other.md b/docs/sources/http_api/other.md index 65d18f94ea4..5bf0cde05fe 100644 --- a/docs/sources/http_api/other.md +++ b/docs/sources/http_api/other.md @@ -18,43 +18,47 @@ parent = "http_api" **Example Request**: - GET /api/frontend/settings HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/frontend/settings HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "allowOrgCreate":true, - "appSubUrl":"", - "buildInfo":{ - "buildstamp":xxxxxx, - "commit":"vyyyy", - "version":"zzzzz" - }, - "datasources":{ - "datasourcename":{ - "index":"grafana-dash", - "meta":{ - "annotations":true, - "module":"plugins/datasource/grafana/datasource", - "name":"Grafana", - "partials":{ - "annotations":"app/plugins/datasource/grafana/partials/annotations.editor.html", - "config":"app/plugins/datasource/grafana/partials/config.html" - }, - "pluginType":"datasource", - "serviceName":"Grafana", - "type":"grafanasearch" - } - } - }, - "defaultDatasource": "Grafana" +{ + "allowOrgCreate":true, + "appSubUrl":"", + "buildInfo":{ + "buildstamp":xxxxxx, + "commit":"vyyyy", + "version":"zzzzz" + }, + "datasources":{ + "datasourcename":{ + "index":"grafana-dash", + "meta":{ + "annotations":true, + "module":"plugins/datasource/grafana/datasource", + "name":"Grafana", + "partials":{ + "annotations":"app/plugins/datasource/grafana/partials/annotations.editor.html", + "config":"app/plugins/datasource/grafana/partials/config.html" + }, + "pluginType":"datasource", + "serviceName":"Grafana", + "type":"grafanasearch" + } } + }, + "defaultDatasource": "Grafana" +} +``` # Login API @@ -64,14 +68,18 @@ parent = "http_api" **Example Request**: - GET /api/login/ping HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/login/ping HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message": "Logged in"} +{"message": "Logged in"} +``` \ No newline at end of file diff --git a/docs/sources/http_api/preferences.md b/docs/sources/http_api/preferences.md index a4b953cdaed..ac1d1ee7a0d 100644 --- a/docs/sources/http_api/preferences.md +++ b/docs/sources/http_api/preferences.md @@ -26,17 +26,21 @@ system default value. **Example Request**: - GET /api/user/preferences HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/user/preferences HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"theme":"","homeDashboardId":0,"timezone":""} +{"theme":"","homeDashboardId":0,"timezone":""} +``` ## Update Current User Prefs @@ -44,23 +48,27 @@ system default value. **Example Request**: - PUT /api/user/preferences HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +PUT /api/user/preferences HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "theme": "", - "homeDashboardId":0, - "timezone":"utc" - } +{ + "theme": "", + "homeDashboardId":0, + "timezone":"utc" +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: text/plain; charset=utf-8 +```http +HTTP/1.1 200 +Content-Type: text/plain; charset=utf-8 - {"message":"Preferences updated"} +{"message":"Preferences updated"} +``` ## Get Current Org Prefs @@ -68,17 +76,21 @@ system default value. **Example Request**: - GET /api/org/preferences HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/org/preferences HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"theme":"","homeDashboardId":0,"timezone":""} +{"theme":"","homeDashboardId":0,"timezone":""} +``` ## Update Current Org Prefs @@ -86,20 +98,24 @@ system default value. **Example Request**: - PUT /api/org/preferences HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +PUT /api/org/preferences HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "theme": "", - "homeDashboardId":0, - "timezone":"utc" - } +{ + "theme": "", + "homeDashboardId":0, + "timezone":"utc" +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: text/plain; charset=utf-8 +```http +HTTP/1.1 200 +Content-Type: text/plain; charset=utf-8 - {"message":"Preferences updated"} +{"message":"Preferences updated"} +``` \ No newline at end of file diff --git a/docs/sources/http_api/snapshot.md b/docs/sources/http_api/snapshot.md index d466d01e051..5cecdb85fc3 100644 --- a/docs/sources/http_api/snapshot.md +++ b/docs/sources/http_api/snapshot.md @@ -17,6 +17,7 @@ parent = "http_api" **Example Request**: +```http POST /api/snapshots HTTP/1.1 Accept: application/json Content-Type: application/json @@ -51,18 +52,20 @@ parent = "http_api" }, "expires": 3600 } +``` JSON Body schema: - **dashboard** – Required. The complete dashboard model. - **name** – Optional. snapshot name -- **expires** - Optional. When the snapshot should expire in seconds. 3600 is 1 hour, 86400 is 1 day. Default is never to expire. +- **expires** - Optional. When the snapshot should expire in seconds. 3600 is 1 hour, 86400 is 1 day. Default is never to expire. - **external** - Optional. Save the snapshot on an external server rather than locally. Default is `false`. - **key** - Optional. Define the unique key. Required if **external** is `true`. - **deleteKey** - Optional. Unique key used to delete the snapshot. It is different from the **key** so that only the creator can delete the snapshot. Required if **external** is `true`. **Example Response**: +```http HTTP/1.1 200 Content-Type: application/json { @@ -71,6 +74,7 @@ JSON Body schema: "key":"YYYYYYY", "url":"myurl/dashboard/snapshot/YYYYYYY" } +``` Keys: @@ -83,54 +87,58 @@ Keys: **Example Request**: - GET /api/snapshots/YYYYYYY HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/snapshots/YYYYYYY HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "meta":{ - "isSnapshot":true, - "type":"snapshot", - "canSave":false, - "canEdit":false, - "canStar":false, - "slug":"", - "expires":"2200-13-32T25:23:23+02:00", - "created":"2200-13-32T28:24:23+02:00" - }, - "dashboard": { - "editable":false, - "hideControls":true, - "nav":[ - { - "enable":false, - "type":"timepicker" - } - ], - "rows": [ - { +{ + "meta":{ + "isSnapshot":true, + "type":"snapshot", + "canSave":false, + "canEdit":false, + "canStar":false, + "slug":"", + "expires":"2200-13-32T25:23:23+02:00", + "created":"2200-13-32T28:24:23+02:00" + }, + "dashboard": { + "editable":false, + "hideControls":true, + "nav": [ + { + "enable":false, + "type":"timepicker" + } + ], + "rows": [ + { - } - ], - "style":"dark", - "tags":[], - "templating":{ - "list":[ - ] - }, - "time":{ - }, - "timezone":"browser", - "title":"Home", - "version":5 - } - } + } + ], + "style":"dark", + "tags":[], + "templating":{ + "list":[ + ] + }, + "time":{ + }, + "timezone":"browser", + "title":"Home", + "version":5 + } +} +``` ## Delete Snapshot by Id @@ -138,14 +146,18 @@ Keys: **Example Request**: - GET /api/snapshots/YYYYYYY HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/snapshots/YYYYYYY HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Snapshot deleted. It might take an hour before it's cleared from a CDN cache."} +{"message":"Snapshot deleted. It might take an hour before it's cleared from a CDN cache."} +``` \ No newline at end of file diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index 761ac938cd8..ba8afd4db22 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -17,34 +17,38 @@ parent = "http_api" **Example Request**: - GET /api/users HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Basic YWRtaW46YWRtaW4= +```http +GET /api/users HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. Requires basic authentication and that the authenticated user is a Grafana Admin. **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "id": 1, - "name": "Admin", - "login": "admin", - "email": "admin@mygraf.com", - "isAdmin": true - }, - { - "id": 2, - "name": "User", - "login": "user", - "email": "user@mygraf.com", - "isAdmin": false - } - ] +[ + { + "id": 1, + "name": "Admin", + "login": "admin", + "email": "admin@mygraf.com", + "isAdmin": true + }, + { + "id": 2, + "name": "User", + "login": "user", + "email": "user@mygraf.com", + "isAdmin": false + } +] +``` ## Search Users with Paging @@ -52,10 +56,12 @@ Default value for the `perpage` parameter is `1000` and for the `page` parameter **Example Request**: - GET /api/users/search?perpage=10&page=1&query=mygraf HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Basic YWRtaW46YWRtaW4= +```http +GET /api/users/search?perpage=10&page=1&query=mygraf HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`, `login` or `email` fields. Query values with spaces need to be url encoded e.g. `query=Jane%20Doe`. @@ -63,29 +69,31 @@ Requires basic authentication and that the authenticated user is a Grafana Admin **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "totalCount": 2, + "users": [ { - "totalCount": 2, - "users": [ - { - "id": 1, - "name": "Admin", - "login": "admin", - "email": "admin@mygraf.com", - "isAdmin": true - }, - { - "id": 2, - "name": "User", - "login": "user", - "email": "user@mygraf.com", - "isAdmin": false - } - ], - "page": 1, - "perPage": 10 + "id": 1, + "name": "Admin", + "login": "admin", + "email": "admin@mygraf.com", + "isAdmin": true + }, + { + "id": 2, + "name": "User", + "login": "user", + "email": "user@mygraf.com", + "isAdmin": false } + ], + "page": 1, + "perPage": 10 +} +``` ## Get single user by Id @@ -93,26 +101,29 @@ Requires basic authentication and that the authenticated user is a Grafana Admin **Example Request**: - GET /api/users/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Basic YWRtaW46YWRtaW4= - +```http +GET /api/users/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` Requires basic authentication and that the authenticated user is a Grafana Admin. **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "email": "user@mygraf.com" - "name": "admin", - "login": "admin", - "theme": "light", - "orgId": 1, - "isGrafanaAdmin": true - } +{ + "email": "user@mygraf.com" + "name": "admin", + "login": "admin", + "theme": "light", + "orgId": 1, + "isGrafanaAdmin": true +} +``` ## Get single user by Username(login) or Email @@ -120,34 +131,39 @@ Requires basic authentication and that the authenticated user is a Grafana Admin **Example Request using the email as option**: - GET /api/users/lookup?loginOrEmail=user@mygraf.com HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/users/lookup?loginOrEmail=user@mygraf.com HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Request using the username as option**: - - GET /api/users/lookup?loginOrEmail=admin HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Basic YWRtaW46YWRtaW4= + +```http +GET /api/users/lookup?loginOrEmail=admin HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` Requires basic authentication and that the authenticated user is a Grafana Admin. **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - { - "email": "user@mygraf.com" - "name": "admin", - "login": "admin", - "theme": "light", - "orgId": 1, - "isGrafanaAdmin": true - } +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "email": "user@mygraf.com" + "name": "admin", + "login": "admin", + "theme": "light", + "orgId": 1, + "isGrafanaAdmin": true +} +``` ## User Update @@ -155,27 +171,30 @@ Requires basic authentication and that the authenticated user is a Grafana Admin **Example Request**: - PUT /api/users/2 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Basic YWRtaW46YWRtaW4= +```http +PUT /api/users/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= - { - "email":"user@mygraf.com", - "name":"User2", - "login":"user", - "theme":"light" - } +{ + "email":"user@mygraf.com", + "name":"User2", + "login":"user", + "theme":"light" +} +``` Requires basic authentication and that the authenticated user is a Grafana Admin. **Example Response**: - HTTP/1.1 200 - Content-Type: application/json - - {"message":"User updated"} +```http +HTTP/1.1 200 +Content-Type: application/json +{"message":"User updated"} +``` ## Get Organisations for user @@ -183,25 +202,29 @@ Requires basic authentication and that the authenticated user is a Grafana Admin **Example Request**: - GET /api/users/1/orgs HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Basic YWRtaW46YWRtaW4= +```http +GET /api/users/1/orgs HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` Requires basic authentication and that the authenticated user is a Grafana Admin. **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "orgId":1, - "name":"Main Org.", - "role":"Admin" - } - ] +[ + { + "orgId":1, + "name":"Main Org.", + "role":"Admin" + } +] +``` ## User @@ -211,24 +234,28 @@ Requires basic authentication and that the authenticated user is a Grafana Admin **Example Request**: - GET /api/user HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/user HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - { - "email":"admin@mygraf.com", - "name":"Admin", - "login":"admin", - "theme":"light", - "orgId":1, - "isGrafanaAdmin":true - } +{ + "email":"admin@mygraf.com", + "name":"Admin", + "login":"admin", + "theme":"light", + "orgId":1, + "isGrafanaAdmin":true +} +``` ## Change Password @@ -238,23 +265,27 @@ Changes the password for the user **Example Request**: - PUT /api/user/password HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +PUT /api/user/password HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - { - "oldPassword": "old_password", - "newPassword": "new_password", - "confirmNew": "confirm_new_password" - } +{ + "oldPassword": "old_password", + "newPassword": "new_password", + "confirmNew": "confirm_new_password" +} +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"User password changed"} +{"message":"User password changed"} +``` ## Switch user context for a specified user @@ -264,15 +295,19 @@ Switch user context to the given organization. Requires basic authentication and **Example Request**: - POST /api/users/7/using/2 HTTP/1.1 - Authorization: Basic YWRtaW46YWRtaW4= +```http +POST /api/users/7/using/2 HTTP/1.1 +Authorization: Basic YWRtaW46YWRtaW4= +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Active organization changed"} +{"message":"Active organization changed"} +``` ## Switch user context for signed in user @@ -282,17 +317,21 @@ Switch user context to the given organization. **Example Request**: - POST /api/user/using/2 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +POST /api/user/using/2 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Active organization changed"} +{"message":"Active organization changed"} +``` ## Organisations of the actual User @@ -302,23 +341,27 @@ Return a list of all organisations of the current user. **Example Request**: - GET /api/user/orgs HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +GET /api/user/orgs HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - [ - { - "orgId":1, - "name":"Main Org.", - "role":"Admin" - } - ] +[ + { + "orgId":1, + "name":"Main Org.", + "role":"Admin" + } +] +``` ## Star a dashboard @@ -328,17 +371,21 @@ Stars the given Dashboard for the actual user. **Example Request**: - POST /api/user/stars/dashboard/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +POST /api/user/stars/dashboard/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Dashboard starred!"} +{"message":"Dashboard starred!"} +``` ## Unstar a dashboard @@ -348,14 +395,18 @@ Deletes the starring of the given Dashboard for the actual user. **Example Request**: - DELETE /api/user/stars/dashboard/1 HTTP/1.1 - Accept: application/json - Content-Type: application/json - Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +```http +DELETE /api/user/stars/dashboard/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` **Example Response**: - HTTP/1.1 200 - Content-Type: application/json +```http +HTTP/1.1 200 +Content-Type: application/json - {"message":"Dashboard unstarred"} +{"message":"Dashboard unstarred"} +``` \ No newline at end of file diff --git a/docs/sources/installation/behind_proxy.md b/docs/sources/installation/behind_proxy.md index 0caa31bbfb5..3d89d8b3c2c 100644 --- a/docs/sources/installation/behind_proxy.md +++ b/docs/sources/installation/behind_proxy.md @@ -15,7 +15,7 @@ weight = 1 It should be straight forward to get Grafana up and running behind a reverse proxy. But here are some things that you might run into. Links and redirects will not be rendered correctly unless you set the server.domain setting. -``` +```bash [server] domain = foo.bar ``` @@ -28,14 +28,14 @@ Here are some example configurations for running Grafana behind a reverse proxy. ### Grafana configuration (ex http://foo.bar.com) -``` +```bash [server] domain = foo.bar ``` ### Nginx configuration -``` +```bash server { listen 80; root /usr/share/nginx/www; @@ -50,14 +50,14 @@ server { ### Examples with **sub path** (ex http://foo.bar.com/grafana) #### Grafana configuration with sub path -``` +```bash [server] domain = foo.bar root_url = %(protocol)s://%(domain)s:/grafana ``` #### Nginx configuration with sub path -``` +```bash server { listen 80; root /usr/share/nginx/www; diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index b8593b681df..627a76a963e 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -37,26 +37,31 @@ A common problem is forgetting to uncomment a line in the `custom.ini` (or `graf All options in the configuration file (listed below) can be overridden using environment variables using the syntax: - GF__ +```bash +GF__ +``` Where the section name is the text within the brackets. Everything should be upper case, `.` should be replaced by `_`. For example, given these configuration settings: - # default section - instance_name = ${HOSTNAME} +```bash +# default section +instance_name = ${HOSTNAME} - [security] - admin_user = admin - - [auth.google] - client_secret = 0ldS3cretKey +[security] +admin_user = admin +[auth.google] +client_secret = 0ldS3cretKey +``` Then you can override them using: - export GF_DEFAULT_INSTANCE_NAME=my-instance - export GF_SECURITY_ADMIN_USER=true - export GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey +```bash +export GF_DEFAULT_INSTANCE_NAME=my-instance +export GF_SECURITY_ADMIN_USER=true +export GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey +```
@@ -93,11 +98,15 @@ The IP address to bind to. If empty will bind to all interfaces The port to bind to, defaults to `3000`. To use port 80 you need to either give the Grafana binary permission for example: - $ sudo setcap 'cap_net_bind_service=+ep' /usr/sbin/grafana-server +```bash +$ sudo setcap 'cap_net_bind_service=+ep' /usr/sbin/grafana-server +``` Or redirect port 80 to the Grafana port using: - $ sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000 +```bash +$ sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000 +``` Another way is put a webserver like Nginx or Apache in front of Grafana and have them proxy requests to Grafana. @@ -312,7 +321,9 @@ You need to create a GitHub OAuth application (you find this under the GitHub settings page). When you create the application you will need to specify a callback URL. Specify this as callback: - http://:/login/github +```bash +http://:/login/github +``` This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/github`. @@ -320,17 +331,19 @@ When the GitHub OAuth application is created you will get a Client ID and a Client Secret. Specify these in the Grafana configuration file. For example: - [auth.github] - enabled = true - allow_sign_up = true - client_id = YOUR_GITHUB_APP_CLIENT_ID - client_secret = YOUR_GITHUB_APP_CLIENT_SECRET - scopes = user:email - auth_url = https://github.com/login/oauth/authorize - token_url = https://github.com/login/oauth/access_token - api_url = https://api.github.com/user - team_ids = - allowed_organizations = +```bash +[auth.github] +enabled = true +allow_sign_up = true +client_id = YOUR_GITHUB_APP_CLIENT_ID +client_secret = YOUR_GITHUB_APP_CLIENT_SECRET +scopes = user:email +auth_url = https://github.com/login/oauth/authorize +token_url = https://github.com/login/oauth/access_token +api_url = https://api.github.com/user +team_ids = +allowed_organizations = +``` Restart the Grafana back-end. You should now see a GitHub login button on the login page. You can now login or sign up with your GitHub @@ -348,15 +361,17 @@ GitHub. If the authenticated user isn't a member of at least one of the teams they will not be able to register or authenticate with your Grafana instance. For example: - [auth.github] - enabled = true - client_id = YOUR_GITHUB_APP_CLIENT_ID - client_secret = YOUR_GITHUB_APP_CLIENT_SECRET - scopes = user:email,read:org - team_ids = 150,300 - auth_url = https://github.com/login/oauth/authorize - token_url = https://github.com/login/oauth/access_token - allow_sign_up = true +```bash +[auth.github] +enabled = true +client_id = YOUR_GITHUB_APP_CLIENT_ID +client_secret = YOUR_GITHUB_APP_CLIENT_SECRET +scopes = user:email,read:org +team_ids = 150,300 +auth_url = https://github.com/login/oauth/authorize +token_url = https://github.com/login/oauth/access_token +allow_sign_up = true +``` ### allowed_organizations @@ -365,16 +380,18 @@ organizations on GitHub. If the authenticated user isn't a member of at least one of the organizations they will not be able to register or authenticate with your Grafana instance. For example - [auth.github] - enabled = true - client_id = YOUR_GITHUB_APP_CLIENT_ID - client_secret = YOUR_GITHUB_APP_CLIENT_SECRET - scopes = user:email,read:org - auth_url = https://github.com/login/oauth/authorize - token_url = https://github.com/login/oauth/access_token - allow_sign_up = true - # space-delimited organization names - allowed_organizations = github google +```bash +[auth.github] +enabled = true +client_id = YOUR_GITHUB_APP_CLIENT_ID +client_secret = YOUR_GITHUB_APP_CLIENT_SECRET +scopes = user:email,read:org +auth_url = https://github.com/login/oauth/authorize +token_url = https://github.com/login/oauth/access_token +allow_sign_up = true +# space-delimited organization names +allowed_organizations = github google +```
@@ -385,22 +402,26 @@ Developer Console](https://console.developers.google.com/project). When you create the project you will need to specify a callback URL. Specify this as callback: - http://:/login/google +```bash +http://:/login/google +``` This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/google`. When the Google project is created you will get a Client ID and a Client Secret. Specify these in the Grafana configuration file. For example: - [auth.google] - enabled = true - client_id = YOUR_GOOGLE_APP_CLIENT_ID - client_secret = YOUR_GOOGLE_APP_CLIENT_SECRET - scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email - auth_url = https://accounts.google.com/o/oauth2/auth - token_url = https://accounts.google.com/o/oauth2/token - allowed_domains = mycompany.com mycompany.org - allow_sign_up = true +```bash +[auth.google] +enabled = true +client_id = YOUR_GOOGLE_APP_CLIENT_ID +client_secret = YOUR_GOOGLE_APP_CLIENT_SECRET +scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email +auth_url = https://accounts.google.com/o/oauth2/auth +token_url = https://accounts.google.com/o/oauth2/token +allowed_domains = mycompany.com mycompany.org +allow_sign_up = true +``` Restart the Grafana back-end. You should now see a Google login button on the login page. You can now login or sign up with your Google @@ -418,19 +439,55 @@ This option could be used if have your own oauth service. This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`. - [auth.generic_oauth] - enabled = true - client_id = YOUR_APP_CLIENT_ID - client_secret = YOUR_APP_CLIENT_SECRET - scopes = - auth_url = - token_url = - api_url = - allowed_domains = mycompany.com mycompany.org - allow_sign_up = true +```bash +[auth.generic_oauth] +enabled = true +client_id = YOUR_APP_CLIENT_ID +client_secret = YOUR_APP_CLIENT_SECRET +scopes = +auth_url = +token_url = +api_url = +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 up oauth2 with Okta + +First set up Grafana as an OpenId client "webapplication" in Okta. Then set the Base URIs to `https:///` and set the Login redirect URIs to `https:///login/generic_oauth`. + +Finaly set up the generic oauth module like this: +```bash +[auth.generic_oauth] +name = Okta +enabled = true +scopes = openid profile email +client_id = +client_secret = +auth_url = https:///oauth2/v1/authorize +token_url = https:///oauth2/v1/token +api_url = https:///oauth2/v1/userinfo +``` + +### Set up oauth2 with Bitbucket + +```bash +[auth.generic_oauth] +name = BitBucket +enabled = true +allow_sign_up = true +client_id = +client_secret = +scopes = account email +auth_url = https://bitbucket.org/site/oauth2/authorize +token_url = https://bitbucket.org/site/oauth2/access_token +api_url = https://api.bitbucket.org/2.0/user +team_ids = +allowed_organizations = +``` +
## [auth.basic] @@ -503,21 +560,25 @@ session table manually. Mysql Example: - CREATE TABLE `session` ( - `key` CHAR(16) NOT NULL, - `data` BLOB, - `expiry` INT(11) UNSIGNED NOT NULL, - PRIMARY KEY (`key`) - ) ENGINE=MyISAM DEFAULT CHARSET=utf8; +```bash +CREATE TABLE `session` ( + `key` CHAR(16) NOT NULL, + `data` BLOB, + `expiry` INT(11) UNSIGNED NOT NULL, + PRIMARY KEY (`key`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; +``` Postgres Example: - CREATE TABLE session ( - key CHAR(16) NOT NULL, - data BYTEA, - expiry INTEGER NOT NULL, - PRIMARY KEY (key) - ); +```bash +CREATE TABLE session ( + key CHAR(16) NOT NULL, + data BYTEA, + expiry INTEGER NOT NULL, + PRIMARY KEY (key) +); +``` Postgres valid `sslmode` are `disable`, `require` (default), `verify-ca`, and `verify-full`. @@ -593,6 +654,9 @@ Address used when sending out emails, defaults to `admin@grafana.localhost` ### from_name Name to be used when sending out emails, defaults to `Grafana` +### ehlo_identity +Name to be used as client identity for EHLO in SMTP dialog, defaults to instance_name. + ## [log] ### mode @@ -649,11 +713,20 @@ You can choose between (s3, webdav, gcs). If left empty Grafana will ignore the ## [external_image_storage.s3] +### bucket +Bucket name for S3. e.g. grafana.snapshot + +### region +Region name for S3. e.g. 'us-east-1', 'cn-north-1', etc + +### path +Optional extra path inside bucket, useful to apply expiration policies + ### bucket_url +(for backward compatibility, only works when no bucket or region are configured) Bucket URL for S3. AWS region can be specified within URL or defaults to 'us-east-1', e.g. - http://grafana.s3.amazonaws.com/ - https://grafana.s3-ap-southeast-2.amazonaws.com/ -- https://grafana.s3-cn-north-1.amazonaws.com.cn ### access_key Access key. e.g. AAAAAAAAAAAAAAAAAAAA @@ -686,7 +759,7 @@ Service Account keys can be created and downloaded from https://console.develope Service Account should have "Storage Object Writer" role. ### bucket name -Bucket Name on Google Cloud Storage. +Bucket Name on Google Cloud Storage. ## [alerting] diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 93915f52824..222a337855a 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -15,7 +15,7 @@ weight = 1 Description | Download ------------ | ------------- -Stable for Debian-based Linux | [grafana_4.5.1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.1_amd64.deb) +Stable for Debian-based Linux | [grafana_4.5.2_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.2_amd64.deb) @@ -26,18 +26,18 @@ installation. ```bash -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.1_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_4.5.2_amd64.deb sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_4.5.1_amd64.deb +sudo dpkg -i grafana_4.5.2_amd64.deb ``` @@ -45,13 +45,17 @@ sudo dpkg -i grafana_4.5.0-beta1_amd64.deb Add the following line to your `/etc/apt/sources.list` file. - deb https://packagecloud.io/grafana/stable/debian/ jessie main +```bash +deb https://packagecloud.io/grafana/stable/debian/ jessie main +``` Use the above line even if you are on Ubuntu or another Debian version. There is also a testing repository if you want beta or release candidates. - deb https://packagecloud.io/grafana/testing/debian/ jessie main +```bash +deb https://packagecloud.io/grafana/testing/debian/ jessie main +``` Then add the [Package Cloud](https://packagecloud.io/grafana) key. This allows you to install signed packages. diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index 03e6979d72b..bfb754900fa 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -14,7 +14,9 @@ weight = 4 Grafana is very easy to install and run using the offical docker container. - $ docker run -d -p 3000:3000 grafana/grafana +```bash +$ docker run -d -p 3000:3000 grafana/grafana +``` All Grafana configuration settings can be defined using environment variables, this is especially useful when using the above container. @@ -26,10 +28,12 @@ folder `/var/lib/grafana` and configuration files is in `/etc/grafana/` folder. You can map these volumes to host folders when you start the container: - $ docker run -d -p 3000:3000 \ - -v /var/lib/grafana:/var/lib/grafana \ - -e "GF_SECURITY_ADMIN_PASSWORD=secret" \ - grafana/grafana +```bash +$ docker run -d -p 3000:3000 \ + -v /var/lib/grafana:/var/lib/grafana \ + -e "GF_SECURITY_ADMIN_PASSWORD=secret" \ + grafana/grafana +``` In the above example I map the data folder and sets a configuration option via an `ENV` instruction. diff --git a/docs/sources/installation/ldap.md b/docs/sources/installation/ldap.md index 769ca3fd1ba..8f6be6e1d8c 100644 --- a/docs/sources/installation/ldap.md +++ b/docs/sources/installation/ldap.md @@ -92,7 +92,7 @@ org_role = "Viewer" 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). -``` +```bash bind_dn = "cn=admin,dc=grafana,dc=org" bind_password = "grafana" ``` @@ -102,7 +102,7 @@ bind_password = "grafana" 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. -``` +```bash bind_dn = "cn=%s,o=users,dc=grafana,dc=org" ``` diff --git a/docs/sources/installation/mac.md b/docs/sources/installation/mac.md index 912a16c66e8..b1d4f18f699 100644 --- a/docs/sources/installation/mac.md +++ b/docs/sources/installation/mac.md @@ -15,7 +15,7 @@ Installation can be done using [homebrew](http://brew.sh/) Install latest stable: -``` +```bash brew update brew install grafana ``` @@ -24,7 +24,7 @@ To start grafana look at the command printed after the homebrew install complete To upgrade use the reinstall command -``` +```bash brew update brew reinstall grafana ``` @@ -34,12 +34,44 @@ brew reinstall grafana You can also install the latest unstable grafana from git: -``` +```bash brew install --HEAD grafana/grafana/grafana ``` To upgrade grafana if you've installed from HEAD: -``` +```bash brew reinstall --HEAD grafana/grafana/grafana ``` + +### Starting Grafana + +To start Grafana using homebrew services first make sure homebrew/services is installed. + +```bash +brew tap homebrew/services +``` + +Then start Grafana using: + +```bash +brew services start grafana +``` + + +### Configuration + +The Configuration file should be located at `/usr/local/etc/grafana/grafana.ini`. + +### Logs + +The log file should be located at `/usr/local/var/log/grafana/grafana.log`. + +### Plugins + +If you want to manually install a plugin place it here: `/usr/local/var/lib/grafana/plugins`. + +### Database + +The default sqlite database is located at `/usr/local/var/lib/grafana` + diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index 1eac82dcfb3..cf08173f3e9 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -15,7 +15,7 @@ weight = 2 Description | Download ------------ | ------------- -Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.5.1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.1-1.x86_64.rpm) +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.5.2 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.2-1.x86_64.rpm) @@ -26,41 +26,53 @@ installation. You can install Grafana using Yum directly. - $ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.1-1.x86_64.rpm +```bash +$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.2-1.x86_64.rpm +``` Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: - $ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.1-1.x86_64.rpm - $ sudo yum install initscripts fontconfig - $ sudo rpm -Uvh grafana-4.5.1-1.x86_64.rpm +```bash +$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.2-1.x86_64.rpm +$ sudo yum install initscripts fontconfig +$ sudo rpm -Uvh grafana-4.5.2-1.x86_64.rpm +``` #### On OpenSuse: - $ sudo rpm -i --nodeps grafana-4.5.1-1.x86_64.rpm +```bash +$ sudo rpm -i --nodeps grafana-4.5.2-1.x86_64.rpm +``` ## Install via YUM Repository Add the following to a new file at `/etc/yum.repos.d/grafana.repo` - [grafana] - name=grafana - baseurl=https://packagecloud.io/grafana/stable/el/6/$basearch - repo_gpgcheck=1 - enabled=1 - gpgcheck=1 - gpgkey=https://packagecloud.io/gpg.key https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana - sslverify=1 - sslcacert=/etc/pki/tls/certs/ca-bundle.crt +```bash +[grafana] +name=grafana +baseurl=https://packagecloud.io/grafana/stable/el/6/$basearch +repo_gpgcheck=1 +enabled=1 +gpgcheck=1 +gpgkey=https://packagecloud.io/gpg.key https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana +sslverify=1 +sslcacert=/etc/pki/tls/certs/ca-bundle.crt +``` There is also a testing repository if you want beta or release candidates. - baseurl=https://packagecloud.io/grafana/testing/el/6/$basearch +```bash +baseurl=https://packagecloud.io/grafana/testing/el/6/$basearch +``` Then install Grafana via the `yum` command. - $ sudo yum install grafana +```bash +$ sudo yum install grafana +``` ### RPM GPG Key @@ -81,7 +93,9 @@ key](https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana). You can start Grafana by running: - $ sudo service grafana-server start +```bash +$ sudo service grafana-server start +``` This will start the `grafana-server` process as the `grafana` user, which is created during package installation. The default HTTP port is @@ -89,17 +103,23 @@ which is created during package installation. The default HTTP port is To configure the Grafana server to start at boot time: - $ sudo /sbin/chkconfig --add grafana-server +```bash +$ sudo /sbin/chkconfig --add grafana-server +``` ## Start the server (via systemd) - $ systemctl daemon-reload - $ systemctl start grafana-server - $ systemctl status grafana-server +```bash +$ systemctl daemon-reload +$ systemctl start grafana-server +$ systemctl status grafana-server +``` ### Enable the systemd service to start at boot - sudo systemctl enable grafana-server.service +```bash +sudo systemctl enable grafana-server.service +``` ## Environment file @@ -138,7 +158,7 @@ for example in alert notifications. If the image is missing text make sure you have font packages installed. -``` +```bash yum install fontconfig yum install freetype* yum install urw-fonts diff --git a/docs/sources/installation/upgrading.md b/docs/sources/installation/upgrading.md index 4cd8471e441..6a4b4e8f047 100644 --- a/docs/sources/installation/upgrading.md +++ b/docs/sources/installation/upgrading.md @@ -29,7 +29,7 @@ installed grafana to custom location using a binary tar/zip it is usally in ` mysqldump -u root -p[root_password] [grafana] > grafana_backup.sql @@ -39,7 +39,7 @@ restore: #### postgres -``` +```bash backup: > pg_dump grafana > grafana_backup @@ -54,7 +54,7 @@ and execute the same `dpkg -i` command but with the new package. It will upgrade If you used our APT repository: -``` +```bash sudo apt-get update sudo apt-get install grafana ``` @@ -73,14 +73,14 @@ and execute the same `yum install` or `rpm -i` command but with the new package. If you used our YUM repository: -``` +```bash sudo yum update grafana ``` ### Docker This just an example, details depend on how you configured your grafana container. -``` +```bash docker pull grafana docker stop my-grafana-container docker rm my-grafana-container diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index ad1090cecf0..ea9614c01be 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -13,7 +13,7 @@ weight = 3 Description | Download ------------ | ------------- -Latest stable package for Windows | [grafana.4.5.1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.1.windows-x64.zip) +Latest stable package for Windows | [grafana.4.5.2.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-4.5.2.windows-x64.zip) Read [Upgrading Grafana]({{< relref "installation/upgrading.md" >}}) for tips and guidance on updating an existing installation. diff --git a/docs/sources/plugins/developing/code-styleguide.md b/docs/sources/plugins/developing/code-styleguide.md index 44379c4d4e5..9ee91412e24 100644 --- a/docs/sources/plugins/developing/code-styleguide.md +++ b/docs/sources/plugins/developing/code-styleguide.md @@ -23,7 +23,7 @@ The most important fields are the first three, especially the id. The convention Examples: -``` +```bash raintank-worldping-app grafana-simple-json-datasource grafana-piechart-panel @@ -66,7 +66,7 @@ The README.md file is rendered both on Grafana.net and in the plugins section in Here is a typical directory structure for a plugin. -``` +```bash johnnyb-awesome-datasource |-- dist |-- spec diff --git a/docs/sources/plugins/developing/datasources.md b/docs/sources/plugins/developing/datasources.md index 612a0786976..0149f06e1aa 100644 --- a/docs/sources/plugins/developing/datasources.md +++ b/docs/sources/plugins/developing/datasources.md @@ -45,7 +45,7 @@ The javascript object that communicates with the database and transforms data to The Datasource should contain the following functions: -``` +```javascript query(options) //used by panels to get data testDatasource() //used by datasource configuration page to make sure the connection is working annotationQuery(options) // used by dashboards to get annotations diff --git a/docs/sources/plugins/developing/development.md b/docs/sources/plugins/developing/development.md index 7d67dd22323..41cef41b653 100644 --- a/docs/sources/plugins/developing/development.md +++ b/docs/sources/plugins/developing/development.md @@ -10,26 +10,42 @@ weight = 1 # Developer Guide -From grafana 3.0 it's very easy to develop your own plugins and share them with other grafana users. - -There are two blog posts about authoring a plugin that might also be of interest to any plugin authors, [Timing is Everything. Writing the Clock Panel Plugin for Grafana 3.0- part 1](https://grafana.com/blog/2016/04/08/timing-is-everything.-writing-the-clock-panel-plugin-for-grafana-3.0/) and [Timing is Everything. Editor Mode in Grafana 3.0 for the Clock Panel Plugin](https://grafana.com/blog/2016/04/15/timing-is-everything.-editor-mode-in-grafana-3.0-for-the-clock-panel-plugin/). +You can extend Grafana by writing your own plugins and then share then with other users in [our plugin repository](https://grafana.com/plugins). ## Short version 1. [Setup grafana](http://docs.grafana.org/project/building_from_source/) 2. Clone an example plugin into ```/var/lib/grafana/plugins``` or `data/plugins` (relative to grafana git repo if you're running development version from source dir) -3. Code away! +3. You one of our example plugins as starting point + +Example plugins + +- [Typescript data source example](https://github.com/grafana/typescript-template-datasource) +- [Simple json data source](https://github.com/grafana/simple-json-datasource) +- [Clock panel](https://github.com/grafana/clock-panel) +- [Pie chart panel](https://github.com/grafana/piechart-panel) + +There are two blog posts about authoring a plugin that might also be of interest to any plugin authors. + +- [Timing is Everything. Writing the Clock Panel Plugin for Grafana](https://grafana.com/blog/2016/04/08/timing-is-everything.-writing-the-clock-panel-plugin-for-grafana-3.0/) +- [Timing is Everything. Editor Mode in Grafana for the Clock Panel Plugin](https://grafana.com/blog/2016/04/15/timing-is-everything.-editor-mode-in-grafana-3.0-for-the-clock-panel-plugin/). ## What languages? -Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages. +Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since +we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages. ## Buildscript -You can use any build system you like that support systemjs. All the built content should end up in a folder named ```dist``` and committed to the repository.By committing the dist folder the person who installs your plugin does not have to run any buildscript. - +You can use any build system you like that support systemjs. All the built content should end up in a folder named ```dist``` and committed to the repository. +By committing the dist folder the person who installs your plugin does not have to run any buildscript. All our example plugins have build scripted configured. +## Keep your plugin up to date + +New versions of Grafana can sometimes cause plugins to break. Checkout our [PLUGIN_DEV.md](https://github.com/grafana/grafana/blob/master/PLUGIN_DEV.md) doc for changes in +Grafana that can impact your plugin. + ## Metadata See the [coding styleguide]({{< relref "code-styleguide.md" >}}) for details on the metadata. diff --git a/docs/sources/plugins/installation.md b/docs/sources/plugins/installation.md index 27f6f583d9a..526f1d5d4eb 100644 --- a/docs/sources/plugins/installation.md +++ b/docs/sources/plugins/installation.md @@ -30,37 +30,37 @@ On Linux systems the grafana-cli will assume that the grafana plugin directory i ### Grafana-cli Commands List available plugins -``` +```bash grafana-cli plugins list-remote ``` Install the latest version of a plugin -``` +```bash grafana-cli plugins install ``` Install a specific version of a plugin -``` +```bash grafana-cli plugins install ``` List installed plugins -``` +```bash grafana-cli plugins ls ``` Update all installed plugins -``` +```bash grafana-cli plugins update-all ``` Update one plugin -``` +```bash grafana-cli plugins update ``` Remove one plugin -``` +```bash grafana-cli plugins remove ``` @@ -73,7 +73,7 @@ The Download URL from Grafana.com API is in this form: `https://grafana.com/api/plugins//versions//download` You can specify a local URL by using the `--pluginUrl` option. -``` +```bash grafana-cli --pluginUrl https://nexus.company.com/grafana/plugins/-.zip plugins install ``` @@ -84,7 +84,7 @@ To manually install a Plugin via the Grafana.com API: {{< imgbox img="/img/docs/installation-tab.png" caption="Installation Tab" >}} 2. Use the Grafana API to find the plugin using this url `https://grafana.com/api/plugins/`. For example: https://grafana.com/api/plugins/jdbranham-diagram-panel should return: - ``` + ```bash { "id": 145, "typeId": 3, @@ -97,7 +97,7 @@ To manually install a Plugin via the Grafana.com API: ``` 3. Find the download link: - ``` + ```bash { "rel": "download", "href": "/plugins/jdbranham-diagram-panel/versions/1.4.0/download" diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index 5056f1bd7b1..1e4f0421a0c 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -13,27 +13,27 @@ dev environment. Grafana ships with its own required backend server; also comple ## Dependencies -- [Go 1.8.1](https://golang.org/dl/) +- [Go 1.9.1](https://golang.org/dl/) - [NodeJS LTS](https://nodejs.org/download/) - [Git](https://git-scm.com/downloads) ## Get Code Create a directory for the project and set your path accordingly (or use the [default Go workspace directory](https://golang.org/doc/code.html#GOPATH)). Then download and install Grafana into your $GOPATH directory: -``` +```bash export GOPATH=`pwd` go get github.com/grafana/grafana ``` On Windows use setx instead of export and then restart your command prompt: -``` -setx GOPATH %cd% +```bash +setx GOPATH %cd% ``` You may see an error such as: `package github.com/grafana/grafana: no buildable Go source files`. This is just a warning, and you can proceed with the directions. ## Building the backend -``` +```bash cd $GOPATH/src/github.com/grafana/grafana go run build.go setup go run build.go build # (or 'go build ./pkg/cmd/grafana-server') @@ -43,36 +43,26 @@ go run build.go build # (or 'go build ./pkg/cmd/grafana-server') The Grafana backend includes Sqlite3 which requires GCC to compile. So in order to compile Grafana on windows you need to install GCC. We recommend [TDM-GCC](http://tdm-gcc.tdragon.net/download). -[node-gyp](https://github.com/nodejs/node-gyp#installation) is the Node.js native addon build tool and it requires extra dependencies to be installed on Windows. In a command prompt which is run as administrator, run: +[node-gyp](https://github.com/nodejs/node-gyp#installation) is the Node.js native addon build tool and it requires extra dependencies to be installed on Windows. In a command prompt which is run as administrator, run: -``` +```bash npm --add-python-to-path='true' --debug install --global windows-build-tools ``` -## Build the Front-end Assets +## Build the Frontend Assets -To build less to css for the frontend you will need a recent version of node (v0.12.0), -npm (v2.5.0) and grunt (v0.4.5). Run the following: +For this you need nodejs (v.6+). -``` +```bash npm install -g yarn yarn install --pure-lockfile -npm install -g grunt-cli -grunt +npm run build ``` -## Recompile backend on source change -To rebuild on source change -``` -go get github.com/Unknwon/bra -bra run -``` - -If the `bra run` command does not work, make sure that the bin directory in your Go workspace directory is in the path. $GOPATH/bin (or %GOPATH%\bin in Windows) is in your path. - ## Running Grafana Locally You can run a local instance of Grafana by running: -``` + +```bash ./bin/grafana-server ``` If you built the binary with `go run build.go build`, run `./bin/grafana-server` @@ -81,19 +71,24 @@ If you built it with `go build .`, run `./grafana` Open grafana in your browser (default [http://localhost:3000](http://localhost:3000)) and login with admin user (default user/pass = admin/admin). -## Developing for Grafana -To add features, customize your config, etc, you'll need to rebuild on source change. -``` +## Developing Grafana + +To add features, customize your config, etc, you'll need to rebuild the backend when you change the source code. We use a tool named `bra` that +does this. + +```bash go get github.com/Unknwon/bra bra run ``` -You'll also need to run `grunt watch` to watch for changes to the front-end. + +You'll also need to run `npm run watch` to watch for changes to the front-end (typescript, html, sass) ## Creating optimized release packages + This step builds linux packages and requires that fpm is installed. Install fpm via `gem install fpm`. -``` +```bash go run build.go build package ``` @@ -105,6 +100,10 @@ You only need to add the options you want to override. Config files are applied 1. grafana.ini 2. custom.ini +### Set app_mode to development + +In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode = development`. + Learn more about Grafana config options in the [Configuration section](/installation/configuration/) ## Create a pull requests @@ -119,7 +118,7 @@ Please contribute to the Grafana project and submit a pull request! Build new fe **Problem**: When running `bra run` for the first time you get an error that it is not a recognized command. -**Solution**: Add the bin directory in your Go workspace directory to the path. Per default this is `$HOME/go/bin` on Linux and `%USERPROFILE%\go\bin` on Windows or `$GOPATH/bin` (`%GOPATH%\bin` on Windows) if you have set your own workspace directory. +**Solution**: Add the bin directory in your Go workspace directory to the path. Per default this is `$HOME/go/bin` on Linux and `%USERPROFILE%\go\bin` on Windows or `$GOPATH/bin` (`%GOPATH%\bin` on Windows) if you have set your own workspace directory.

**Problem**: When executing a `go get` command on Windows and you get an error about the git repository not existing. diff --git a/docs/sources/reference/annotations.md b/docs/sources/reference/annotations.md index e8a15707bec..3852c2c727b 100644 --- a/docs/sources/reference/annotations.md +++ b/docs/sources/reference/annotations.md @@ -10,12 +10,45 @@ weight = 2 # Annotations +{{< docs-imagebox img="/img/docs/v46/annotations.png" max-width="800px" >}} + Annotations provide a way to mark points on the graph with rich events. When you hover over an annotation -you can get title, tags, and text information for the event. +you can get event description and event tags. The text field can include links to other systems with more detail. -![](/img/docs/annotations/toggles.png) +## Native annotations -## Queries +Grafana v4.6+ comes with a native annotation store and the ability to add annotation events directly from the graph panel or via the [HTTP API]({{< relref "http_api/annotations.md" >}}). + +## Adding annotations + +By holding down **CTRL** or **CMD** + Click. Add tags to the annotation will make it searchable from other dashboards. + +{{< docs-imagebox img="/img/docs/annotations/annotation-still.png" +max-width="600px" animated-gif="/img/docs/annotations/annotation.gif" >}} + +### Adding regions events + +You can also hold down **CTRL** or **CMD** and select region to create a region annotation. + +{{< docs-imagebox img="/img/docs/annotations/region-annotation-still.png" +max-width="600px" animated-gif="/img/docs/annotations/region-annotation.gif" >}} + +### Built in query + +After you added an annotation they will still be visible. This is due to the built in annotation query that exists on all dashboards. This annotation query will +fetch all annotation events that originate from the current dashboard and show them on the panel where they where created. This includes alert state history annotations. You can +stop annotations from being fetched & drawn by opening the **Annotations** settings (via Dashboard cogs menu) and modifying the query named `Annotations & Alerts (Built-in)`. + +When you copy a dashboard using the **Save As** feature it will get a new dashboard id so annotations created on source dashboard will no longer be visible on the copy. You +can still show them if you add a new **Annotation Query** and filter by tags. But this only works if the annotations on the source dashboard had tags to filter by. + +### 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. + +## Querying other data sources Annotation events are fetched via annotation queries. To add a new annotation query to a dashboard open the dashboard settings menu, then select `Annotations`. This will open the dashboard annotations diff --git a/docs/sources/reference/dashboard.md b/docs/sources/reference/dashboard.md index 3bbecfcef4d..d2da621a5a3 100644 --- a/docs/sources/reference/dashboard.md +++ b/docs/sources/reference/dashboard.md @@ -24,7 +24,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized > Note: In the following JSON, id is shown as null which is the default value assigned to it until a dashboard is saved. Once a dashboard is saved, an integer value is assigned to the `id` field. -``` +```json { "id": null, "title": "New dashboard", diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index 61ae1f761eb..badd3b5712a 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -43,7 +43,7 @@ You also get a link to service side rendered PNG of the panel. Useful if you wan Example of a link to a server-side rendered PNG: -``` +```bash http://play.grafana.org/render/dashboard-solo/db/grafana-play-home?orgId=1&panelId=4&from=1499272191563&to=1499279391563&width=1000&height=500&tz=UTC%2B02%3A00&timeout=5000 ``` diff --git a/docs/sources/tutorials/api_org_token_howto.md b/docs/sources/tutorials/api_org_token_howto.md index e985b499dbe..984cfd40bd0 100644 --- a/docs/sources/tutorials/api_org_token_howto.md +++ b/docs/sources/tutorials/api_org_token_howto.md @@ -22,24 +22,24 @@ Some parts of the API are only available through basic authentication and these The task is to create a new organization and then add a Token that can be used by other users. In the examples below which use basic auth, the user is `admin` and the password is `admin`. 1. [Create the org](http://docs.grafana.org/http_api/org/#create-organisation). Here is an example using curl: - ``` + ```bash curl -X POST -H "Content-Type: application/json" -d '{"name":"apiorg"}' http://admin:admin@localhost:3000/api/orgs ``` This should return a response: `{"message":"Organization created","orgId":6}`. Use the orgId for the next steps. 2. Optional step. If the org was created previously and/or step 3 fails then first [add your Admin user to the org](http://docs.grafana.org/http_api/org/#add-user-in-organisation): - ``` + ```bash curl -X POST -H "Content-Type: application/json" -d '{"loginOrEmail":"admin", "role": "Admin"}' http://admin:admin@localhost:3000/api/orgs//users ``` 3. [Switch the org context for the Admin user to the new org](http://docs.grafana.org/http_api/user/#switch-user-context): - ``` + ```bash curl -X POST http://admin:admin@localhost:3000/api/user/using/ ``` 4. [Create the API token](http://docs.grafana.org/http_api/auth/#create-api-key): - ``` + ```bash curl -X POST -H "Content-Type: application/json" -d '{"name":"apikeycurl", "role": "Admin"}' http://admin:admin@localhost:3000/api/auth/keys ``` @@ -49,11 +49,11 @@ The task is to create a new organization and then add a Token that can be used b ## How To Add A Dashboard -Using the Token that was created in the previous step, you can create a dashboard or carry out other actions without having to switch organizations. +Using the Token that was created in the previous step, you can create a dashboard or carry out other actions without having to switch organizations. 1. [Add a dashboard](http://docs.grafana.org/http_api/dashboard/#create-update-dashboard) using the key (or bearer token as it is also called): - ``` + ```bash curl -X POST --insecure -H "Authorization: Bearer eyJrIjoiR0ZXZmt1UFc0OEpIOGN5RWdUalBJTllUTk83VlhtVGwiLCJuIjoiYXBpa2V5Y3VybCIsImlkIjo2fQ==" -H "Content-Type: application/json" -d '{ "dashboard": { "id": null, diff --git a/docs/sources/tutorials/authproxy.md b/docs/sources/tutorials/authproxy.md new file mode 100644 index 00000000000..d4d2b9926fc --- /dev/null +++ b/docs/sources/tutorials/authproxy.md @@ -0,0 +1,243 @@ ++++ +title = "Grafana Authproxy" +type = "docs" +keywords = ["grafana", "tutorials", "authproxy"] +[menu.docs] +parent = "tutorials" +weight = 10 ++++ + +# Grafana Authproxy + +AuthProxy allows you to offload the authentication of users to a web server (there are many reasons why you’d want to run a web server in front of a production version of Grafana, especially if it’s exposed to the Internet). + +Popular web servers have a very extensive list of pluggable authentication modules, and any of them can be used with the AuthProxy feature. + +The Grafana AuthProxy feature is very simple in design, but it is this simplicity that makes it so powerful. + +## Interacting with Grafana’s AuthProxy via curl + +The AuthProxy feature can be configured through the Grafana configuration file with the following options: + +```js +[auth.proxy] +enabled = true +header_name = X-WEBAUTH-USER +header_property = username +auto_sign_up = true +``` + +* **enabled**: this is to toggle the feature on or off +* **header_name**: this is the HTTP header name that passes the username or email address of the authenticated user to Grafana. Grafana will trust what ever username is contained in this header and automatically log the user in. +* **header_property**: this tells Grafana whether the value in the header_name is a username or an email address. (In Grafana you can log in using your account username or account email) +* **auto_sign_up**: If set to true, Grafana will automatically create user accounts in the Grafana DB if one does not exist. If set to false, users who do not exist in the GrafanaDB won’t be able to log in, even though their username and password are valid. + +With a fresh install of Grafana, using the above configuration for the authProxy feature, we can send a simple API call to list all users. The only user that will be present is the default “Admin” user that is added the first time Grafana starts up. As you can see all we need to do to authenticate the request is to provide the “X-WEBAUTH-USER” header. + +```bash +curl -H "X-WEBAUTH-USER: admin" http://localhost:3000/api/users +[ + { + "id":1, + "name":"", + "login":"admin", + "email":"admin@localhost", + "isAdmin":true + } +] +``` + +We can then send a second request to the `/api/user` method which will return the details of the logged in user. We will use this request to show how Grafana automatically adds the new user we specify to the system. Here we create a new user called “anthony”. + +```bash +curl -H "X-WEBAUTH-USER: anthony" http://localhost:3000/api/user +{ + "email":"anthony", + "name":"", + "login":"anthony", + "theme":"", + "orgId":1, + "isGrafanaAdmin":false +} +``` + +## Making Apache’s auth work together with Grafana’s AuthProxy + +I’ll demonstrate how to use Apache for authenticating users. In this example we use BasicAuth with Apache’s text file based authentication handler, i.e. htpasswd files. However, any available Apache authentication capabilities could be used. + +### Apache BasicAuth + +In this example we use Apache as a reverseProxy in front of Grafana. Apache handles the Authentication of users before forwarding requests to the Grafana backend service. + +#### Apache configuration + +```bash + + ServerAdmin webmaster@authproxy + ServerName authproxy + ErrorLog "logs/authproxy-error_log" + CustomLog "logs/authproxy-access_log" common + + + AuthType Basic + AuthName GrafanaAuthProxy + AuthBasicProvider file + AuthUserFile /etc/apache2/grafana_htpasswd + Require valid-user + + RewriteEngine On + RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER},NS] + RequestHeader set X-WEBAUTH-USER "%{PROXY_USER}e" + + + RequestHeader unset Authorization + + ProxyRequests Off + ProxyPass / http://localhost:3000/ + ProxyPassReverse / http://localhost:3000/ + +``` + +* The first 4 lines of the virtualhost configuration are standard, so we won’t go into detail on what they do. + +* We use a **\** configuration block for applying our authentication rules to every proxied request. These rules include requiring basic authentication where user:password credentials are stored in the **/etc/apache2/grafana_htpasswd** file. This file can be created with the `htpasswd` command. + + * The next part of the configuration is the tricky part. We use Apache’s rewrite engine to create our **X-WEBAUTH-USER header**, populated with the authenticated user. + + * **RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER}, NS]**: This line is a little bit of magic. What it does, is for every request use the rewriteEngines look-ahead (LA-U) feature to determine what the REMOTE_USER variable would be set to after processing the request. Then assign the result to the variable PROXY_USER. This is neccessary as the REMOTE_USER variable is not available to the RequestHeader function. + + * **RequestHeader set X-WEBAUTH-USER “%{PROXY_USER}e”**: With the authenticated username now stored in the PROXY_USER variable, we create a new HTTP request header that will be sent to our backend Grafana containing the username. + +* The **RequestHeader unset Authorization** removes the Authorization header from the HTTP request before it is forwarded to Grafana. This ensures that Grafana does not try to authenticate the user using these credentials (BasicAuth is a supported authentication handler in Grafana). + +* The last 3 lines are then just standard reverse proxy configuration to direct all authenticated requests to our Grafana server running on port 3000. + +#### Grafana configuration + +```bash +############# Users ################ +[users] + # disable user signup / registration +allow_sign_up = false + +# Set to true to automatically assign new users to the default organization (id 1) +auto_assign_org = true + +# Default role new users will be automatically assigned (if auto_assign_org above is set to true) + auto_assign_org_role = Editor + + +############ Auth Proxy ######## +[auth.proxy] +enabled = true + +# the Header name that contains the authenticated user. +header_name = X-WEBAUTH-USER + +# does the user authenticate against the proxy using a 'username' or an 'email' +header_property = username + +# automatically add the user to the system if they don't already exist. +auto_sign_up = true +``` + +#### Full walk through using Docker. + +##### Grafana Container + +For this example, we use the offical Grafana docker image available at [Docker Hub](https://hub.docker.com/r/grafana/grafana/) + +* Create a file `grafana.ini` with the following contents + +```bash +[users] +allow_sign_up = false +auto_assign_org = true +auto_assign_org_role = Editor + +[auth.proxy] +enabled = true +header_name = X-WEBAUTH-USER +header_property = username +auto_sign_up = true +``` + +* Launch the Grafana container, using our custom grafana.ini to replace `/etc/grafana/grafana.ini`. We dont expose any ports for this container as it will only be connected to by our Apache container. + +```bash +docker run -i -v $(pwd)/grafana.ini:/etc/grafana/grafana.ini --name grafana grafana/grafana +``` + +### Apache Container + +For this example we use the offical Apache docker image available at [Docker Hub](https://hub.docker.com/_/httpd/) + +* Create a file `httpd.conf` with the following contents + +```bash +ServerRoot "/usr/local/apache2" +Listen 80 +LoadModule authn_file_module modules/mod_authn_file.so +LoadModule authn_core_module modules/mod_authn_core.so +LoadModule authz_host_module modules/mod_authz_host.so +LoadModule authz_user_module modules/mod_authz_user.so +LoadModule authz_core_module modules/mod_authz_core.so +LoadModule auth_basic_module modules/mod_auth_basic.so +LoadModule log_config_module modules/mod_log_config.so +LoadModule env_module modules/mod_env.so +LoadModule headers_module modules/mod_headers.so +LoadModule unixd_module modules/mod_unixd.so +LoadModule rewrite_module modules/mod_rewrite.so +LoadModule proxy_module modules/mod_proxy.so +LoadModule proxy_http_module modules/mod_proxy_http.so + +User daemon +Group daemon + +ServerAdmin you@example.com + + AllowOverride none + Require all denied + +DocumentRoot "/usr/local/apache2/htdocs" +ErrorLog /proc/self/fd/2 +LogLevel error + + LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined + LogFormat "%h %l %u %t \"%r\" %>s %b" common + + LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %I %O" combinedio + + CustomLog /proc/self/fd/1 common + + + AuthType Basic + AuthName GrafanaAuthProxy + AuthBasicProvider file + AuthUserFile /tmp/htpasswd + Require valid-user + RewriteEngine On + RewriteRule .* - [E=PROXY_USER:%{LA-U:REMOTE_USER},NS] + RequestHeader set X-WEBAUTH-USER "%{PROXY_USER}e" + +RequestHeader unset Authorization +ProxyRequests Off +ProxyPass / http://grafana:3000/ +ProxyPassReverse / http://grafana:3000/ +``` + +* Create a htpasswd file. We create a new user **anthony** with the password **password** + + ```bash + htpasswd -bc htpasswd anthony password + ``` + +* Launch the httpd container using our custom httpd.conf and our htpasswd file. The container will listen on port 80, and we create a link to the **grafana** container so that this container can resolve the hostname **grafana** to the grafana container’s ip address. + + ```bash + docker run -i -p 80:80 --link grafana:grafana -v $(pwd)/httpd.conf:/usr/local/apache2/conf/httpd.conf -v $(pwd)/htpasswd:/tmp/htpasswd httpd:2.4 + ``` + +### Use grafana. + +With our Grafana and Apache containers running, you can now connect to http://localhost/ and log in using the username/password we created in the htpasswd file. \ No newline at end of file diff --git a/docs/sources/tutorials/hubot_howto.md b/docs/sources/tutorials/hubot_howto.md index 58c902951ee..2f122e5b4e2 100644 --- a/docs/sources/tutorials/hubot_howto.md +++ b/docs/sources/tutorials/hubot_howto.md @@ -39,9 +39,9 @@ read the official [Getting Started With Hubot](https://hubot.github.com/docs/) g ## Install Hubot-Grafana script In your Hubot project repo install the Grafana plugin using `npm`: - - npm install hubot-grafana --save - +```bash +npm install hubot-grafana --save +``` Edit the file external-scripts.json, and add hubot-grafana to the list of plugins. ```json @@ -56,13 +56,15 @@ Edit the file external-scripts.json, and add hubot-grafana to the list of plugin The `hubot-grafana` plugin requires a number of environment variables to be set in order to work properly. - export HUBOT_GRAFANA_HOST=http://play.grafana.org - export HUBOT_GRAFANA_API_KEY=abcd01234deadbeef01234 - export HUBOT_GRAFANA_S3_BUCKET=mybucket - export HUBOT_GRAFANA_S3_ACCESS_KEY_ID=ABCDEF123456XYZ - export HUBOT_GRAFANA_S3_SECRET_ACCESS_KEY=aBcD01234dEaDbEef01234 - export HUBOT_GRAFANA_S3_PREFIX=graphs - export HUBOT_GRAFANA_S3_REGION=us-standard +```bash +export HUBOT_GRAFANA_HOST=http://play.grafana.org +export HUBOT_GRAFANA_API_KEY=abcd01234deadbeef01234 +export HUBOT_GRAFANA_S3_BUCKET=mybucket +export HUBOT_GRAFANA_S3_ACCESS_KEY_ID=ABCDEF123456XYZ +export HUBOT_GRAFANA_S3_SECRET_ACCESS_KEY=aBcD01234dEaDbEef01234 +export HUBOT_GRAFANA_S3_PREFIX=graphs +export HUBOT_GRAFANA_S3_REGION=us-standard +``` ### Grafana server side rendering @@ -112,7 +114,9 @@ can create hubot command aliases with the hubot script `hubot-alias`. Install it: - npm i --save hubot-alias +```bash +npm i --save hubot-alias +``` Now add `hubot-alias` to the list of plugins in `external-scripts.json` and restart hubot. diff --git a/karma.conf.js b/karma.conf.js index cdcea23a90b..3f006af08b6 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,23 +1,30 @@ +var webpack = require('webpack'); +var path = require('path'); +var webpackTestConfig = require('./scripts/webpack/webpack.test.js'); + module.exports = function(config) { + 'use strict'; config.set({ - basePath: __dirname + '/public_gen', - frameworks: ['mocha', 'expect', 'sinon'], // list of files / patterns to load in the browser files: [ - 'vendor/npm/es6-shim/es6-shim.js', - 'vendor/npm/systemjs/dist/system.src.js', - 'test/test-main.js', - - {pattern: '**/*.js', included: false}, + { pattern: 'public/test/index.ts', watched: false } ], + preprocessors: { + 'public/test/index.ts': ['webpack', 'sourcemap'], + }, + + webpack: webpackTestConfig, + webpackServer: { + noInfo: true, // please don't spam the console when running in karma! + }, + // list of files to exclude exclude: [], - reporters: ['dots'], port: 9876, colors: true, @@ -26,9 +33,8 @@ module.exports = function(config) { browsers: ['PhantomJS'], captureTimeout: 20000, singleRun: true, - autoWatchBatchDelay: 1000, - browserNoActivityTimeout: 60000, - + // autoWatchBatchDelay: 1000, + // browserNoActivityTimeout: 60000, }); }; diff --git a/latest.json b/latest.json index d7b498cb65e..fa72bc4fadf 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "4.5.1", - "testing": "4.5.1" + "stable": "4.5.2", + "testing": "4.5.2" } diff --git a/package.json b/package.json index 1c03aac7778..9ef0f2fb548 100644 --- a/package.json +++ b/package.json @@ -4,20 +4,35 @@ "company": "Grafana Labs" }, "name": "grafana", - "version": "4.6.0-pre1", + "version": "4.7.0-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" }, "devDependencies": { + "@types/d3": "^4.10.1", + "@types/enzyme": "^2.8.9", + "@types/node": "^8.0.31", "@types/react": "^16.0.5", "@types/react-dom": "^15.5.4", + "angular-mocks": "^1.6.6", "autoprefixer": "^6.4.0", + "awesome-typescript-loader": "^3.2.3", + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-preset-es2015": "^6.24.1", + "css-loader": "^0.28.7", + "enzyme": "^3.0.0", + "enzyme-adapter-react-16": "^1.0.0", "es6-promise": "^3.0.2", - "es6-shim": "^0.35.1", + "es6-shim": "^0.35.3", "expect.js": "~0.2.0", + "expose-loader": "^0.7.3", + "extract-text-webpack-plugin": "^3.0.0", + "file-loader": "^0.11.2", + "gaze": "^1.1.2", "glob": "~7.0.0", - "grunt": "^0.4.5", + "grunt": "1.0.1", "grunt-angular-templates": "^1.1.0", "grunt-cli": "~1.2.0", "grunt-contrib-clean": "~1.0.0", @@ -25,72 +40,90 @@ "grunt-contrib-concat": "^1.0.1", "grunt-contrib-copy": "~1.0.0", "grunt-contrib-cssmin": "~1.0.2", - "grunt-contrib-htmlmin": "~2.0.0", "grunt-contrib-jshint": "~1.1.0", - "grunt-contrib-uglify": "~2.0.0", - "grunt-contrib-watch": "^1.0.0", "grunt-exec": "^1.0.1", - "grunt-filerev": "^2.3.1", + "grunt-jscs": "3.0.1", "grunt-karma": "~2.0.0", - "grunt-ng-annotate": "^3.0.0", "grunt-notify": "^0.4.5", "grunt-postcss": "^0.8.0", "grunt-sass": "^2.0.0", - "grunt-string-replace": "~1.3.1", - "grunt-systemjs-builder": "^0.2.7", + "grunt-sass-lint": "^0.2.2", "grunt-usemin": "3.1.1", + "grunt-webpack": "^3.0.2", + "html-loader": "^0.5.1", + "html-webpack-plugin": "^2.30.1", + "husky": "^0.14.3", "jshint-stylish": "~2.2.1", - "karma": "1.3.0", - "karma-chrome-launcher": "~2.0.0", + "json-loader": "^0.5.7", + "karma": "1.7.0", + "karma-chrome-launcher": "~2.2.0", "karma-coverage": "1.1.1", "karma-expect": "~1.1.3", "karma-mocha": "~1.3.0", - "karma-phantomjs-launcher": "1.0.2", + "karma-phantomjs-launcher": "1.0.4", + "karma-sinon": "^1.0.5", + "karma-sourcemap-loader": "^0.3.7", + "karma-webpack": "^2.0.4", + "lint-staged": "^4.2.3", "load-grunt-tasks": "3.5.2", - "mocha": "3.2.0", - "phantomjs-prebuilt": "^2.1.14", - "reflect-metadata": "0.1.8", - "rxjs": "^5.4.3", + "mocha": "^4.0.1", + "ng-annotate-loader": "^0.6.1", + "ng-annotate-webpack-plugin": "^0.2.1-pre", + "ngtemplate-loader": "^2.0.1", + "npm": "^5.4.2", + "phantomjs-prebuilt": "^2.1.15", + "postcss-browser-reporter": "^0.5.0", + "postcss-loader": "^2.0.6", + "postcss-reporter": "^5.0.0", + "prettier": "1.7.3", + "react-test-renderer": "^16.0.0", "sass-lint": "^1.10.2", - "systemjs": "0.19.41", + "sass-loader": "^6.0.6", + "sinon": "1.17.6", + "systemjs": "0.20.19", + "systemjs-plugin-css": "^0.1.36", + "ts-loader": "^2.3.7", + "tslint": "^5.7.0", + "tslint-loader": "^3.5.3", + "typescript": "^2.5.2", + "webpack": "^3.6.0", + "webpack-bundle-analyzer": "^2.9.0", + "webpack-cleanup-plugin": "^0.5.1", + "webpack-merge": "^4.1.0", "zone.js": "^0.7.2" }, "scripts": { - "build": "./node_modules/grunt-cli/bin/grunt", - "test": "./node_modules/grunt-cli/bin/grunt test", - "dev": "./node_modules/grunt-cli/bin/grunt && ./node_modules/grunt-cli/bin/grunt watch" + "dev": "./node_modules/.bin/webpack --progress --colors --config scripts/webpack/webpack.dev.js", + "watch": "./node_modules/.bin/webpack --progress --colors --watch --config scripts/webpack/webpack.dev.js", + "build": "./node_modules/.bin/grunt build", + "test": "./node_modules/.bin/grunt test", + "lint": "./node_modules/.bin/tslint -c tslint.json --project tsconfig.json --type-check", + "watch-test": "./node_modules/grunt-cli/bin/grunt karma:dev" }, "license": "Apache-2.0", "dependencies": { - "@types/enzyme": "^2.8.8", - "ace-builds": "^1.2.8", "angular": "^1.6.6", "angular-bindonce": "^0.3.1", "angular-mocks": "^1.6.6", "angular-native-dragdrop": "^1.2.2", "angular-route": "^1.6.6", "angular-sanitize": "^1.6.6", + "babel-polyfill": "^6.26.0", + "brace": "^0.10.0", "clipboard": "^1.7.1", - "eventemitter3": "^2.0.2", - "gaze": "^1.1.2", - "grunt-jscs": "3.0.1", - "grunt-sass-lint": "^0.2.2", - "grunt-sync": "^0.6.2", + "eventemitter3": "^2.0.3", + "file-saver": "^1.3.3", "jquery": "^3.2.1", - "karma-sinon": "^1.0.5", "lodash": "^4.17.4", + "moment": "^2.18.1", "mousetrap": "^1.6.0", "ngreact": "^0.4.1", - "react": "^15.6.1", - "react-dom": "^15.6.1", - "react-test-renderer": "^15.6.1", + "react": "^16.0.0", + "react-dom": "^16.0.0", "remarkable": "^1.7.1", - "sinon": "1.17.6", - "systemjs-builder": "^0.15.34", + "rxjs": "^5.4.3", "tether": "^1.4.0", "tether-drop": "https://github.com/torkelo/drop", - "tslint": "^5.7.0", - "typescript": "^2.5.2", - "virtual-scroll": "^1.1.1" + "tinycolor2": "^1.4.1" } } diff --git a/packaging/publish/publish_both.sh b/packaging/publish/publish_both.sh index 38abc022e55..0a76851f6fa 100755 --- a/packaging/publish/publish_both.sh +++ b/packaging/publish/publish_both.sh @@ -1,5 +1,5 @@ #! /usr/bin/env bash -version=4.5.1 +version=4.5.2 wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb @@ -8,15 +8,15 @@ package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb -package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb -package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb +package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose +package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-${version}-1.x86_64.rpm -package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm -package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm +package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose +package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose -package_cloud push grafana/stable/el/7 grafana-${version}-1.x86_64.rpm -package_cloud push grafana/stable/el/6 grafana-${version}-1.x86_64.rpm +package_cloud push grafana/stable/el/7 grafana-${version}-1.x86_64.rpm --verbose +package_cloud push grafana/stable/el/6 grafana-${version}-1.x86_64.rpm --verbose rm grafana*.{deb,rpm} diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh index 17fabc5e136..276193ad63f 100755 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,10 +1,10 @@ #! /usr/bin/env bash -deb_ver=4.5.0-beta1 -rpm_ver=4.5.0-beta1 +deb_ver=4.6.0-beta1 +rpm_ver=4.6.0-beta1 -# wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb +wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${deb_ver}_amd64.deb -# package_cloud push grafana/testing/debian/jessie grafana_${deb_ver}_amd64.deb +package_cloud push grafana/testing/debian/jessie grafana_${deb_ver}_amd64.deb package_cloud push grafana/testing/debian/wheezy grafana_${deb_ver}_amd64.deb package_cloud push grafana/testing/debian/stretch grafana_${deb_ver}_amd64.deb diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index e07c77f1c1d..e6454e9cf86 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -1,7 +1,11 @@ package api import ( + "strings" + "time" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/services/annotations" ) @@ -11,13 +15,12 @@ func GetAnnotations(c *middleware.Context) Response { query := &annotations.ItemQuery{ From: c.QueryInt64("from") / 1000, To: c.QueryInt64("to") / 1000, - Type: annotations.ItemType(c.Query("type")), OrgId: c.OrgId, AlertId: c.QueryInt64("alertId"), DashboardId: c.QueryInt64("dashboardId"), PanelId: c.QueryInt64("panelId"), Limit: c.QueryInt64("limit"), - NewState: c.QueryStrings("newState"), + Tags: c.QueryStrings("tags"), } repo := annotations.GetRepository() @@ -27,40 +30,45 @@ func GetAnnotations(c *middleware.Context) Response { return ApiError(500, "Failed to get annotations", err) } - result := make([]dtos.Annotation, 0) - for _, item := range items { - result = append(result, dtos.Annotation{ - AlertId: item.AlertId, - Time: item.Epoch * 1000, - Data: item.Data, - NewState: item.NewState, - PrevState: item.PrevState, - Text: item.Text, - Metric: item.Metric, - Title: item.Title, - PanelId: item.PanelId, - RegionId: item.RegionId, - Type: string(item.Type), - }) + if item.Email != "" { + item.AvatarUrl = dtos.GetGravatarUrl(item.Email) + } + item.Time = item.Time * 1000 } - return Json(200, result) + return Json(200, items) +} + +type CreateAnnotationError struct { + message string +} + +func (e *CreateAnnotationError) Error() string { + return e.message } func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response { repo := annotations.GetRepository() + if cmd.Text == "" { + err := &CreateAnnotationError{"text field should not be empty"} + return ApiError(500, "Failed to save annotation", err) + } + item := annotations.Item{ OrgId: c.OrgId, + UserId: c.UserId, DashboardId: cmd.DashboardId, PanelId: cmd.PanelId, Epoch: cmd.Time / 1000, - Title: cmd.Title, Text: cmd.Text, - CategoryId: cmd.CategoryId, - NewState: cmd.FillColor, - Type: annotations.EventType, + Data: cmd.Data, + Tags: cmd.Tags, + } + + if item.Epoch == 0 { + item.Epoch = time.Now().Unix() } if err := repo.Save(&item); err != nil { @@ -71,12 +79,16 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response if cmd.IsRegion { item.RegionId = item.Id + if item.Data == nil { + item.Data = simplejson.New() + } + if err := repo.Update(&item); err != nil { return ApiError(500, "Failed set regionId on annotation", err) } item.Id = 0 - item.Epoch = cmd.TimeEnd + item.Epoch = cmd.TimeEnd / 1000 if err := repo.Save(&item); err != nil { return ApiError(500, "Failed save annotation for region end time", err) @@ -86,6 +98,100 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response return ApiSuccess("Annotation added") } +func formatGraphiteAnnotation(what string, data string) string { + text := what + if data != "" { + text = text + "\n" + data + } + return text +} + +func PostGraphiteAnnotation(c *middleware.Context, cmd dtos.PostGraphiteAnnotationsCmd) Response { + repo := annotations.GetRepository() + + if cmd.What == "" { + err := &CreateAnnotationError{"what field should not be empty"} + return ApiError(500, "Failed to save Graphite annotation", err) + } + + if cmd.When == 0 { + cmd.When = time.Now().Unix() + } + text := formatGraphiteAnnotation(cmd.What, cmd.Data) + + // Support tags in prior to Graphite 0.10.0 format (string of tags separated by space) + var tagsArray []string + switch tags := cmd.Tags.(type) { + case string: + if tags != "" { + tagsArray = strings.Split(tags, " ") + } else { + tagsArray = []string{} + } + case []interface{}: + for _, t := range tags { + if tagStr, ok := t.(string); ok { + tagsArray = append(tagsArray, tagStr) + } else { + err := &CreateAnnotationError{"tag should be a string"} + return ApiError(500, "Failed to save Graphite annotation", err) + } + } + default: + err := &CreateAnnotationError{"unsupported tags format"} + return ApiError(500, "Failed to save Graphite annotation", err) + } + + item := annotations.Item{ + OrgId: c.OrgId, + UserId: c.UserId, + Epoch: cmd.When, + Text: text, + Tags: tagsArray, + } + + if err := repo.Save(&item); err != nil { + return ApiError(500, "Failed to save Graphite annotation", err) + } + + return ApiSuccess("Graphite annotation added") +} + +func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Response { + annotationId := c.ParamsInt64(":annotationId") + + repo := annotations.GetRepository() + + item := annotations.Item{ + OrgId: c.OrgId, + UserId: c.UserId, + Id: annotationId, + Epoch: cmd.Time / 1000, + Text: cmd.Text, + Tags: cmd.Tags, + } + + if err := repo.Update(&item); err != nil { + return ApiError(500, "Failed to update annotation", err) + } + + if cmd.IsRegion { + itemRight := item + itemRight.RegionId = item.Id + itemRight.Epoch = cmd.TimeEnd / 1000 + + // We don't know id of region right event, so set it to 0 and find then using query like + // ... WHERE region_id = AND id != ... + itemRight.Id = 0 + + if err := repo.Update(&itemRight); err != nil { + return ApiError(500, "Failed to update annotation for region end time", err) + } + } + + return ApiSuccess("Annotation updated") +} + func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Response { repo := annotations.GetRepository() @@ -101,3 +207,33 @@ func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Res return ApiSuccess("Annotations deleted") } + +func DeleteAnnotationById(c *middleware.Context) Response { + repo := annotations.GetRepository() + annotationId := c.ParamsInt64(":annotationId") + + err := repo.Delete(&annotations.DeleteParams{ + Id: annotationId, + }) + + if err != nil { + return ApiError(500, "Failed to delete annotation", err) + } + + return ApiSuccess("Annotation deleted") +} + +func DeleteAnnotationRegion(c *middleware.Context) Response { + repo := annotations.GetRepository() + regionId := c.ParamsInt64(":regionId") + + err := repo.Delete(&annotations.DeleteParams{ + RegionId: regionId, + }) + + if err != nil { + return ApiError(500, "Failed to delete annotation region", err) + } + + return ApiSuccess("Annotation region deleted") +} diff --git a/pkg/api/api.go b/pkg/api/api.go index a979363d528..8c91120facc 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -289,6 +289,10 @@ func (hs *HttpServer) registerRoutes() { apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) { annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation)) + annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById)) + annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation)) + annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion)) + annotationsRoute.Post("/graphite", bind(dtos.PostGraphiteAnnotationsCmd{}), wrap(PostGraphiteAnnotation)) }, reqEditorRole) // error test diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 812ea667eee..0440c880979 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -6,27 +6,33 @@ import ( "net/http" "time" - "gopkg.in/macaron.v1" - "github.com/grafana/grafana/pkg/api/pluginproxy" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + macaron "gopkg.in/macaron.v1" ) -var pluginProxyTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).Dial, - TLSHandshakeTimeout: 10 * time.Second, -} +var pluginProxyTransport *http.Transport func InitAppPluginRoutes(r *macaron.Macaron) { + pluginProxyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: setting.PluginAppsSkipVerifyTLS, + Renegotiation: tls.RenegotiateFreelyAsClient, + }, + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, + } + for _, plugin := range plugins.Apps { for _, route := range plugin.Routes { url := util.JoinUrlFragments("/api/plugin-proxy/"+plugin.Id, route.Path) diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 7abb5da1cec..80280fd3cc9 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -65,7 +65,7 @@ func New(hash string) *Avatar { return &Avatar{ hash: hash, reqParams: url.Values{ - "d": {"404"}, + "d": {"retro"}, "size": {"200"}, "r": {"pg"}}.Encode(), } @@ -146,7 +146,7 @@ func CacheServer() http.Handler { } func newNotFound() *Avatar { - avatar := &Avatar{} + avatar := &Avatar{notFound: true} // load transparent png into buffer path := filepath.Join(setting.StaticRootPath, "img", "transparent.png") diff --git a/pkg/api/cloudwatch/cloudwatch.go b/pkg/api/cloudwatch/cloudwatch.go deleted file mode 100644 index e0076db40c5..00000000000 --- a/pkg/api/cloudwatch/cloudwatch.go +++ /dev/null @@ -1,516 +0,0 @@ -package cloudwatch - -import ( - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "os" - "strings" - "sync" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awsutil" - "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/ec2metadata" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/aws/aws-sdk-go/service/ec2" - "github.com/aws/aws-sdk-go/service/sts" - "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" - m "github.com/grafana/grafana/pkg/models" -) - -type actionHandler func(*cwRequest, *middleware.Context) - -var actionHandlers map[string]actionHandler - -type cwRequest struct { - Region string `json:"region"` - Action string `json:"action"` - Body []byte `json:"-"` - DataSource *m.DataSource -} - -type datasourceInfo struct { - Profile string - Region string - AuthType string - AssumeRoleArn string - Namespace string - - AccessKey string - SecretKey string -} - -func (req *cwRequest) GetDatasourceInfo() *datasourceInfo { - authType := req.DataSource.JsonData.Get("authType").MustString() - assumeRoleArn := req.DataSource.JsonData.Get("assumeRoleArn").MustString() - accessKey := "" - secretKey := "" - - for key, value := range req.DataSource.SecureJsonData.Decrypt() { - if key == "accessKey" { - accessKey = value - } - if key == "secretKey" { - secretKey = value - } - } - - return &datasourceInfo{ - AuthType: authType, - AssumeRoleArn: assumeRoleArn, - Region: req.Region, - Profile: req.DataSource.Database, - AccessKey: accessKey, - SecretKey: secretKey, - } -} - -func init() { - actionHandlers = map[string]actionHandler{ - "GetMetricStatistics": handleGetMetricStatistics, - "ListMetrics": handleListMetrics, - "DescribeAlarms": handleDescribeAlarms, - "DescribeAlarmsForMetric": handleDescribeAlarmsForMetric, - "DescribeAlarmHistory": handleDescribeAlarmHistory, - "DescribeInstances": handleDescribeInstances, - "__GetRegions": handleGetRegions, - "__GetNamespaces": handleGetNamespaces, - "__GetMetrics": handleGetMetrics, - "__GetDimensions": handleGetDimensions, - } -} - -type cache struct { - credential *credentials.Credentials - expiration *time.Time -} - -var awsCredentialCache map[string]cache = make(map[string]cache) -var credentialCacheLock sync.RWMutex - -func getCredentials(dsInfo *datasourceInfo) (*credentials.Credentials, error) { - cacheKey := dsInfo.Profile + ":" + dsInfo.AssumeRoleArn - credentialCacheLock.RLock() - if _, ok := awsCredentialCache[cacheKey]; ok { - if awsCredentialCache[cacheKey].expiration != nil && - (*awsCredentialCache[cacheKey].expiration).After(time.Now().UTC()) { - result := awsCredentialCache[cacheKey].credential - credentialCacheLock.RUnlock() - return result, nil - } - } - credentialCacheLock.RUnlock() - - accessKeyId := "" - secretAccessKey := "" - sessionToken := "" - var expiration *time.Time - expiration = nil - if dsInfo.AuthType == "arn" && strings.Index(dsInfo.AssumeRoleArn, "arn:aws:iam:") == 0 { - params := &sts.AssumeRoleInput{ - RoleArn: aws.String(dsInfo.AssumeRoleArn), - RoleSessionName: aws.String("GrafanaSession"), - DurationSeconds: aws.Int64(900), - } - - stsSess, err := session.NewSession() - if err != nil { - return nil, err - } - stsCreds := credentials.NewChainCredentials( - []credentials.Provider{ - &credentials.EnvProvider{}, - &credentials.SharedCredentialsProvider{Filename: "", Profile: dsInfo.Profile}, - remoteCredProvider(stsSess), - }) - stsConfig := &aws.Config{ - Region: aws.String(dsInfo.Region), - Credentials: stsCreds, - } - - sess, err := session.NewSession(stsConfig) - if err != nil { - return nil, err - } - svc := sts.New(sess, stsConfig) - resp, err := svc.AssumeRole(params) - if err != nil { - return nil, err - } - if resp.Credentials != nil { - accessKeyId = *resp.Credentials.AccessKeyId - secretAccessKey = *resp.Credentials.SecretAccessKey - sessionToken = *resp.Credentials.SessionToken - expiration = resp.Credentials.Expiration - } - } - - sess, err := session.NewSession() - if err != nil { - return nil, err - } - creds := credentials.NewChainCredentials( - []credentials.Provider{ - &credentials.StaticProvider{Value: credentials.Value{ - AccessKeyID: accessKeyId, - SecretAccessKey: secretAccessKey, - SessionToken: sessionToken, - }}, - &credentials.EnvProvider{}, - &credentials.StaticProvider{Value: credentials.Value{ - AccessKeyID: dsInfo.AccessKey, - SecretAccessKey: dsInfo.SecretKey, - }}, - &credentials.SharedCredentialsProvider{Filename: "", Profile: dsInfo.Profile}, - remoteCredProvider(sess), - }) - - credentialCacheLock.Lock() - awsCredentialCache[cacheKey] = cache{ - credential: creds, - expiration: expiration, - } - credentialCacheLock.Unlock() - - return creds, 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` - - c := ec2metadata.New(sess) - return endpointcreds.NewProviderClient( - c.Client.Config, - c.Client.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} -} - -func getAwsConfig(req *cwRequest) (*aws.Config, error) { - creds, err := getCredentials(req.GetDatasourceInfo()) - if err != nil { - return nil, err - } - - cfg := &aws.Config{ - Region: aws.String(req.Region), - Credentials: creds, - } - return cfg, nil -} - -func handleGetMetricStatistics(req *cwRequest, c *middleware.Context) { - cfg, err := getAwsConfig(req) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - sess, err := session.NewSession(cfg) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - svc := cloudwatch.New(sess, cfg) - - reqParam := &struct { - Parameters struct { - Namespace string `json:"namespace"` - MetricName string `json:"metricName"` - Dimensions []*cloudwatch.Dimension `json:"dimensions"` - Statistics []*string `json:"statistics"` - ExtendedStatistics []*string `json:"extendedStatistics"` - StartTime int64 `json:"startTime"` - EndTime int64 `json:"endTime"` - Period int64 `json:"period"` - } `json:"parameters"` - }{} - json.Unmarshal(req.Body, reqParam) - - params := &cloudwatch.GetMetricStatisticsInput{ - Namespace: aws.String(reqParam.Parameters.Namespace), - MetricName: aws.String(reqParam.Parameters.MetricName), - Dimensions: reqParam.Parameters.Dimensions, - StartTime: aws.Time(time.Unix(reqParam.Parameters.StartTime, 0)), - EndTime: aws.Time(time.Unix(reqParam.Parameters.EndTime, 0)), - Period: aws.Int64(reqParam.Parameters.Period), - } - if len(reqParam.Parameters.Statistics) != 0 { - params.Statistics = reqParam.Parameters.Statistics - } - if len(reqParam.Parameters.ExtendedStatistics) != 0 { - params.ExtendedStatistics = reqParam.Parameters.ExtendedStatistics - } - - resp, err := svc.GetMetricStatistics(params) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - metrics.M_Aws_CloudWatch_GetMetricStatistics.Inc() - - c.JSON(200, resp) -} - -func handleListMetrics(req *cwRequest, c *middleware.Context) { - cfg, err := getAwsConfig(req) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - sess, err := session.NewSession(cfg) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - svc := cloudwatch.New(sess, cfg) - - reqParam := &struct { - Parameters struct { - Namespace string `json:"namespace"` - MetricName string `json:"metricName"` - Dimensions []*cloudwatch.DimensionFilter `json:"dimensions"` - } `json:"parameters"` - }{} - json.Unmarshal(req.Body, reqParam) - - params := &cloudwatch.ListMetricsInput{ - Namespace: aws.String(reqParam.Parameters.Namespace), - MetricName: aws.String(reqParam.Parameters.MetricName), - Dimensions: reqParam.Parameters.Dimensions, - } - - var resp cloudwatch.ListMetricsOutput - err = svc.ListMetricsPages(params, - func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { - metrics.M_Aws_CloudWatch_ListMetrics.Inc() - metrics, _ := awsutil.ValuesAtPath(page, "Metrics") - for _, metric := range metrics { - resp.Metrics = append(resp.Metrics, metric.(*cloudwatch.Metric)) - } - return !lastPage - }) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - - c.JSON(200, resp) -} - -func handleDescribeAlarms(req *cwRequest, c *middleware.Context) { - cfg, err := getAwsConfig(req) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - sess, err := session.NewSession(cfg) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - svc := cloudwatch.New(sess, cfg) - - reqParam := &struct { - Parameters struct { - ActionPrefix string `json:"actionPrefix"` - AlarmNamePrefix string `json:"alarmNamePrefix"` - AlarmNames []*string `json:"alarmNames"` - StateValue string `json:"stateValue"` - } `json:"parameters"` - }{} - json.Unmarshal(req.Body, reqParam) - - params := &cloudwatch.DescribeAlarmsInput{ - MaxRecords: aws.Int64(100), - } - if reqParam.Parameters.ActionPrefix != "" { - params.ActionPrefix = aws.String(reqParam.Parameters.ActionPrefix) - } - if reqParam.Parameters.AlarmNamePrefix != "" { - params.AlarmNamePrefix = aws.String(reqParam.Parameters.AlarmNamePrefix) - } - if len(reqParam.Parameters.AlarmNames) != 0 { - params.AlarmNames = reqParam.Parameters.AlarmNames - } - if reqParam.Parameters.StateValue != "" { - params.StateValue = aws.String(reqParam.Parameters.StateValue) - } - - resp, err := svc.DescribeAlarms(params) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - - c.JSON(200, resp) -} - -func handleDescribeAlarmsForMetric(req *cwRequest, c *middleware.Context) { - cfg, err := getAwsConfig(req) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - sess, err := session.NewSession(cfg) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - svc := cloudwatch.New(sess, cfg) - - reqParam := &struct { - Parameters struct { - Namespace string `json:"namespace"` - MetricName string `json:"metricName"` - Dimensions []*cloudwatch.Dimension `json:"dimensions"` - Statistic string `json:"statistic"` - ExtendedStatistic string `json:"extendedStatistic"` - Period int64 `json:"period"` - } `json:"parameters"` - }{} - json.Unmarshal(req.Body, reqParam) - - params := &cloudwatch.DescribeAlarmsForMetricInput{ - Namespace: aws.String(reqParam.Parameters.Namespace), - MetricName: aws.String(reqParam.Parameters.MetricName), - Period: aws.Int64(reqParam.Parameters.Period), - } - if len(reqParam.Parameters.Dimensions) != 0 { - params.Dimensions = reqParam.Parameters.Dimensions - } - if reqParam.Parameters.Statistic != "" { - params.Statistic = aws.String(reqParam.Parameters.Statistic) - } - if reqParam.Parameters.ExtendedStatistic != "" { - params.ExtendedStatistic = aws.String(reqParam.Parameters.ExtendedStatistic) - } - - resp, err := svc.DescribeAlarmsForMetric(params) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - - c.JSON(200, resp) -} - -func handleDescribeAlarmHistory(req *cwRequest, c *middleware.Context) { - cfg, err := getAwsConfig(req) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - sess, err := session.NewSession(cfg) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - svc := cloudwatch.New(sess, cfg) - - reqParam := &struct { - Parameters struct { - AlarmName string `json:"alarmName"` - HistoryItemType string `json:"historyItemType"` - StartDate int64 `json:"startDate"` - EndDate int64 `json:"endDate"` - } `json:"parameters"` - }{} - json.Unmarshal(req.Body, reqParam) - - params := &cloudwatch.DescribeAlarmHistoryInput{ - AlarmName: aws.String(reqParam.Parameters.AlarmName), - StartDate: aws.Time(time.Unix(reqParam.Parameters.StartDate, 0)), - EndDate: aws.Time(time.Unix(reqParam.Parameters.EndDate, 0)), - } - if reqParam.Parameters.HistoryItemType != "" { - params.HistoryItemType = aws.String(reqParam.Parameters.HistoryItemType) - } - - resp, err := svc.DescribeAlarmHistory(params) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - - c.JSON(200, resp) -} - -func handleDescribeInstances(req *cwRequest, c *middleware.Context) { - cfg, err := getAwsConfig(req) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - sess, err := session.NewSession(cfg) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - svc := ec2.New(sess, cfg) - - reqParam := &struct { - Parameters struct { - Filters []*ec2.Filter `json:"filters"` - InstanceIds []*string `json:"instanceIds"` - } `json:"parameters"` - }{} - json.Unmarshal(req.Body, reqParam) - - params := &ec2.DescribeInstancesInput{} - if len(reqParam.Parameters.Filters) > 0 { - params.Filters = reqParam.Parameters.Filters - } - if len(reqParam.Parameters.InstanceIds) > 0 { - params.InstanceIds = reqParam.Parameters.InstanceIds - } - - var resp ec2.DescribeInstancesOutput - err = svc.DescribeInstancesPages(params, - func(page *ec2.DescribeInstancesOutput, lastPage bool) bool { - reservations, _ := awsutil.ValuesAtPath(page, "Reservations") - for _, reservation := range reservations { - resp.Reservations = append(resp.Reservations, reservation.(*ec2.Reservation)) - } - return !lastPage - }) - if err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return - } - - c.JSON(200, resp) -} - -func HandleRequest(c *middleware.Context, ds *m.DataSource) { - var req cwRequest - req.Body, _ = ioutil.ReadAll(c.Req.Request.Body) - req.DataSource = ds - json.Unmarshal(req.Body, &req) - - if handler, found := actionHandlers[req.Action]; !found { - c.JsonApiErr(500, "Unexpected AWS Action", errors.New(req.Action)) - return - } else { - handler(&req, c) - } -} diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go index 958fdff89ca..c917b0d9feb 100644 --- a/pkg/api/dtos/annotations.go +++ b/pkg/api/dtos/annotations.go @@ -2,37 +2,37 @@ package dtos import "github.com/grafana/grafana/pkg/components/simplejson" -type Annotation struct { - AlertId int64 `json:"alertId"` - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - NewState string `json:"newState"` - PrevState string `json:"prevState"` - Time int64 `json:"time"` - Title string `json:"title"` - Text string `json:"text"` - Metric string `json:"metric"` - RegionId int64 `json:"regionId"` - Type string `json:"type"` - - Data *simplejson.Json `json:"data"` +type PostAnnotationsCmd struct { + DashboardId int64 `json:"dashboardId"` + PanelId int64 `json:"panelId"` + Time int64 `json:"time"` + Text string `json:"text"` + Tags []string `json:"tags"` + Data *simplejson.Json `json:"data"` + IsRegion bool `json:"isRegion"` + TimeEnd int64 `json:"timeEnd"` } -type PostAnnotationsCmd struct { - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - CategoryId int64 `json:"categoryId"` - Time int64 `json:"time"` - Title string `json:"title"` - Text string `json:"text"` - - FillColor string `json:"fillColor"` - IsRegion bool `json:"isRegion"` - TimeEnd int64 `json:"timeEnd"` +type UpdateAnnotationsCmd struct { + Id int64 `json:"id"` + Time int64 `json:"time"` + Text string `json:"text"` + Tags []string `json:"tags"` + IsRegion bool `json:"isRegion"` + TimeEnd int64 `json:"timeEnd"` } type DeleteAnnotationsCmd struct { - AlertId int64 `json:"alertId"` - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` + AlertId int64 `json:"alertId"` + DashboardId int64 `json:"dashboardId"` + PanelId int64 `json:"panelId"` + AnnotationId int64 `json:"annotationId"` + RegionId int64 `json:"regionId"` +} + +type PostGraphiteAnnotationsCmd struct { + When int64 `json:"when"` + What string `json:"what"` + Data string `json:"data"` + Tags interface{} `json:"tags"` } diff --git a/pkg/api/grafana_com_proxy.go b/pkg/api/grafana_com_proxy.go index 5db508f4e11..a2a446b48eb 100644 --- a/pkg/api/grafana_com_proxy.go +++ b/pkg/api/grafana_com_proxy.go @@ -1,7 +1,6 @@ package api import ( - "crypto/tls" "net" "net/http" "net/http/httputil" @@ -14,11 +13,11 @@ import ( ) var grafanaComProxyTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, - Proxy: http.ProxyFromEnvironment, + Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, + DualStack: true, }).Dial, TLSHandshakeTimeout: 10 * time.Second, } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index b43d55b2a8f..3107dcf7e4b 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -11,6 +11,8 @@ import ( "path" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" gocache "github.com/patrickmn/go-cache" @@ -19,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/api/live" httpstatic "github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" @@ -153,7 +154,7 @@ func (hs *HttpServer) newMacaron() *macaron.Macaron { for _, route := range plugins.StaticRoutes { pluginRoute := path.Join("/public/plugins/", route.PluginId) - logger.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory) + hs.log.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory) hs.mapStatic(m, route.Directory, "", pluginRoute) } @@ -187,7 +188,9 @@ func (hs *HttpServer) metricsEndpoint(ctx *macaron.Context) { return } - promhttp.Handler().ServeHTTP(ctx.Resp, ctx.Req.Request) + promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{ + DisableCompression: true, + }).ServeHTTP(ctx.Resp, ctx.Req.Request) } func (hs *HttpServer) healthHandler(ctx *macaron.Context) { diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 4be49915fd9..847f09f0eb8 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io/ioutil" - "log" "net/http" "net/url" @@ -16,6 +15,7 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -29,6 +29,7 @@ var ( ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") ErrUsersQuotaReached = errors.New("Users quota reached") ErrNoEmail = errors.New("Login provider didn't return an email address") + oauthLogger = log.New("oauth.login") ) func GenStateString() string { @@ -50,10 +51,11 @@ func OAuthLogin(ctx *middleware.Context) { return } - error := ctx.Query("error") - if error != "" { + errorParam := ctx.Query("error") + if errorParam != "" { errorDesc := ctx.Query("error_description") - redirectWithError(ctx, ErrProviderDeniedRequest, "error", error, "errorDesc", errorDesc) + oauthLogger.Error("failed to login ", "error", errorParam, "errorDesc", errorDesc) + redirectWithError(ctx, ErrProviderDeniedRequest, "error", errorParam, "errorDesc", errorDesc) return } @@ -69,8 +71,12 @@ func OAuthLogin(ctx *middleware.Context) { return } - // verify state string - savedState := ctx.Session.Get(middleware.SESS_KEY_OAUTH_STATE).(string) + savedState, ok := ctx.Session.Get(middleware.SESS_KEY_OAUTH_STATE).(string) + if !ok { + ctx.Handle(500, "login.OAuthLogin(missing saved state)", nil) + return + } + queryState := ctx.Query("state") if savedState != queryState { ctx.Handle(500, "login.OAuthLogin(state mismatch)", nil) @@ -78,36 +84,37 @@ func OAuthLogin(ctx *middleware.Context) { } // handle call back + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: setting.OAuthService.OAuthInfos[name].TlsSkipVerify, + }, + } + oauthClient := &http.Client{ + Transport: tr, + } - // initialize oauth2 context - oauthCtx := oauth2.NoContext - if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" { + if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" || setting.OAuthService.OAuthInfos[name].TlsClientKey != "" { cert, err := tls.LoadX509KeyPair(setting.OAuthService.OAuthInfos[name].TlsClientCert, setting.OAuthService.OAuthInfos[name].TlsClientKey) if err != nil { - log.Fatal(err) + log.Fatal(1, "Failed to setup TlsClientCert", "oauth provider", name, "error", err) } - // Load CA cert + tr.TLSClientConfig.Certificates = append(tr.TLSClientConfig.Certificates, cert) + } + + if setting.OAuthService.OAuthInfos[name].TlsClientCa != "" { caCert, err := ioutil.ReadFile(setting.OAuthService.OAuthInfos[name].TlsClientCa) if err != nil { - log.Fatal(err) + log.Fatal(1, "Failed to setup TlsClientCa", "oauth provider", name, "error", err) } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) - tr := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - Certificates: []tls.Certificate{cert}, - RootCAs: caCertPool, - }, - } - sslcli := &http.Client{Transport: tr} - - oauthCtx = context.Background() - oauthCtx = context.WithValue(oauthCtx, oauth2.HTTPClient, sslcli) + tr.TLSClientConfig.RootCAs = caCertPool } + oauthCtx := context.WithValue(context.Background(), oauth2.HTTPClient, oauthClient) + // get token from provider token, err := connect.Exchange(oauthCtx, code) if err != nil { diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 10fafe21fef..faac8c03c62 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -17,7 +17,6 @@ import ( "github.com/opentracing/opentracing-go" - "github.com/grafana/grafana/pkg/api/cloudwatch" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -63,11 +62,6 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx } func (proxy *DataSourceProxy) HandleRequest() { - if proxy.ds.Type == m.DS_CLOUDWATCH { - cloudwatch.HandleRequest(proxy.ctx, proxy.ds) - return - } - if err := proxy.validateRequest(); err != nil { proxy.ctx.JsonApiErr(403, err.Error(), nil) return diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 042c03f9832..0483b624a30 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -158,7 +158,9 @@ func GetPluginMarkdown(c *middleware.Context) Response { return ApiError(500, "Could not get markdown file", err) } else { - return Respond(200, content) + resp := Respond(200, content) + resp.Header("Content-Type", "text/plain; charset=utf-8") + return resp } } diff --git a/pkg/cmd/grafana-cli/main.go b/pkg/cmd/grafana-cli/main.go index 73548c3b159..86eb6bc271a 100644 --- a/pkg/cmd/grafana-cli/main.go +++ b/pkg/cmd/grafana-cli/main.go @@ -17,8 +17,6 @@ var version = "master" func main() { setupLogging() - services.Init(version) - app := cli.NewApp() app.Name = "Grafana cli" app.Usage = "" @@ -44,12 +42,20 @@ func main() { Value: "", EnvVar: "GF_PLUGIN_URL", }, + cli.BoolFlag{ + Name: "insecure", + Usage: "Skip TLS verification (insecure)", + }, cli.BoolFlag{ Name: "debug, d", Usage: "enable debug logging", }, } + app.Before = func(c *cli.Context) error { + services.Init(version, c.GlobalBool("insecure")) + return nil + } app.Commands = commands.Commands app.CommandNotFound = cmdNotFound diff --git a/pkg/cmd/grafana-cli/services/services.go b/pkg/cmd/grafana-cli/services/services.go index 6c739f8b0ee..d13e90d6a2f 100644 --- a/pkg/cmd/grafana-cli/services/services.go +++ b/pkg/cmd/grafana-cli/services/services.go @@ -22,7 +22,7 @@ var ( grafanaVersion string ) -func Init(version string) { +func Init(version string, skipTLSVerify bool) { grafanaVersion = version tr := &http.Transport{ @@ -30,13 +30,15 @@ func Init(version string) { DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, + DualStack: true, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, - - TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: skipTLSVerify, + }, } HttpClient = http.Client{ diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 96fb3f2e9a1..fa63f05efba 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -14,22 +14,24 @@ import ( "net/http" _ "net/http/pprof" + "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" _ "github.com/grafana/grafana/pkg/services/alerting/conditions" _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" + _ "github.com/grafana/grafana/pkg/tsdb/cloudwatch" _ "github.com/grafana/grafana/pkg/tsdb/graphite" _ "github.com/grafana/grafana/pkg/tsdb/influxdb" _ "github.com/grafana/grafana/pkg/tsdb/mysql" _ "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/testdata" ) -var version = "4.1.0" +var version = "4.6.0" var commit = "NA" var buildstamp string var build_date string @@ -80,6 +82,8 @@ func main() { setting.BuildCommit = commit setting.BuildStamp = buildstampInt64 + metrics.M_Grafana_Version.WithLabelValues(version).Set(1) + server := NewGrafanaServer() server.Start() } diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index 32058e6cc2d..728614735d0 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -28,15 +28,27 @@ func NewImageUploader() (ImageUploader, error) { return nil, err } + bucket := s3sec.Key("bucket").MustString("") + region := s3sec.Key("region").MustString("") + path := s3sec.Key("path").MustString("") bucketUrl := s3sec.Key("bucket_url").MustString("") accessKey := s3sec.Key("access_key").MustString("") secretKey := s3sec.Key("secret_key").MustString("") - info, err := getRegionAndBucketFromUrl(bucketUrl) - if err != nil { - return nil, err + + if path != "" && path[len(path)-1:] != "/" { + path += "/" } - return NewS3Uploader(info.region, info.bucket, "public-read", accessKey, secretKey), nil + if bucket == "" || region == "" { + info, err := getRegionAndBucketFromUrl(bucketUrl) + if err != nil { + return nil, err + } + bucket = info.bucket + region = info.region + } + + return NewS3Uploader(region, bucket, path, "public-read", accessKey, secretKey), nil case "webdav": webdavSec, err := setting.Cfg.GetSection("external_image_storage.webdav") if err != nil { diff --git a/pkg/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go index a8694c0389c..62196357c61 100644 --- a/pkg/components/imguploader/s3uploader.go +++ b/pkg/components/imguploader/s3uploader.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" "github.com/aws/aws-sdk-go/aws/ec2metadata" + "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "github.com/grafana/grafana/pkg/log" @@ -18,16 +19,18 @@ import ( type S3Uploader struct { region string bucket string + path string acl string secretKey string accessKey string log log.Logger } -func NewS3Uploader(region, bucket, acl, accessKey, secretKey string) *S3Uploader { +func NewS3Uploader(region, bucket, path, acl, accessKey, secretKey string) *S3Uploader { return &S3Uploader{ region: region, bucket: bucket, + path: path, acl: acl, accessKey: accessKey, secretKey: secretKey, @@ -54,8 +57,10 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string, Credentials: creds, } - key := util.GetRandomString(20) + ".png" - log.Debug("Uploading image to s3", "bucket = ", u.bucket, ", key = ", key) + s3_endpoint, _ := endpoints.DefaultResolver().EndpointFor("s3", u.region) + key := u.path + util.GetRandomString(20) + ".png" + image_url := s3_endpoint.URL + "/" + u.bucket + "/" + key + log.Debug("Uploading image to s3", "url = ", image_url) file, err := os.Open(imageDiskPath) if err != nil { @@ -78,10 +83,5 @@ func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string, if err != nil { return "", err } - - if u.region == "us-east-1" { - return "https://" + u.bucket + ".s3.amazonaws.com/" + key, nil - } else { - return "https://" + u.bucket + ".s3-" + u.region + ".amazonaws.com/" + key, nil - } + return image_url, nil } diff --git a/pkg/components/imguploader/webdavuploader.go b/pkg/components/imguploader/webdavuploader.go index 01de7119756..53d75247c76 100644 --- a/pkg/components/imguploader/webdavuploader.go +++ b/pkg/components/imguploader/webdavuploader.go @@ -24,7 +24,8 @@ type WebdavUploader struct { var netTransport = &http.Transport{ Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ - Timeout: 60 * time.Second, + Timeout: 60 * time.Second, + DualStack: true, }).Dial, TLSHandshakeTimeout: 5 * time.Second, } diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index fef1fe26f6c..4b155ae3208 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -56,6 +56,7 @@ var ( M_StatTotal_Users prometheus.Gauge M_StatTotal_Orgs prometheus.Gauge M_StatTotal_Playlists prometheus.Gauge + M_Grafana_Version *prometheus.GaugeVec ) func init() { @@ -263,6 +264,13 @@ func init() { Help: "total amount of playlists", Namespace: exporterName, }) + + M_Grafana_Version = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "info", + Help: "Information about the Grafana", + Namespace: exporterName, + }, []string{"version"}) + } func initMetricVars(settings *MetricSettings) { @@ -298,7 +306,8 @@ func initMetricVars(settings *MetricSettings) { M_StatTotal_Dashboards, M_StatTotal_Users, M_StatTotal_Orgs, - M_StatTotal_Playlists) + M_StatTotal_Playlists, + M_Grafana_Version) go instrumentationLoop(settings) } diff --git a/pkg/models/dashboard_snapshot.go b/pkg/models/dashboard_snapshot.go index 57c5524ace8..9273b88f291 100644 --- a/pkg/models/dashboard_snapshot.go +++ b/pkg/models/dashboard_snapshot.go @@ -73,11 +73,12 @@ type GetDashboardSnapshotQuery struct { } type DashboardSnapshots []*DashboardSnapshot +type DashboardSnapshotsList []*DashboardSnapshotDTO type GetDashboardSnapshotsQuery struct { Name string Limit int OrgId int64 - Result DashboardSnapshots + Result DashboardSnapshotsList } diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 4f10033425f..069900cc091 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -17,6 +17,7 @@ const ( DS_CLOUDWATCH = "cloudwatch" DS_KAIROSDB = "kairosdb" DS_PROMETHEUS = "prometheus" + DS_POSTGRES = "postgres" DS_ACCESS_DIRECT = "direct" DS_ACCESS_PROXY = "proxy" ) @@ -62,6 +63,7 @@ var knownDatasourcePlugins map[string]bool = map[string]bool{ DS_CLOUDWATCH: true, DS_PROMETHEUS: true, DS_OPENTSDB: true, + DS_POSTGRES: true, "opennms": true, "druid": true, "dalmatinerdb": true, diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go index e32c0ac9e7c..b4a4e7f8a4d 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -3,6 +3,7 @@ package models import ( "crypto/tls" "crypto/x509" + "errors" "net" "net/http" "sync" @@ -45,14 +46,23 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { return t.Transport, nil } + var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool + if ds.JsonData != nil { + tlsClientAuth = ds.JsonData.Get("tlsAuth").MustBool(false) + tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false) + tlsSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false) + } + transport := &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, + InsecureSkipVerify: tlsSkipVerify, + Renegotiation: tls.RenegotiateFreelyAsClient, }, Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, + DualStack: true, }).Dial, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, @@ -60,30 +70,24 @@ func (ds *DataSource) GetHttpTransport() (*http.Transport, error) { IdleConnTimeout: 90 * time.Second, } - var tlsAuth, tlsAuthWithCACert bool - if ds.JsonData != nil { - tlsAuth = ds.JsonData.Get("tlsAuth").MustBool(false) - tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false) - } - - if tlsAuth { - transport.TLSClientConfig.InsecureSkipVerify = false - + if tlsClientAuth || tlsAuthWithCACert { decrypted := ds.SecureJsonData.Decrypt() - if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 { caPool := x509.NewCertPool() ok := caPool.AppendCertsFromPEM([]byte(decrypted["tlsCACert"])) - if ok { - transport.TLSClientConfig.RootCAs = caPool + if !ok { + return nil, errors.New("Failed to parse TLS CA PEM certificate") } + transport.TLSClientConfig.RootCAs = caPool } - cert, err := tls.X509KeyPair([]byte(decrypted["tlsClientCert"]), []byte(decrypted["tlsClientKey"])) - if err != nil { - return nil, err + if tlsClientAuth { + cert, err := tls.X509KeyPair([]byte(decrypted["tlsClientCert"]), []byte(decrypted["tlsClientKey"])) + if err != nil { + return nil, err + } + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} } - transport.TLSClientConfig.Certificates = []tls.Certificate{cert} } ptc.cache[ds.Id] = cachedTransport{ diff --git a/pkg/models/datasource_cache_test.go b/pkg/models/datasource_cache_test.go index 5e821ea28c4..85ece0bbdcc 100644 --- a/pkg/models/datasource_cache_test.go +++ b/pkg/models/datasource_cache_test.go @@ -29,61 +29,140 @@ func TestDataSourceCache(t *testing.T) { Convey("Should be using the cached proxy", func() { So(t2, ShouldEqual, t1) }) + Convey("Should verify TLS by default", func() { + So(t1.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) + }) + Convey("Should have no TLS client certificate configured", func() { + So(len(t1.TLSClientConfig.Certificates), ShouldEqual, 0) + }) + Convey("Should have no user-supplied TLS CA onfigured", func() { + So(t1.TLSClientConfig.RootCAs, ShouldBeNil) + }) }) - Convey("When getting kubernetes datasource proxy", t, func() { + Convey("When caching a datasource proxy then updating it", t, func() { + clearCache() + setting.SecretKey = "password" + + json := simplejson.New() + json.Set("tlsAuthWithCACert", true) + + tlsCaCert, err := util.Encrypt([]byte(caCert), "password") + So(err, ShouldBeNil) + ds := DataSource{ + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + SecureJsonData: map[string][]byte{"tlsCACert": tlsCaCert}, + Updated: time.Now().Add(-2 * time.Minute), + } + + t1, err := ds.GetHttpTransport() + So(err, ShouldBeNil) + + Convey("Should verify TLS by default", func() { + So(t1.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) + }) + Convey("Should have no TLS client certificate configured", func() { + So(len(t1.TLSClientConfig.Certificates), ShouldEqual, 0) + }) + Convey("Should have no user-supplied TLS CA configured", func() { + So(t1.TLSClientConfig.RootCAs, ShouldBeNil) + }) + + ds.JsonData = nil + ds.SecureJsonData = map[string][]byte{} + ds.Updated = time.Now() + + t2, err := ds.GetHttpTransport() + So(err, ShouldBeNil) + + Convey("Should have no user-supplied TLS CA configured after the update", func() { + So(t2.TLSClientConfig.RootCAs, ShouldBeNil) + }) + }) + + Convey("When caching a datasource proxy with TLS client authentication enabled", t, func() { clearCache() setting.SecretKey = "password" json := simplejson.New() json.Set("tlsAuth", true) + + tlsClientCert, err := util.Encrypt([]byte(clientCert), "password") + So(err, ShouldBeNil) + tlsClientKey, err := util.Encrypt([]byte(clientKey), "password") + So(err, ShouldBeNil) + + ds := DataSource{ + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + JsonData: json, + SecureJsonData: map[string][]byte{ + "tlsClientCert": tlsClientCert, + "tlsClientKey": tlsClientKey, + }, + } + + tr, err := ds.GetHttpTransport() + So(err, ShouldBeNil) + + Convey("Should verify TLS by default", func() { + So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) + }) + Convey("Should have a TLS client certificate configured", func() { + So(len(tr.TLSClientConfig.Certificates), ShouldEqual, 1) + }) + }) + + Convey("When caching a datasource proxy with a user-supplied TLS CA", t, func() { + clearCache() + setting.SecretKey = "password" + + json := simplejson.New() json.Set("tlsAuthWithCACert", true) - t := time.Now() + tlsCaCert, err := util.Encrypt([]byte(caCert), "password") + So(err, ShouldBeNil) + ds := DataSource{ - Url: "http://k8s:8001", - Type: "Kubernetes", - Updated: t.Add(-2 * time.Minute), + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + JsonData: json, + SecureJsonData: map[string][]byte{"tlsCACert": tlsCaCert}, } - transport, err := ds.GetHttpTransport() + tr, err := ds.GetHttpTransport() So(err, ShouldBeNil) - Convey("Should have no cert", func() { - So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true) + Convey("Should verify TLS by default", func() { + So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) }) + Convey("Should have a TLS CA configured", func() { + So(len(tr.TLSClientConfig.RootCAs.Subjects()), ShouldEqual, 1) + }) + }) - ds.JsonData = json + Convey("When caching a datasource proxy when user skips TLS verification", t, func() { + clearCache() - tlsCaCert, _ := util.Encrypt([]byte(caCert), "password") - tlsClientCert, _ := util.Encrypt([]byte(clientCert), "password") - tlsClientKey, _ := util.Encrypt([]byte(clientKey), "password") + json := simplejson.New() + json.Set("tlsSkipVerify", true) - ds.SecureJsonData = map[string][]byte{ - "tlsCACert": tlsCaCert, - "tlsClientCert": tlsClientCert, - "tlsClientKey": tlsClientKey, + ds := DataSource{ + Id: 1, + Url: "http://k8s:8001", + Type: "Kubernetes", + JsonData: json, } - ds.Updated = t.Add(-1 * time.Minute) - transport, err = ds.GetHttpTransport() + tr, err := ds.GetHttpTransport() So(err, ShouldBeNil) - Convey("Should add cert", func() { - So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false) - So(len(transport.TLSClientConfig.Certificates), ShouldEqual, 1) - }) - - ds.JsonData = nil - ds.SecureJsonData = map[string][]byte{} - ds.Updated = t - - transport, err = ds.GetHttpTransport() - So(err, ShouldBeNil) - - Convey("Should remove cert", func() { - So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true) - So(len(transport.TLSClientConfig.Certificates), ShouldEqual, 0) + Convey("Should skip TLS verification", func() { + So(tr.TLSClientConfig.InsecureSkipVerify, ShouldEqual, true) }) }) } @@ -115,7 +194,8 @@ FHoXIyGOdq1chmRVocdGBCF8fUoGIbuF14r53rpvcbEKtKnnP8+96luKAZLq0a4n 3lb92xM= -----END CERTIFICATE-----` -const clientCert string = `-----BEGIN CERTIFICATE----- +const clientCert string = ` +-----BEGIN CERTIFICATE----- MIICsjCCAZoCCQCcd8sOfstQLzANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxj YS1rOHMtc3RobG0wHhcNMTYxMTAyMDkyNTE1WhcNMTcxMTAyMDkyNTE1WjAfMR0w GwYDVQQDDBRhZG0tZGFuaWVsLWs4cy1zdGhsbTCCASIwDQYJKoZIhvcNAQEBBQAD diff --git a/pkg/models/tags.go b/pkg/models/tags.go new file mode 100644 index 00000000000..1b90b7d55ba --- /dev/null +++ b/pkg/models/tags.go @@ -0,0 +1,60 @@ +package models + +import ( + "strings" +) + +type Tag struct { + Id int64 + Key string + Value string +} + +func ParseTagPairs(tagPairs []string) (tags []*Tag) { + if tagPairs == nil { + return []*Tag{} + } + + for _, tagPair := range tagPairs { + var tag Tag + + if strings.Contains(tagPair, ":") { + keyValue := strings.Split(tagPair, ":") + tag.Key = strings.Trim(keyValue[0], " ") + tag.Value = strings.Trim(keyValue[1], " ") + } else { + tag.Key = strings.Trim(tagPair, " ") + } + + if tag.Key == "" || ContainsTag(tags, &tag) { + continue + } + + tags = append(tags, &tag) + } + + return tags +} + +func ContainsTag(existingTags []*Tag, tag *Tag) bool { + for _, t := range existingTags { + if t.Key == tag.Key && t.Value == tag.Value { + return true + } + } + return false +} + +func JoinTagPairs(tags []*Tag) []string { + tagPairs := []string{} + + for _, tag := range tags { + if tag.Value != "" { + tagPairs = append(tagPairs, tag.Key+":"+tag.Value) + } else { + tagPairs = append(tagPairs, tag.Key) + } + } + + return tagPairs +} diff --git a/pkg/models/tags_test.go b/pkg/models/tags_test.go new file mode 100644 index 00000000000..7d95187d668 --- /dev/null +++ b/pkg/models/tags_test.go @@ -0,0 +1,95 @@ +package models + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestParsingTags(t *testing.T) { + Convey("Testing parsing a tag pairs into tags", t, func() { + Convey("Can parse one empty tag", func() { + tags := ParseTagPairs([]string{""}) + So(len(tags), ShouldEqual, 0) + }) + + Convey("Can parse valid tags", func() { + tags := ParseTagPairs([]string{"outage", "type:outage", "error"}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse tags with spaces", func() { + tags := ParseTagPairs([]string{" outage ", " type : outage ", "error "}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse empty tags", func() { + tags := ParseTagPairs([]string{" outage ", "", "", ":", "type : outage ", "error ", "", ""}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse tags with extra colons", func() { + tags := ParseTagPairs([]string{" outage", "type : outage:outage2 :outage3 ", "error :"}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse tags that contains key and values with spaces", func() { + tags := ParseTagPairs([]string{" outage 1", "type 1: outage 1 ", "has error "}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage 1") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type 1") + So(tags[1].Value, ShouldEqual, "outage 1") + So(tags[2].Key, ShouldEqual, "has error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can filter out duplicate tags", func() { + tags := ParseTagPairs([]string{"test", "test", "key:val1", "key:val2"}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "test") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "key") + So(tags[1].Value, ShouldEqual, "val1") + So(tags[2].Key, ShouldEqual, "key") + So(tags[2].Value, ShouldEqual, "val2") + }) + + Convey("Can join tag pairs", func() { + tagPairs := []*Tag{ + {Key: "key1", Value: "val1"}, + {Key: "key2", Value: ""}, + {Key: "key3"}, + } + tags := JoinTagPairs(tagPairs) + So(len(tags), ShouldEqual, 3) + So(tags[0], ShouldEqual, "key1:val1") + So(tags[1], ShouldEqual, "key2") + So(tags[2], ShouldEqual, "key3") + }) + }) +} diff --git a/pkg/services/alerting/conditions/reducer.go b/pkg/services/alerting/conditions/reducer.go index 0f396e2ffc3..0a61c13fa12 100644 --- a/pkg/services/alerting/conditions/reducer.go +++ b/pkg/services/alerting/conditions/reducer.go @@ -94,6 +94,63 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float { value = (values[(length/2)-1] + values[length/2]) / 2 } } + case "diff": + var ( + points = series.Points + first float64 + i int + ) + // get the newest point + for i = len(points) - 1; i >= 0; i-- { + if points[i][0].Valid { + allNull = false + first = points[i][0].Float64 + break + } + } + // get other points + points = points[0:i] + for i := len(points) - 1; i >= 0; i-- { + if points[i][0].Valid { + allNull = false + value = first - points[i][0].Float64 + break + } + } + case "percent_diff": + var ( + points = series.Points + first float64 + i int + ) + // get the newest point + for i = len(points) - 1; i >= 0; i-- { + if points[i][0].Valid { + allNull = false + first = points[i][0].Float64 + break + } + } + // get other points + points = points[0:i] + for i := len(points) - 1; i >= 0; i-- { + if points[i][0].Valid { + allNull = false + val := (first - points[i][0].Float64) / points[i][0].Float64 * 100 + value = math.Abs(val) + break + } + } + case "count_non_null": + for _, v := range series.Points { + if v[0].Valid { + value++ + } + } + + if value > 0 { + allNull = false + } } if allNull { diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go index ff6105a06ec..866b574f59f 100644 --- a/pkg/services/alerting/conditions/reducer_test.go +++ b/pkg/services/alerting/conditions/reducer_test.go @@ -67,6 +67,35 @@ func TestSimpleReducer(t *testing.T) { So(reducer.Reduce(series).Valid, ShouldEqual, false) }) + Convey("count_non_null", func() { + Convey("with null values and real values", func() { + reducer := NewSimpleReducer("count_non_null") + series := &tsdb.TimeSeries{ + Name: "test time serie", + } + + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 2)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(3), 3)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(3), 4)) + + So(reducer.Reduce(series).Valid, ShouldEqual, true) + So(reducer.Reduce(series).Float64, ShouldEqual, 2) + }) + + Convey("with null values", func() { + reducer := NewSimpleReducer("count_non_null") + series := &tsdb.TimeSeries{ + Name: "test time serie", + } + + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1)) + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 2)) + + So(reducer.Reduce(series).Valid, ShouldEqual, false) + }) + }) + Convey("avg of number values and null values should ignore nulls", func() { reducer := NewSimpleReducer("avg") series := &tsdb.TimeSeries{ @@ -80,6 +109,17 @@ func TestSimpleReducer(t *testing.T) { So(reducer.Reduce(series).Float64, ShouldEqual, float64(3)) }) + + Convey("diff", func() { + result := testReducer("diff", 30, 40) + So(result, ShouldEqual, float64(10)) + }) + + Convey("percent_diff", func() { + result := testReducer("percent_diff", 30, 40) + So(result, ShouldEqual, float64(33.33333333333333)) + }) + }) } diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index aefa7de6ede..f1f63d42a04 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -84,15 +84,17 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { return err } - message := evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text + "
Check Dashboard" - fields := make([]map[string]interface{}, 0) - message += "
" + attributes := make([]map[string]interface{}, 0) for index, evt := range evalContext.EvalMatches { - message += evt.Metric + " :: " + strconv.FormatFloat(evt.Value.Float64, 'f', -1, 64) + "
" - fields = append(fields, map[string]interface{}{ - "title": evt.Metric, - "value": evt.Value, - "short": true, + metricName := evt.Metric + if len(metricName) > 50 { + metricName = metricName[:50] + } + attributes = append(attributes, map[string]interface{}{ + "label": metricName, + "value": map[string]interface{}{ + "label": strconv.FormatFloat(evt.Value.Float64, 'f', -1, 64), + }, }) if index > maxFieldCount { break @@ -100,16 +102,23 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { } if evalContext.Error != nil { - fields = append(fields, map[string]interface{}{ - "title": "Error message", - "value": evalContext.Error.Error(), - "short": false, + attributes = append(attributes, map[string]interface{}{ + "label": "Error message", + "value": map[string]interface{}{ + "label": evalContext.Error.Error(), + }, }) } + message := "" if evalContext.Rule.State != models.AlertStateOK { //dont add message when going back to alert state ok. message += " " + evalContext.Rule.Message } + + if message == "" { + message = evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text + } + //HipChat has a set list of colors var color string switch evalContext.Rule.State { @@ -123,15 +132,24 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { // Add a card with link to the dashboard card := map[string]interface{}{ - "style": "link", + "style": "application", "url": ruleUrl, "id": "1", "title": evalContext.GetNotificationTitle(), - "description": evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text, + "description": message, "icon": map[string]interface{}{ "url": "https://grafana.com/assets/img/fav32.png", }, - "date": evalContext.EndTime.Unix(), + "date": evalContext.EndTime.Unix(), + "attributes": attributes, + } + if evalContext.ImagePublicUrl != "" { + card["thumbnail"] = map[string]interface{}{ + "url": evalContext.ImagePublicUrl, + "url@2x": evalContext.ImagePublicUrl, + "width": 1193, + "height": 564, + } } body := map[string]interface{}{ @@ -144,6 +162,7 @@ func (this *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { hipUrl := fmt.Sprintf("%s/v2/room/%s/notification?auth_token=%s", this.Url, this.RoomId, this.ApiKey) data, _ := json.Marshal(&body) + this.log.Info("Request payload", "json", string(data)) cmd := &models.SendWebhookSync{Url: hipUrl, Body: string(data)} if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { diff --git a/pkg/services/alerting/notifiers/kafka.go b/pkg/services/alerting/notifiers/kafka.go new file mode 100644 index 00000000000..92f6489106b --- /dev/null +++ b/pkg/services/alerting/notifiers/kafka.go @@ -0,0 +1,120 @@ +package notifiers + +import ( + "strconv" + + "fmt" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "kafka", + Name: "Kafka REST Proxy", + Description: "Sends notifications to Kafka Rest Proxy", + Factory: NewKafkaNotifier, + OptionsTemplate: ` +

Kafka settings

+
+ Kafka REST Proxy + +
+
+ Topic + +
+ `, + }) +} + +func NewKafkaNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + endpoint := model.Settings.Get("kafkaRestProxy").MustString() + if endpoint == "" { + return nil, alerting.ValidationError{Reason: "Could not find kafka rest proxy endpoint property in settings"} + } + topic := model.Settings.Get("kafkaTopic").MustString() + if topic == "" { + return nil, alerting.ValidationError{Reason: "Could not find kafka topic property in settings"} + } + + return &KafkaNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Endpoint: endpoint, + Topic: topic, + log: log.New("alerting.notifier.kafka"), + }, nil +} + +type KafkaNotifier struct { + NotifierBase + Endpoint string + Topic string + log log.Logger +} + +func (this *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error { + + state := evalContext.Rule.State + + customData := "Triggered metrics:\n\n" + for _, evt := range evalContext.EvalMatches { + customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) + } + + this.log.Info("Notifying Kafka", "alert_state", state) + + recordJSON := simplejson.New() + records := make([]interface{}, 1) + + bodyJSON := simplejson.New() + bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message) + bodyJSON.Set("client", "Grafana") + bodyJSON.Set("details", customData) + bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) + + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("Failed get rule link", "error", err) + return err + } + bodyJSON.Set("client_url", ruleUrl) + + if evalContext.ImagePublicUrl != "" { + contexts := make([]interface{}, 1) + imageJSON := simplejson.New() + imageJSON.Set("type", "image") + imageJSON.Set("src", evalContext.ImagePublicUrl) + contexts[0] = imageJSON + bodyJSON.Set("contexts", contexts) + } + + valueJSON := simplejson.New() + valueJSON.Set("value", bodyJSON) + records[0] = valueJSON + recordJSON.Set("records", records) + body, _ := recordJSON.MarshalJSON() + + topicUrl := this.Endpoint + "/topics/" + this.Topic + + cmd := &m.SendWebhookSync{ + Url: topicUrl, + Body: string(body), + HttpMethod: "POST", + HttpHeader: map[string]string{ + "Content-Type": "application/vnd.kafka.json.v2+json", + "Accept": "application/vnd.kafka.v2+json", + }, + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send notification to Kafka", "error", err, "body", string(body)) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/kafka_test.go b/pkg/services/alerting/notifiers/kafka_test.go new file mode 100644 index 00000000000..045976cb14b --- /dev/null +++ b/pkg/services/alerting/notifiers/kafka_test.go @@ -0,0 +1,55 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestKafkaNotifier(t *testing.T) { + Convey("Kafka notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "kafka_testing", + Type: "kafka", + Settings: settingsJSON, + } + + _, err := NewKafkaNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("settings should send an event to kafka", func() { + json := ` + { + "kafkaRestProxy": "http://localhost:8082", + "kafkaTopic": "topic1" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "kafka_testing", + Type: "kafka", + Settings: settingsJSON, + } + + not, err := NewKafkaNotifier(model) + kafkaNotifier := not.(*KafkaNotifier) + + So(err, ShouldBeNil) + So(kafkaNotifier.Name, ShouldEqual, "kafka_testing") + So(kafkaNotifier.Type, ShouldEqual, "kafka") + So(kafkaNotifier.Endpoint, ShouldEqual, "http://localhost:8082") + So(kafkaNotifier.Topic, ShouldEqual, "topic1") + }) + + }) + }) +} diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index e67ccfb10e5..aea02465177 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -37,8 +37,7 @@ func init() { } var ( - opsgenieCreateAlertURL string = "https://api.opsgenie.com/v1/json/alert" - opsgenieCloseAlertURL string = "https://api.opsgenie.com/v1/json/alert/close" + opsgenieAlertURL string = "https://api.opsgenie.com/v2/alerts" ) func NewOpsGenieNotifier(model *m.AlertNotification) (alerting.Notifier, error) { @@ -87,7 +86,6 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err } bodyJSON := simplejson.New() - bodyJSON.Set("apiKey", this.ApiKey) bodyJSON.Set("message", evalContext.Rule.Name) bodyJSON.Set("source", "Grafana") bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) @@ -103,9 +101,13 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err body, _ := bodyJSON.MarshalJSON() cmd := &m.SendWebhookSync{ - Url: opsgenieCreateAlertURL, + Url: opsgenieAlertURL, Body: string(body), HttpMethod: "POST", + HttpHeader: map[string]string{ + "Content-Type": "application/json", + "Authorization": fmt.Sprintf("GenieKey %s", this.ApiKey), + }, } if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { @@ -119,14 +121,17 @@ func (this *OpsGenieNotifier) closeAlert(evalContext *alerting.EvalContext) erro this.log.Info("Closing OpsGenie alert", "ruleId", evalContext.Rule.Id, "notification", this.Name) bodyJSON := simplejson.New() - bodyJSON.Set("apiKey", this.ApiKey) - bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) + bodyJSON.Set("source", "Grafana") body, _ := bodyJSON.MarshalJSON() cmd := &m.SendWebhookSync{ - Url: opsgenieCloseAlertURL, + Url: fmt.Sprintf("%s/alertId-%d/close?identifierType=alias", opsgenieAlertURL, evalContext.Rule.Id), Body: string(body), HttpMethod: "POST", + HttpHeader: map[string]string{ + "Content-Type": "application/json", + "Authorization": fmt.Sprintf("GenieKey %s", this.ApiKey), + }, } if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go index 1cf8b3a1ba8..c36dde63943 100644 --- a/pkg/services/alerting/notifiers/pagerduty.go +++ b/pkg/services/alerting/notifiers/pagerduty.go @@ -3,6 +3,8 @@ package notifiers import ( "strconv" + "fmt" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/log" @@ -72,6 +74,10 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { if evalContext.Rule.State == m.AlertStateOK { eventType = "resolve" } + customData := "Triggered metrics:\n\n" + for _, evt := range evalContext.EvalMatches { + customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value) + } this.log.Info("Notifying Pagerduty", "event_type", eventType) @@ -79,6 +85,7 @@ func (this *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error { bodyJSON.Set("service_key", this.Key) bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message) bodyJSON.Set("client", "Grafana") + bodyJSON.Set("details", customData) bodyJSON.Set("event_type", eventType) bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10)) diff --git a/pkg/services/alerting/notifiers/sensu.go b/pkg/services/alerting/notifiers/sensu.go index a4c41e14bae..9f77801d458 100644 --- a/pkg/services/alerting/notifiers/sensu.go +++ b/pkg/services/alerting/notifiers/sensu.go @@ -112,7 +112,7 @@ func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error { } if evalContext.Rule.Message != "" { - bodyJSON.Set("message", evalContext.Rule.Message) + bodyJSON.Set("output", evalContext.Rule.Message) } body, _ := bodyJSON.MarshalJSON() diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index d917daa3620..ed1451da419 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -1,7 +1,11 @@ package notifiers import ( + "bytes" "encoding/json" + "io" + "mime/multipart" + "os" "time" "github.com/grafana/grafana/pkg/bus" @@ -15,7 +19,7 @@ func init() { alerting.RegisterNotifier(&alerting.NotifierPlugin{ Type: "slack", Name: "Slack", - Description: "Sends notifications using Grafana server configured STMP settings", + Description: "Sends notifications to Slack via Slack Webhooks", Factory: NewSlackNotifier, OptionsTemplate: `

Slack settings

@@ -45,6 +49,17 @@ func init() { Mention a user or a group using @ when notifying in a channel +
+ Token + + + + Provide a bot token to use the Slack file.upload API (starts with "xoxb") + +
`, }) @@ -58,12 +73,16 @@ func NewSlackNotifier(model *m.AlertNotification) (alerting.Notifier, error) { recipient := model.Settings.Get("recipient").MustString() mention := model.Settings.Get("mention").MustString() + token := model.Settings.Get("token").MustString() + uploadImage := model.Settings.Get("uploadImage").MustBool(true) return &SlackNotifier{ NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), Url: url, Recipient: recipient, Mention: mention, + Token: token, + Upload: uploadImage, log: log.New("alerting.notifier.slack"), }, nil } @@ -73,6 +92,8 @@ type SlackNotifier struct { Url string Recipient string Mention string + Token string + Upload bool log log.Logger } @@ -110,6 +131,11 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { if evalContext.Rule.State != m.AlertStateOK { //dont add message when going back to alert state ok. message += " " + evalContext.Rule.Message } + image_url := "" + // default to file.upload API method if a token is provided + if this.Token == "" { + image_url = evalContext.ImagePublicUrl + } body := map[string]interface{}{ "attachments": []map[string]interface{}{ @@ -120,7 +146,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { "title_link": ruleUrl, "text": message, "fields": fields, - "image_url": evalContext.ImagePublicUrl, + "image_url": image_url, "footer": "Grafana v" + setting.BuildVersion, "footer_icon": "https://grafana.com/assets/img/fav32.png", "ts": time.Now().Unix(), @@ -133,14 +159,75 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { if this.Recipient != "" { body["channel"] = this.Recipient } - data, _ := json.Marshal(&body) cmd := &m.SendWebhookSync{Url: this.Url, Body: string(data)} - if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { this.log.Error("Failed to send slack notification", "error", err, "webhook", this.Name) return err } - + if this.Token != "" && this.UploadImage { + err = SlackFileUpload(evalContext, this.log, "https://slack.com/api/files.upload", this.Recipient, this.Token) + if err != nil { + return err + } + } return nil } + +func SlackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error { + if evalContext.ImageOnDiskPath == "" { + evalContext.ImageOnDiskPath = "public/img/mixed_styles.png" + } + log.Info("Uploading to slack via file.upload API") + headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient) + if err != nil { + return err + } + cmd := &m.SendWebhookSync{Url: url, Body: uploadBody.String(), HttpHeader: headers, HttpMethod: "POST"} + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + log.Error("Failed to upload slack image", "error", err, "webhook", "file.upload") + return err + } + if err != nil { + return err + } + return nil +} + +func GenerateSlackBody(file string, token string, recipient string) (map[string]string, bytes.Buffer, error) { + // Slack requires all POSTs to files.upload to present + // an "application/x-www-form-urlencoded" encoded querystring + // See https://api.slack.com/methods/files.upload + var b bytes.Buffer + w := multipart.NewWriter(&b) + // Add the generated image file + f, err := os.Open(file) + if err != nil { + return nil, b, err + } + defer f.Close() + fw, err := w.CreateFormFile("file", file) + if err != nil { + return nil, b, err + } + _, err = io.Copy(fw, f) + if err != nil { + return nil, b, err + } + // Add the authorization token + err = w.WriteField("token", token) + if err != nil { + return nil, b, err + } + // Add the channel(s) to POST to + err = w.WriteField("channels", recipient) + if err != nil { + return nil, b, err + } + w.Close() + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + "Authorization": "auth_token=\"" + token + "\"", + } + return headers, b, nil +} diff --git a/pkg/services/alerting/notifiers/slack_test.go b/pkg/services/alerting/notifiers/slack_test.go index 5b1763064aa..13f8c7b48b7 100644 --- a/pkg/services/alerting/notifiers/slack_test.go +++ b/pkg/services/alerting/notifiers/slack_test.go @@ -48,14 +48,16 @@ func TestSlackNotifier(t *testing.T) { So(slackNotifier.Url, ShouldEqual, "http://google.com") So(slackNotifier.Recipient, ShouldEqual, "") So(slackNotifier.Mention, ShouldEqual, "") + So(slackNotifier.Token, ShouldEqual, "") }) - Convey("from settings with Recipient and Mention", func() { + Convey("from settings with Recipient, Mention, and Token", func() { json := ` { "url": "http://google.com", "recipient": "#ds-opentsdb", - "mention": "@carl" + "mention": "@carl", + "token": "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX" }` settingsJSON, _ := simplejson.NewJson([]byte(json)) @@ -74,6 +76,7 @@ func TestSlackNotifier(t *testing.T) { So(slackNotifier.Url, ShouldEqual, "http://google.com") So(slackNotifier.Recipient, ShouldEqual, "#ds-opentsdb") So(slackNotifier.Mention, ShouldEqual, "@carl") + So(slackNotifier.Token, ShouldEqual, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX") }) }) diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index d34dbf5a632..448b4ace5bb 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -73,10 +73,8 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { OrgId: evalContext.Rule.OrgId, DashboardId: evalContext.Rule.DashboardId, PanelId: evalContext.Rule.PanelId, - Type: annotations.AlertType, AlertId: evalContext.Rule.Id, - Title: evalContext.Rule.Name, - Text: evalContext.GetStateModel().Text, + Text: "", NewState: string(evalContext.Rule.State), PrevState: string(evalContext.PrevAlertState), Epoch: time.Now().Unix(), diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index be9d3f2d4d0..2fdc824f172 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -5,7 +5,7 @@ import "github.com/grafana/grafana/pkg/components/simplejson" type Repository interface { Save(item *Item) error Update(item *Item) error - Find(query *ItemQuery) ([]*Item, error) + Find(query *ItemQuery) ([]*ItemDTO, error) Delete(params *DeleteParams) error } @@ -13,11 +13,10 @@ type ItemQuery struct { OrgId int64 `json:"orgId"` From int64 `json:"from"` To int64 `json:"to"` - Type ItemType `json:"type"` AlertId int64 `json:"alertId"` DashboardId int64 `json:"dashboardId"` PanelId int64 `json:"panelId"` - NewState []string `json:"newState"` + Tags []string `json:"tags"` Limit int64 `json:"limit"` } @@ -28,12 +27,15 @@ type PostParams struct { Epoch int64 `json:"epoch"` Title string `json:"title"` Text string `json:"text"` + Icon string `json:"icon"` } type DeleteParams struct { + Id int64 `json:"id"` AlertId int64 `json:"alertId"` DashboardId int64 `json:"dashboardId"` PanelId int64 `json:"panelId"` + RegionId int64 `json:"regionId"` } var repositoryInstance Repository @@ -46,29 +48,41 @@ func SetRepository(rep Repository) { repositoryInstance = rep } -type ItemType string - -const ( - AlertType ItemType = "alert" - EventType ItemType = "event" -) - type Item struct { - Id int64 `json:"id"` - OrgId int64 `json:"orgId"` - DashboardId int64 `json:"dashboardId"` - PanelId int64 `json:"panelId"` - CategoryId int64 `json:"categoryId"` - RegionId int64 `json:"regionId"` - Type ItemType `json:"type"` - Title string `json:"title"` - Text string `json:"text"` - Metric string `json:"metric"` - AlertId int64 `json:"alertId"` - UserId int64 `json:"userId"` - PrevState string `json:"prevState"` - NewState string `json:"newState"` - Epoch int64 `json:"epoch"` + Id int64 `json:"id"` + OrgId int64 `json:"orgId"` + UserId int64 `json:"userId"` + DashboardId int64 `json:"dashboardId"` + PanelId int64 `json:"panelId"` + RegionId int64 `json:"regionId"` + Text string `json:"text"` + AlertId int64 `json:"alertId"` + PrevState string `json:"prevState"` + NewState string `json:"newState"` + Epoch int64 `json:"epoch"` + Tags []string `json:"tags"` + Data *simplejson.Json `json:"data"` - Data *simplejson.Json `json:"data"` + // needed until we remove it from db + Type string + Title string +} + +type ItemDTO struct { + Id int64 `json:"id"` + AlertId int64 `json:"alertId"` + AlertName string `json:"alertName"` + DashboardId int64 `json:"dashboardId"` + PanelId int64 `json:"panelId"` + UserId int64 `json:"userId"` + NewState string `json:"newState"` + PrevState string `json:"prevState"` + Time int64 `json:"time"` + Text string `json:"text"` + RegionId int64 `json:"regionId"` + Tags []string `json:"tags"` + Login string `json:"login"` + Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` + Data *simplejson.Json `json:"data"` } diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index df9f1138b15..7fbf39ee41d 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -101,7 +101,11 @@ func createDialer() (*gomail.Dialer, error) { d := gomail.NewDialer(host, iPort, setting.Smtp.User, setting.Smtp.Password) d.TLSConfig = tlsconfig - d.LocalName = setting.InstanceName + if setting.Smtp.EhloIdentity != "" { + d.LocalName = setting.Smtp.EhloIdentity + } else { + d.LocalName = setting.InstanceName + } return d, nil } diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index c74804ab828..dff5aa4924a 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -27,7 +27,8 @@ type Webhook struct { var netTransport = &http.Transport{ Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ - Timeout: 30 * time.Second, + Timeout: 30 * time.Second, + DualStack: true, }).Dial, TLSHandshakeTimeout: 5 * time.Second, } diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index bc589f89c14..33a4cae53c2 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -100,13 +100,13 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { sql.WriteString(")") } + sql.WriteString(" ORDER BY name ASC") + if query.Limit != 0 { sql.WriteString(" LIMIT ?") params = append(params, query.Limit) } - sql.WriteString(" ORDER BY name ASC") - alerts := make([]*m.Alert, 0) if err := x.Sql(sql.String(), params...).Find(&alerts); err != nil { return err diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index ffad5bf2cad..d97db10f630 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -2,9 +2,11 @@ package sqlstore import ( "bytes" + "errors" "fmt" "strings" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" ) @@ -13,19 +15,94 @@ type SqlAnnotationRepo struct { func (r *SqlAnnotationRepo) Save(item *annotations.Item) error { return inTransaction(func(sess *DBSession) error { - + tags := models.ParseTagPairs(item.Tags) + item.Tags = models.JoinTagPairs(tags) if _, err := sess.Table("annotation").Insert(item); err != nil { return err } + if item.Tags != nil { + if tags, err := r.ensureTagsExist(sess, tags); err != nil { + return err + } else { + for _, tag := range tags { + if _, err := sess.Exec("INSERT INTO annotation_tag (annotation_id, tag_id) VALUES(?,?)", item.Id, tag.Id); err != nil { + return err + } + } + } + } + return nil }) } +// Will insert if needed any new key/value pars and return ids +func (r *SqlAnnotationRepo) ensureTagsExist(sess *DBSession, tags []*models.Tag) ([]*models.Tag, error) { + for _, tag := range tags { + var existingTag models.Tag + + // check if it exists + if exists, err := sess.Table("tag").Where("`key`=? AND `value`=?", tag.Key, tag.Value).Get(&existingTag); err != nil { + return nil, err + } else if exists { + tag.Id = existingTag.Id + } else { + if _, err := sess.Table("tag").Insert(tag); err != nil { + return nil, err + } + } + } + + return tags, nil +} + func (r *SqlAnnotationRepo) Update(item *annotations.Item) error { return inTransaction(func(sess *DBSession) error { + var ( + isExist bool + err error + ) + existing := new(annotations.Item) - if _, err := sess.Table("annotation").Id(item.Id).Update(item); err != nil { + if item.Id == 0 && item.RegionId != 0 { + // Update region end time + isExist, err = sess.Table("annotation").Where("region_id=? AND id!=? AND org_id=?", item.RegionId, item.RegionId, item.OrgId).Get(existing) + } else { + isExist, err = sess.Table("annotation").Where("id=? AND org_id=?", item.Id, item.OrgId).Get(existing) + } + + if err != nil { + return err + } + if !isExist { + return errors.New("Annotation not found") + } + + existing.Epoch = item.Epoch + existing.Text = item.Text + if item.RegionId != 0 { + existing.RegionId = item.RegionId + } + + if item.Tags != nil { + if tags, err := r.ensureTagsExist(sess, models.ParseTagPairs(item.Tags)); err != nil { + return err + } else { + if _, err := sess.Exec("DELETE FROM annotation_tag WHERE annotation_id = ?", existing.Id); err != nil { + return err + } + for _, tag := range tags { + if _, err := sess.Exec("INSERT INTO annotation_tag (annotation_id, tag_id) VALUES(?,?)", existing.Id, tag.Id); err != nil { + return err + } + } + } + } + + existing.Tags = item.Tags + + if _, err := sess.Table("annotation").Id(existing.Id).Cols("epoch", "text", "region_id", "tags").Update(existing); err != nil { return err } @@ -33,51 +110,79 @@ func (r *SqlAnnotationRepo) Update(item *annotations.Item) error { }) } -func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.Item, error) { +func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) { var sql bytes.Buffer params := make([]interface{}, 0) - sql.WriteString(`SELECT * - from annotation - `) + sql.WriteString(` + SELECT + annotation.id, + annotation.epoch as time, + annotation.dashboard_id, + annotation.panel_id, + annotation.new_state, + annotation.prev_state, + annotation.alert_id, + annotation.region_id, + annotation.text, + annotation.tags, + annotation.data, + usr.email, + usr.login, + alert.name as alert_name + FROM annotation + LEFT OUTER JOIN ` + dialect.Quote("user") + ` as usr on usr.id = annotation.user_id + LEFT OUTER JOIN alert on alert.id = annotation.alert_id + `) - sql.WriteString(`WHERE org_id = ?`) + sql.WriteString(`WHERE annotation.org_id = ?`) params = append(params, query.OrgId) if query.AlertId != 0 { - sql.WriteString(` AND alert_id = ?`) - params = append(params, query.AlertId) - } - - if query.AlertId != 0 { - sql.WriteString(` AND alert_id = ?`) + sql.WriteString(` AND annotation.alert_id = ?`) params = append(params, query.AlertId) } if query.DashboardId != 0 { - sql.WriteString(` AND dashboard_id = ?`) + sql.WriteString(` AND annotation.dashboard_id = ?`) params = append(params, query.DashboardId) } if query.PanelId != 0 { - sql.WriteString(` AND panel_id = ?`) + sql.WriteString(` AND annotation.panel_id = ?`) params = append(params, query.PanelId) } if query.From > 0 && query.To > 0 { - sql.WriteString(` AND epoch BETWEEN ? AND ?`) + sql.WriteString(` AND annotation.epoch BETWEEN ? AND ?`) params = append(params, query.From, query.To) } - if query.Type != "" { - sql.WriteString(` AND type = ?`) - params = append(params, string(query.Type)) - } + if len(query.Tags) > 0 { + keyValueFilters := []string{} - if len(query.NewState) > 0 { - sql.WriteString(` AND new_state IN (?` + strings.Repeat(",?", len(query.NewState)-1) + ")") - for _, v := range query.NewState { - params = append(params, v) + tags := models.ParseTagPairs(query.Tags) + for _, tag := range tags { + if tag.Value == "" { + keyValueFilters = append(keyValueFilters, "(tag.key = ?)") + params = append(params, tag.Key) + } else { + keyValueFilters = append(keyValueFilters, "(tag.key = ? AND tag.value = ?)") + params = append(params, tag.Key, tag.Value) + } + } + + if len(tags) > 0 { + tagsSubQuery := fmt.Sprintf(` + SELECT SUM(1) FROM annotation_tag at + INNER JOIN tag on tag.id = at.tag_id + WHERE at.annotation_id = annotation.id + AND ( + %s + ) + `, strings.Join(keyValueFilters, " OR ")) + + sql.WriteString(fmt.Sprintf(" AND (%s) = %d ", tagsSubQuery, len(tags))) } } @@ -87,7 +192,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit)) - items := make([]*annotations.Item, 0) + items := make([]*annotations.ItemDTO, 0) if err := x.Sql(sql.String(), params...).Find(&items); err != nil { return nil, err } @@ -97,11 +202,31 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) error { return inTransaction(func(sess *DBSession) error { + var ( + sql string + annoTagSql string + queryParams []interface{} + ) - sql := "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?" + if params.RegionId != 0 { + annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE region_id = ?)" + sql = "DELETE FROM annotation WHERE region_id = ?" + queryParams = []interface{}{params.RegionId} + } else if params.Id != 0 { + annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE id = ?)" + sql = "DELETE FROM annotation WHERE id = ?" + queryParams = []interface{}{params.Id} + } else { + annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ?)" + sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?" + queryParams = []interface{}{params.DashboardId, params.PanelId} + } - _, err := sess.Exec(sql, params.DashboardId, params.PanelId) - if err != nil { + if _, err := sess.Exec(annoTagSql, queryParams...); err != nil { + return err + } + + if _, err := sess.Exec(sql, queryParams...); err != nil { return err } diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go new file mode 100644 index 00000000000..3f7415a952b --- /dev/null +++ b/pkg/services/sqlstore/annotation_test.go @@ -0,0 +1,208 @@ +package sqlstore + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" +) + +func TestSavingTags(t *testing.T) { + Convey("Testing annotation saving/loading", t, func() { + InitTestDB(t) + + repo := SqlAnnotationRepo{} + + Convey("Can save tags", func() { + tagPairs := []*models.Tag{ + {Key: "outage"}, + {Key: "type", Value: "outage"}, + {Key: "server", Value: "server-1"}, + {Key: "error"}, + } + tags, err := repo.ensureTagsExist(newSession(), tagPairs) + + So(err, ShouldBeNil) + So(len(tags), ShouldEqual, 4) + }) + }) +} + +func TestAnnotations(t *testing.T) { + Convey("Testing annotation saving/loading", t, func() { + InitTestDB(t) + + repo := SqlAnnotationRepo{} + + Convey("Can save annotation", func() { + err := repo.Save(&annotations.Item{ + OrgId: 1, + UserId: 1, + DashboardId: 1, + Text: "hello", + Epoch: 10, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + }) + + So(err, ShouldBeNil) + + Convey("Can query for annotation", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + + Convey("Can read tags", func() { + So(items[0].Tags, ShouldResemble, []string{"outage", "error", "type:outage", "server:server-1"}) + }) + }) + + Convey("Should not find any when item is outside time range", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 12, + To: 15, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 0) + }) + + Convey("Should not find one when tag filter does not match", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"asd"}, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 0) + }) + + Convey("Should find one when all tag filters does match", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"outage", "error"}, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + }) + + Convey("Should find one when all key value tag filters does match", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"type:outage", "server:server-1"}, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + }) + + Convey("Can update annotation and remove all tags", func() { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + } + items, err := repo.Find(query) + + So(err, ShouldBeNil) + + annotationId := items[0].Id + + err = repo.Update(&annotations.Item{ + Id: annotationId, + OrgId: 1, + Text: "something new", + Tags: []string{}, + }) + + So(err, ShouldBeNil) + + items, err = repo.Find(query) + + So(err, ShouldBeNil) + + Convey("Can read tags", func() { + So(items[0].Id, ShouldEqual, annotationId) + So(len(items[0].Tags), ShouldEqual, 0) + So(items[0].Text, ShouldEqual, "something new") + }) + }) + + Convey("Can update annotation with new tags", func() { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + } + items, err := repo.Find(query) + + So(err, ShouldBeNil) + + annotationId := items[0].Id + + err = repo.Update(&annotations.Item{ + Id: annotationId, + OrgId: 1, + Text: "something new", + Tags: []string{"newtag1", "newtag2"}, + }) + + So(err, ShouldBeNil) + + items, err = repo.Find(query) + + So(err, ShouldBeNil) + + Convey("Can read tags", func() { + So(items[0].Id, ShouldEqual, annotationId) + So(items[0].Tags, ShouldResemble, []string{"newtag1", "newtag2"}) + So(items[0].Text, ShouldEqual, "something new") + }) + }) + + Convey("Can delete annotation", func() { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + } + items, err := repo.Find(query) + So(err, ShouldBeNil) + + annotationId := items[0].Id + + err = repo.Delete(&annotations.DeleteParams{Id: annotationId}) + + items, err = repo.Find(query) + So(err, ShouldBeNil) + + Convey("Should be deleted", func() { + So(len(items), ShouldEqual, 0) + }) + }) + + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 27812eef32e..d91b4a08aa6 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -261,6 +261,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { "DELETE FROM dashboard WHERE id = ?", "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", "DELETE FROM dashboard_version WHERE dashboard_id = ?", + "DELETE FROM annotation WHERE dashboard_id = ?", } for _, sql := range deletes { diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index 810189b3246..0ef7f99da67 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -86,9 +86,10 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { } func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error { - var snapshots = make(m.DashboardSnapshots, 0) + var snapshots = make(m.DashboardSnapshotsList, 0) sess := x.Limit(query.Limit) + sess.Table("dashboard_snapshot") if query.Name != "" { sess.Where("name LIKE ?", query.Name) diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index a9343266863..8d2bf94bc42 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -57,4 +57,37 @@ func addAnnotationMig(mg *Migrator) { mg.AddMigration("Add column region_id to annotation table", NewAddColumnMigration(table, &Column{ Name: "region_id", Type: DB_BigInt, Nullable: true, Default: "0", })) + + categoryIdIndex := &Index{Cols: []string{"org_id", "category_id"}, Type: IndexType} + mg.AddMigration("Drop category_id index", NewDropIndexMigration(table, categoryIdIndex)) + + mg.AddMigration("Add column tags to annotation table", NewAddColumnMigration(table, &Column{ + Name: "tags", Type: DB_NVarchar, Nullable: true, Length: 500, + })) + + /// + /// Annotation tag + /// + annotationTagTable := Table{ + Name: "annotation_tag", + Columns: []*Column{ + {Name: "annotation_id", Type: DB_BigInt, Nullable: false}, + {Name: "tag_id", Type: DB_BigInt, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"annotation_id", "tag_id"}, Type: UniqueIndex}, + }, + } + + mg.AddMigration("Create annotation_tag table v2", NewAddTableMigration(annotationTagTable)) + mg.AddMigration("Add unique index annotation_tag.annotation_id_tag_id", NewAddIndexMigration(annotationTagTable, annotationTagTable.Indices[0])) + + // + // clear alert text + // + updateTextFieldSql := "UPDATE annotation SET TEXT = '' WHERE alert_id > 0" + mg.AddMigration("Update alert annotations and set TEXT to empty", new(RawSqlMigration). + Sqlite(updateTextFieldSql). + Postgres(updateTextFieldSql). + Mysql(updateTextFieldSql)) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 38072fe88e4..4984ff18592 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -26,6 +26,7 @@ func AddMigrations(mg *Migrator) { addAnnotationMig(mg) addTestDataMigrations(mg) addDashboardVersionMigration(mg) + addTagMigration(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/tag_mig.go b/pkg/services/sqlstore/migrations/tag_mig.go new file mode 100644 index 00000000000..0303ddd6409 --- /dev/null +++ b/pkg/services/sqlstore/migrations/tag_mig.go @@ -0,0 +1,24 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addTagMigration(mg *Migrator) { + + tagTable := Table{ + Name: "tag", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "key", Type: DB_NVarchar, Length: 100, Nullable: false}, + {Name: "value", Type: DB_NVarchar, Length: 100, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"key", "value"}, Type: UniqueIndex}, + }, + } + + // create table + mg.AddMigration("create tag table", NewAddTableMigration(tagTable)) + + // create indices + mg.AddMigration("add index tag.key_value", NewAddIndexMigration(tagTable, tagTable.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go index 92109efdfab..8de26194411 100644 --- a/pkg/services/sqlstore/migrator/postgres_dialect.go +++ b/pkg/services/sqlstore/migrator/postgres_dialect.go @@ -104,7 +104,7 @@ func (db *Postgres) SqlType(c *Column) string { func (db *Postgres) TableCheckSql(tableName string) (string, []interface{}) { args := []interface{}{"grafana", tableName} - sql := "SELECT `TABLE_NAME` from `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA`=? and `TABLE_NAME`=?" + sql := "SELECT table_name FROM information_schema.tables WHERE table_schema=? and table_name=?" return sql, args } diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 37c3bc9d1d8..3781d83dd96 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -96,6 +96,7 @@ func CreateUser(cmd *m.CreateUserCommand) error { EmailVerified: cmd.EmailVerified, Created: time.Now(), Updated: time.Now(), + LastSeenAt: time.Now().AddDate(-10, 0, 0), } if len(cmd.Password) > 0 { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index c29512ce5f0..ca65fe581af 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -122,6 +122,9 @@ var ( // Basic Auth BasicAuthEnabled bool + // Plugin settings + PluginAppsSkipVerifyTLS bool + // Session settings. SessionOptions session.Options @@ -264,12 +267,16 @@ func applyCommandLineDefaultProperties(props map[string]string) { func applyCommandLineProperties(props map[string]string) { for _, section := range Cfg.Sections() { + sectionName := section.Name() + "." + if section.Name() == ini.DEFAULT_SECTION { + sectionName = "" + } for _, key := range section.Keys() { - keyString := fmt.Sprintf("%s.%s", section.Name(), key.Name()) + keyString := sectionName + key.Name() value, exists := props[keyString] if exists { - key.SetValue(value) appliedCommandLineProperties = append(appliedCommandLineProperties, fmt.Sprintf("%s=%s", keyString, value)) + key.SetValue(value) } } } @@ -449,16 +456,11 @@ func validateStaticRootPath() error { return nil } - if _, err := os.Stat(path.Join(StaticRootPath, "css")); err == nil { - return nil + if _, err := os.Stat(path.Join(StaticRootPath, "build")); err != nil { + logger.Error("Failed to detect generated javascript files in public/build") } - if _, err := os.Stat(StaticRootPath + "_gen/css"); err == nil { - StaticRootPath = StaticRootPath + "_gen" - return nil - } - - return fmt.Errorf("Failed to detect generated css or javascript files in static root (%s), have you executed default grunt task?", StaticRootPath) + return nil } func NewConfigContext(args *CommandLineArgs) error { @@ -561,6 +563,9 @@ func NewConfigContext(args *CommandLineArgs) error { authBasic := Cfg.Section("auth.basic") BasicAuthEnabled = authBasic.Key("enabled").MustBool(true) + // global plugin settings + PluginAppsSkipVerifyTLS = Cfg.Section("plugins").Key("app_tls_skip_verify_insecure").MustBool(false) + // PhantomJS rendering ImagesDir = filepath.Join(DataPath, "png") PhantomDir = filepath.Join(HomePath, "vendor/phantomjs") @@ -656,4 +661,5 @@ func LogConfigurationInfo() { logger.Info("Path Data", "path", DataPath) logger.Info("Path Logs", "path", LogsPath) logger.Info("Path Plugins", "path", PluginsPath) + logger.Info("App mode " + Env) } diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index bc52d2336c3..ee2e812415b 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -13,6 +13,7 @@ type OAuthInfo struct { TlsClientCert string TlsClientKey string TlsClientCa string + TlsSkipVerify bool } type OAuther struct { diff --git a/pkg/setting/setting_smtp.go b/pkg/setting/setting_smtp.go index db065070620..9d8b8a529a5 100644 --- a/pkg/setting/setting_smtp.go +++ b/pkg/setting/setting_smtp.go @@ -1,15 +1,16 @@ package setting type SmtpSettings struct { - Enabled bool - Host string - User string - Password string - CertFile string - KeyFile string - FromAddress string - FromName string - SkipVerify bool + Enabled bool + Host string + User string + Password string + CertFile string + KeyFile string + FromAddress string + FromName string + EhloIdentity string + SkipVerify bool SendWelcomeEmailOnSignUp bool TemplatesPattern string @@ -25,6 +26,7 @@ func readSmtpSettings() { Smtp.KeyFile = sec.Key("key_file").String() Smtp.FromAddress = sec.Key("from_address").String() Smtp.FromName = sec.Key("from_name").String() + Smtp.EhloIdentity = sec.Key("ehlo_identity").String() Smtp.SkipVerify = sec.Key("skip_verify").MustBool(false) emails := Cfg.Section("emails") diff --git a/pkg/social/social.go b/pkg/social/social.go index 9d2a53946c7..d40c0a0c965 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -66,6 +66,7 @@ func NewOAuthService() { 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 { diff --git a/pkg/tracing/tracing.go b/pkg/tracing/tracing.go index be81cd7e794..921996d155d 100644 --- a/pkg/tracing/tracing.go +++ b/pkg/tracing/tracing.go @@ -80,8 +80,8 @@ func internalInit(settings *TracingSettings) (io.Closer, error) { return nil, err } - logger.Info("Initialized jaeger tracer", "address", settings.Address) opentracing.InitGlobalTracer(tracer) + logger.Info("Initializing Jaeger tracer", "address", settings.Address) return closer, nil } diff --git a/pkg/tsdb/cloudwatch/annotation_query.go b/pkg/tsdb/cloudwatch/annotation_query.go new file mode 100644 index 00000000000..287f4e770ef --- /dev/null +++ b/pkg/tsdb/cloudwatch/annotation_query.go @@ -0,0 +1,220 @@ +package cloudwatch + +import ( + "context" + "errors" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb" +) + +func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { + result := &tsdb.Response{ + Results: make(map[string]*tsdb.QueryResult), + } + firstQuery := queryContext.Queries[0] + queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: firstQuery.RefId} + + parameters := firstQuery.Model + usePrefixMatch := parameters.Get("prefixMatching").MustBool(false) + region := parameters.Get("region").MustString("") + namespace := parameters.Get("namespace").MustString("") + metricName := parameters.Get("metricName").MustString("") + dimensions := parameters.Get("dimensions").MustMap() + statistics, extendedStatistics, err := parseStatistics(parameters) + if err != nil { + return nil, err + } + period := int64(parameters.Get("period").MustInt(0)) + if period == 0 && !usePrefixMatch { + period = 300 + } + actionPrefix := parameters.Get("actionPrefix").MustString("") + alarmNamePrefix := parameters.Get("alarmNamePrefix").MustString("") + + svc, err := e.getClient(region) + if err != nil { + return nil, err + } + + var alarmNames []*string + if usePrefixMatch { + params := &cloudwatch.DescribeAlarmsInput{ + MaxRecords: aws.Int64(100), + ActionPrefix: aws.String(actionPrefix), + AlarmNamePrefix: aws.String(alarmNamePrefix), + } + resp, err := svc.DescribeAlarms(params) + if err != nil { + return nil, errors.New("Failed to call cloudwatch:DescribeAlarms") + } + alarmNames = filterAlarms(resp, namespace, metricName, dimensions, statistics, extendedStatistics, period) + } else { + if region == "" || namespace == "" || metricName == "" || len(statistics) == 0 { + return result, nil + } + + var qd []*cloudwatch.Dimension + for k, v := range dimensions { + if vv, ok := v.(string); ok { + qd = append(qd, &cloudwatch.Dimension{ + Name: aws.String(k), + Value: aws.String(vv), + }) + } + } + for _, s := range statistics { + params := &cloudwatch.DescribeAlarmsForMetricInput{ + Namespace: aws.String(namespace), + MetricName: aws.String(metricName), + Dimensions: qd, + Statistic: aws.String(s), + Period: aws.Int64(int64(period)), + } + resp, err := svc.DescribeAlarmsForMetric(params) + if err != nil { + return nil, errors.New("Failed to call cloudwatch:DescribeAlarmsForMetric") + } + for _, alarm := range resp.MetricAlarms { + alarmNames = append(alarmNames, alarm.AlarmName) + } + } + for _, s := range extendedStatistics { + params := &cloudwatch.DescribeAlarmsForMetricInput{ + Namespace: aws.String(namespace), + MetricName: aws.String(metricName), + Dimensions: qd, + ExtendedStatistic: aws.String(s), + Period: aws.Int64(int64(period)), + } + resp, err := svc.DescribeAlarmsForMetric(params) + if err != nil { + return nil, errors.New("Failed to call cloudwatch:DescribeAlarmsForMetric") + } + for _, alarm := range resp.MetricAlarms { + alarmNames = append(alarmNames, alarm.AlarmName) + } + } + } + + startTime, err := queryContext.TimeRange.ParseFrom() + if err != nil { + return nil, err + } + endTime, err := queryContext.TimeRange.ParseTo() + if err != nil { + return nil, err + } + + annotations := make([]map[string]string, 0) + for _, alarmName := range alarmNames { + params := &cloudwatch.DescribeAlarmHistoryInput{ + AlarmName: alarmName, + StartDate: aws.Time(startTime), + EndDate: aws.Time(endTime), + MaxRecords: aws.Int64(100), + } + resp, err := svc.DescribeAlarmHistory(params) + if err != nil { + return nil, errors.New("Failed to call cloudwatch:DescribeAlarmHistory") + } + for _, history := range resp.AlarmHistoryItems { + annotation := make(map[string]string) + annotation["time"] = history.Timestamp.UTC().Format(time.RFC3339) + annotation["title"] = *history.AlarmName + annotation["tags"] = *history.HistoryItemType + annotation["text"] = *history.HistorySummary + annotations = append(annotations, annotation) + } + } + + transformAnnotationToTable(annotations, queryResult) + result.Results[firstQuery.RefId] = queryResult + return result, err +} + +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)) +} + +func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, metricName string, dimensions map[string]interface{}, statistics []string, extendedStatistics []string, period int64) []*string { + alarmNames := make([]*string, 0) + + for _, alarm := range alarms.MetricAlarms { + if namespace != "" && *alarm.Namespace != namespace { + continue + } + if metricName != "" && *alarm.MetricName != metricName { + continue + } + + match := true + if len(dimensions) == 0 { + // all match + } else if len(alarm.Dimensions) != len(dimensions) { + match = false + } else { + for _, d := range alarm.Dimensions { + if _, ok := dimensions[*d.Name]; !ok { + match = false + } + } + } + if !match { + continue + } + + if len(statistics) != 0 { + found := false + for _, s := range statistics { + if *alarm.Statistic == s { + found = true + } + } + if !found { + continue + } + } + + if len(extendedStatistics) != 0 { + found := false + for _, s := range extendedStatistics { + if *alarm.Statistic == s { + found = true + } + } + if !found { + continue + } + } + + if period != 0 && *alarm.Period != period { + continue + } + + alarmNames = append(alarmNames, alarm.AlarmName) + } + + return alarmNames +} diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go new file mode 100644 index 00000000000..266b71ec14e --- /dev/null +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -0,0 +1,361 @@ +package cloudwatch + +import ( + "context" + "errors" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/grafana/grafana/pkg/components/null" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/metrics" +) + +type CloudWatchExecutor struct { + *models.DataSource +} + +type DatasourceInfo struct { + Profile string + Region string + AuthType string + AssumeRoleArn string + Namespace string + + AccessKey string + SecretKey string +} + +func NewCloudWatchExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + return &CloudWatchExecutor{}, nil +} + +var ( + plog log.Logger + standardStatistics map[string]bool + aliasFormat *regexp.Regexp +) + +func init() { + plog = log.New("tsdb.cloudwatch") + tsdb.RegisterTsdbQueryEndpoint("cloudwatch", NewCloudWatchExecutor) + standardStatistics = map[string]bool{ + "Average": true, + "Maximum": true, + "Minimum": true, + "Sum": true, + "SampleCount": true, + } + aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) +} + +func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSource, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { + var result *tsdb.Response + e.DataSource = dsInfo + queryType := queryContext.Queries[0].Model.Get("type").MustString("") + var err error + + switch queryType { + case "metricFindQuery": + result, err = e.executeMetricFindQuery(ctx, queryContext) + break + case "annotationQuery": + result, err = e.executeAnnotationQuery(ctx, queryContext) + break + case "timeSeriesQuery": + fallthrough + default: + result, err = e.executeTimeSeriesQuery(ctx, queryContext) + break + } + + return result, err +} + +func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { + result := &tsdb.Response{ + Results: make(map[string]*tsdb.QueryResult), + } + + errCh := make(chan error, 1) + resCh := make(chan *tsdb.QueryResult, 1) + + currentlyExecuting := 0 + for i, model := range queryContext.Queries { + queryType := model.Model.Get("type").MustString() + if queryType != "timeSeriesQuery" && queryType != "" { + continue + } + currentlyExecuting++ + go func(refId string, index int) { + queryRes, err := e.executeQuery(ctx, queryContext.Queries[index].Model, queryContext) + currentlyExecuting-- + if err != nil { + errCh <- err + } else { + queryRes.RefId = refId + resCh <- queryRes + } + }(model.RefId, i) + } + + for currentlyExecuting != 0 { + select { + case res := <-resCh: + result.Results[res.RefId] = res + case err := <-errCh: + return result, err + case <-ctx.Done(): + return result, ctx.Err() + } + } + + return result, nil +} + +func (e *CloudWatchExecutor) executeQuery(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) { + query, err := parseQuery(parameters) + if err != nil { + return nil, err + } + + client, err := e.getClient(query.Region) + if err != nil { + return nil, err + } + + startTime, err := queryContext.TimeRange.ParseFrom() + if err != nil { + return nil, err + } + + endTime, err := queryContext.TimeRange.ParseTo() + if err != nil { + return nil, err + } + + params := &cloudwatch.GetMetricStatisticsInput{ + Namespace: aws.String(query.Namespace), + MetricName: aws.String(query.MetricName), + Dimensions: query.Dimensions, + Period: aws.Int64(int64(query.Period)), + StartTime: aws.Time(startTime), + EndTime: aws.Time(endTime), + } + if len(query.Statistics) > 0 { + params.Statistics = query.Statistics + } + if len(query.ExtendedStatistics) > 0 { + params.ExtendedStatistics = query.ExtendedStatistics + } + + if setting.Env == setting.DEV { + plog.Debug("CloudWatch query", "raw query", params) + } + + resp, err := client.GetMetricStatisticsWithContext(ctx, params, request.WithResponseReadTimeout(10*time.Second)) + if err != nil { + return nil, err + } + metrics.M_Aws_CloudWatch_GetMetricStatistics.Inc() + + queryRes, err := parseResponse(resp, query) + if err != nil { + return nil, err + } + + return queryRes, nil +} + +func parseDimensions(model *simplejson.Json) ([]*cloudwatch.Dimension, error) { + var result []*cloudwatch.Dimension + + for k, v := range model.Get("dimensions").MustMap() { + kk := k + if vv, ok := v.(string); ok { + result = append(result, &cloudwatch.Dimension{ + Name: &kk, + Value: &vv, + }) + } else { + return nil, errors.New("failed to parse") + } + } + + sort.Slice(result, func(i, j int) bool { + return *result[i].Name < *result[j].Name + }) + return result, nil +} + +func parseStatistics(model *simplejson.Json) ([]string, []string, error) { + var statistics []string + var extendedStatistics []string + + for _, s := range model.Get("statistics").MustArray() { + if ss, ok := s.(string); ok { + if _, isStandard := standardStatistics[ss]; isStandard { + statistics = append(statistics, ss) + } else { + extendedStatistics = append(extendedStatistics, ss) + } + } else { + return nil, nil, errors.New("failed to parse") + } + } + + return statistics, extendedStatistics, nil +} + +func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) { + region, err := model.Get("region").String() + if err != nil { + return nil, err + } + + namespace, err := model.Get("namespace").String() + if err != nil { + return nil, err + } + + metricName, err := model.Get("metricName").String() + if err != nil { + return nil, err + } + + dimensions, err := parseDimensions(model) + if err != nil { + return nil, err + } + + statistics, extendedStatistics, err := parseStatistics(model) + if err != nil { + return nil, err + } + + p := model.Get("period").MustString("") + if p == "" { + if namespace == "AWS/EC2" { + p = "300" + } else { + p = "60" + } + } + + period := 300 + if regexp.MustCompile(`^\d+$`).Match([]byte(p)) { + period, err = strconv.Atoi(p) + if err != nil { + return nil, err + } + } else { + d, err := time.ParseDuration(p) + if err != nil { + return nil, err + } + period = int(d.Seconds()) + } + + alias := model.Get("alias").MustString("{{metric}}_{{stat}}") + + return &CloudWatchQuery{ + Region: region, + Namespace: namespace, + MetricName: metricName, + Dimensions: dimensions, + Statistics: aws.StringSlice(statistics), + ExtendedStatistics: aws.StringSlice(extendedStatistics), + Period: period, + Alias: alias, + }, nil +} + +func formatAlias(query *CloudWatchQuery, stat string, dimensions map[string]string) string { + data := map[string]string{} + data["region"] = query.Region + data["namespace"] = query.Namespace + data["metric"] = query.MetricName + data["stat"] = stat + for k, v := range dimensions { + data[k] = v + } + + result := aliasFormat.ReplaceAllFunc([]byte(query.Alias), func(in []byte) []byte { + labelName := strings.Replace(string(in), "{{", "", 1) + labelName = strings.Replace(labelName, "}}", "", 1) + labelName = strings.TrimSpace(labelName) + if val, exists := data[labelName]; exists { + return []byte(val) + } + + return in + }) + + return string(result) +} + +func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatchQuery) (*tsdb.QueryResult, error) { + queryRes := tsdb.NewQueryResult() + + var value float64 + for _, s := range append(query.Statistics, query.ExtendedStatistics...) { + series := tsdb.TimeSeries{ + Tags: map[string]string{}, + } + for _, d := range query.Dimensions { + series.Tags[*d.Name] = *d.Value + } + series.Name = formatAlias(query, *s, series.Tags) + + lastTimestamp := make(map[string]time.Time) + sort.Slice(resp.Datapoints, func(i, j int) bool { + return (*resp.Datapoints[i].Timestamp).Before(*resp.Datapoints[j].Timestamp) + }) + for _, v := range resp.Datapoints { + switch *s { + case "Average": + value = *v.Average + case "Maximum": + value = *v.Maximum + case "Minimum": + value = *v.Minimum + case "Sum": + value = *v.Sum + case "SampleCount": + value = *v.SampleCount + default: + if strings.Index(*s, "p") == 0 && v.ExtendedStatistics[*s] != nil { + value = *v.ExtendedStatistics[*s] + } + } + + // terminate gap of data points + timestamp := *v.Timestamp + if _, ok := lastTimestamp[*s]; ok { + nextTimestampFromLast := lastTimestamp[*s].Add(time.Duration(query.Period) * time.Second) + for timestamp.After(nextTimestampFromLast) { + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(nextTimestampFromLast.Unix()*1000))) + nextTimestampFromLast = nextTimestampFromLast.Add(time.Duration(query.Period) * time.Second) + } + } + lastTimestamp[*s] = timestamp + + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(value), float64(timestamp.Unix()*1000))) + } + + queryRes.Series = append(queryRes.Series, &series) + } + + return queryRes, nil +} diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go new file mode 100644 index 00000000000..5c322a44d56 --- /dev/null +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -0,0 +1,181 @@ +package cloudwatch + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/grafana/grafana/pkg/components/null" + "github.com/grafana/grafana/pkg/components/simplejson" + . "github.com/smartystreets/goconvey/convey" +) + +func TestCloudWatch(t *testing.T) { + Convey("CloudWatch", t, func() { + + Convey("can parse cloudwatch json model", func() { + json := ` + { + "region": "us-east-1", + "namespace": "AWS/ApplicationELB", + "metricName": "TargetResponseTime", + "dimensions": { + "LoadBalancer": "lb", + "TargetGroup": "tg" + }, + "statistics": [ + "Average", + "Maximum", + "p50.00", + "p90.00" + ], + "period": "60", + "alias": "{{metric}}_{{stat}}" + } + ` + modelJson, err := simplejson.NewJson([]byte(json)) + So(err, ShouldBeNil) + + res, err := parseQuery(modelJson) + So(err, ShouldBeNil) + So(res.Region, ShouldEqual, "us-east-1") + So(res.Namespace, ShouldEqual, "AWS/ApplicationELB") + So(res.MetricName, ShouldEqual, "TargetResponseTime") + So(len(res.Dimensions), ShouldEqual, 2) + So(*res.Dimensions[0].Name, ShouldEqual, "LoadBalancer") + So(*res.Dimensions[0].Value, ShouldEqual, "lb") + So(*res.Dimensions[1].Name, ShouldEqual, "TargetGroup") + So(*res.Dimensions[1].Value, ShouldEqual, "tg") + So(len(res.Statistics), ShouldEqual, 2) + So(*res.Statistics[0], ShouldEqual, "Average") + So(*res.Statistics[1], ShouldEqual, "Maximum") + So(len(res.ExtendedStatistics), ShouldEqual, 2) + So(*res.ExtendedStatistics[0], ShouldEqual, "p50.00") + So(*res.ExtendedStatistics[1], ShouldEqual, "p90.00") + So(res.Period, ShouldEqual, 60) + So(res.Alias, ShouldEqual, "{{metric}}_{{stat}}") + }) + + Convey("can parse cloudwatch response", func() { + timestamp := time.Unix(0, 0) + resp := &cloudwatch.GetMetricStatisticsOutput{ + Label: aws.String("TargetResponseTime"), + Datapoints: []*cloudwatch.Datapoint{ + { + Timestamp: aws.Time(timestamp), + Average: aws.Float64(10.0), + Maximum: aws.Float64(20.0), + ExtendedStatistics: map[string]*float64{ + "p50.00": aws.Float64(30.0), + "p90.00": aws.Float64(40.0), + }, + }, + }, + } + query := &CloudWatchQuery{ + Region: "us-east-1", + Namespace: "AWS/ApplicationELB", + MetricName: "TargetResponseTime", + Dimensions: []*cloudwatch.Dimension{ + { + Name: aws.String("LoadBalancer"), + Value: aws.String("lb"), + }, + { + Name: aws.String("TargetGroup"), + Value: aws.String("tg"), + }, + }, + Statistics: []*string{aws.String("Average"), aws.String("Maximum")}, + ExtendedStatistics: []*string{aws.String("p50.00"), aws.String("p90.00")}, + Period: 60, + Alias: "{{namespace}}_{{metric}}_{{stat}}", + } + + queryRes, err := parseResponse(resp, query) + So(err, ShouldBeNil) + So(queryRes.Series[0].Name, ShouldEqual, "AWS/ApplicationELB_TargetResponseTime_Average") + So(queryRes.Series[0].Tags["LoadBalancer"], ShouldEqual, "lb") + So(queryRes.Series[0].Tags["TargetGroup"], ShouldEqual, "tg") + So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String()) + So(queryRes.Series[1].Points[0][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) + So(queryRes.Series[2].Points[0][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) + So(queryRes.Series[3].Points[0][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) + }) + + Convey("terminate gap of data points", func() { + timestamp := time.Unix(0, 0) + resp := &cloudwatch.GetMetricStatisticsOutput{ + Label: aws.String("TargetResponseTime"), + Datapoints: []*cloudwatch.Datapoint{ + { + Timestamp: aws.Time(timestamp), + Average: aws.Float64(10.0), + Maximum: aws.Float64(20.0), + ExtendedStatistics: map[string]*float64{ + "p50.00": aws.Float64(30.0), + "p90.00": aws.Float64(40.0), + }, + }, + { + Timestamp: aws.Time(timestamp.Add(60 * time.Second)), + Average: aws.Float64(20.0), + Maximum: aws.Float64(30.0), + ExtendedStatistics: map[string]*float64{ + "p50.00": aws.Float64(40.0), + "p90.00": aws.Float64(50.0), + }, + }, + { + Timestamp: aws.Time(timestamp.Add(180 * time.Second)), + Average: aws.Float64(30.0), + Maximum: aws.Float64(40.0), + ExtendedStatistics: map[string]*float64{ + "p50.00": aws.Float64(50.0), + "p90.00": aws.Float64(60.0), + }, + }, + }, + } + query := &CloudWatchQuery{ + Region: "us-east-1", + Namespace: "AWS/ApplicationELB", + MetricName: "TargetResponseTime", + Dimensions: []*cloudwatch.Dimension{ + { + Name: aws.String("LoadBalancer"), + Value: aws.String("lb"), + }, + { + Name: aws.String("TargetGroup"), + Value: aws.String("tg"), + }, + }, + Statistics: []*string{aws.String("Average"), aws.String("Maximum")}, + ExtendedStatistics: []*string{aws.String("p50.00"), aws.String("p90.00")}, + Period: 60, + Alias: "{{namespace}}_{{metric}}_{{stat}}", + } + + queryRes, err := parseResponse(resp, query) + So(err, ShouldBeNil) + So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String()) + So(queryRes.Series[1].Points[0][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) + So(queryRes.Series[2].Points[0][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) + So(queryRes.Series[3].Points[0][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) + So(queryRes.Series[0].Points[1][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) + So(queryRes.Series[1].Points[1][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) + So(queryRes.Series[2].Points[1][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) + So(queryRes.Series[3].Points[1][0].String(), ShouldEqual, null.FloatFrom(50.0).String()) + So(queryRes.Series[0].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) + So(queryRes.Series[1].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) + So(queryRes.Series[2].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) + So(queryRes.Series[3].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) + So(queryRes.Series[0].Points[3][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) + So(queryRes.Series[1].Points[3][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) + So(queryRes.Series[2].Points[3][0].String(), ShouldEqual, null.FloatFrom(50.0).String()) + So(queryRes.Series[3].Points[3][0].String(), ShouldEqual, null.FloatFrom(60.0).String()) + }) + }) +} diff --git a/pkg/tsdb/cloudwatch/credentials.go b/pkg/tsdb/cloudwatch/credentials.go new file mode 100644 index 00000000000..784f3b729ac --- /dev/null +++ b/pkg/tsdb/cloudwatch/credentials.go @@ -0,0 +1,196 @@ +package cloudwatch + +import ( + "fmt" + "os" + "strings" + "sync" + "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/ec2metadata" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/aws/aws-sdk-go/service/sts" +) + +type cache struct { + credential *credentials.Credentials + expiration *time.Time +} + +var awsCredentialCache map[string]cache = make(map[string]cache) +var credentialCacheLock sync.RWMutex + +func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) { + cacheKey := dsInfo.AccessKey + ":" + dsInfo.Profile + ":" + dsInfo.AssumeRoleArn + credentialCacheLock.RLock() + if _, ok := awsCredentialCache[cacheKey]; ok { + if awsCredentialCache[cacheKey].expiration != nil && + (*awsCredentialCache[cacheKey].expiration).After(time.Now().UTC()) { + result := awsCredentialCache[cacheKey].credential + credentialCacheLock.RUnlock() + return result, nil + } + } + credentialCacheLock.RUnlock() + + accessKeyId := "" + secretAccessKey := "" + sessionToken := "" + var expiration *time.Time + expiration = nil + if dsInfo.AuthType == "arn" && strings.Index(dsInfo.AssumeRoleArn, "arn:aws:iam:") == 0 { + params := &sts.AssumeRoleInput{ + RoleArn: aws.String(dsInfo.AssumeRoleArn), + RoleSessionName: aws.String("GrafanaSession"), + DurationSeconds: aws.Int64(900), + } + + stsSess, err := session.NewSession() + if err != nil { + return nil, err + } + stsCreds := credentials.NewChainCredentials( + []credentials.Provider{ + &credentials.EnvProvider{}, + &credentials.SharedCredentialsProvider{Filename: "", Profile: dsInfo.Profile}, + remoteCredProvider(stsSess), + }) + stsConfig := &aws.Config{ + Region: aws.String(dsInfo.Region), + Credentials: stsCreds, + } + + sess, err := session.NewSession(stsConfig) + if err != nil { + return nil, err + } + svc := sts.New(sess, stsConfig) + resp, err := svc.AssumeRole(params) + if err != nil { + return nil, err + } + if resp.Credentials != nil { + accessKeyId = *resp.Credentials.AccessKeyId + secretAccessKey = *resp.Credentials.SecretAccessKey + sessionToken = *resp.Credentials.SessionToken + expiration = resp.Credentials.Expiration + } + } else { + now := time.Now() + e := now.Add(5 * time.Minute) + expiration = &e + } + + sess, err := session.NewSession() + if err != nil { + return nil, err + } + creds := credentials.NewChainCredentials( + []credentials.Provider{ + &credentials.StaticProvider{Value: credentials.Value{ + AccessKeyID: accessKeyId, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + }}, + &credentials.EnvProvider{}, + &credentials.StaticProvider{Value: credentials.Value{ + AccessKeyID: dsInfo.AccessKey, + SecretAccessKey: dsInfo.SecretKey, + }}, + &credentials.SharedCredentialsProvider{Filename: "", Profile: dsInfo.Profile}, + remoteCredProvider(sess), + }) + + credentialCacheLock.Lock() + awsCredentialCache[cacheKey] = cache{ + credential: creds, + expiration: expiration, + } + credentialCacheLock.Unlock() + + return creds, 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` + + c := ec2metadata.New(sess) + return endpointcreds.NewProviderClient( + c.Client.Config, + c.Client.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} +} + +func (e *CloudWatchExecutor) getDsInfo(region string) *DatasourceInfo { + authType := e.DataSource.JsonData.Get("authType").MustString() + assumeRoleArn := e.DataSource.JsonData.Get("assumeRoleArn").MustString() + accessKey := "" + secretKey := "" + for key, value := range e.DataSource.SecureJsonData.Decrypt() { + if key == "accessKey" { + accessKey = value + } + if key == "secretKey" { + secretKey = value + } + } + + datasourceInfo := &DatasourceInfo{ + Region: region, + Profile: e.DataSource.Database, + AuthType: authType, + AssumeRoleArn: assumeRoleArn, + AccessKey: accessKey, + SecretKey: secretKey, + } + + return datasourceInfo +} + +func (e *CloudWatchExecutor) getAwsConfig(dsInfo *DatasourceInfo) (*aws.Config, error) { + creds, err := GetCredentials(dsInfo) + if err != nil { + return nil, err + } + + cfg := &aws.Config{ + Region: aws.String(dsInfo.Region), + Credentials: creds, + } + return cfg, nil +} + +func (e *CloudWatchExecutor) getClient(region string) (*cloudwatch.CloudWatch, error) { + datasourceInfo := e.getDsInfo(region) + cfg, err := e.getAwsConfig(datasourceInfo) + if err != nil { + return nil, err + } + + sess, err := session.NewSession(cfg) + if err != nil { + return nil, err + } + + client := cloudwatch.New(sess, cfg) + return client, nil +} diff --git a/pkg/api/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/credentials_test.go similarity index 100% rename from pkg/api/cloudwatch/cloudwatch_test.go rename to pkg/tsdb/cloudwatch/credentials_test.go diff --git a/pkg/api/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metric_find_query.go similarity index 68% rename from pkg/api/cloudwatch/metrics.go rename to pkg/tsdb/cloudwatch/metric_find_query.go index 16b496d6be6..b1ce507d27c 100644 --- a/pkg/api/cloudwatch/metrics.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -1,7 +1,9 @@ package cloudwatch import ( - "encoding/json" + "context" + "errors" + "reflect" "sort" "strings" "sync" @@ -11,14 +13,20 @@ import ( "github.com/aws/aws-sdk-go/aws/awsutil" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/tsdb" ) var metricsMap map[string][]string var dimensionsMap map[string][]string +type suggestData struct { + Text string + Value string +} + type CustomMetricsCache struct { Expire time.Time Cache []string @@ -30,7 +38,7 @@ var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCa func init() { metricsMap = map[string][]string{ "AWS/ApiGateway": {"4XXError", "5XXError", "CacheHitCount", "CacheMissCount", "Count", "IntegrationLatency", "Latency"}, - "AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "HealthyHostCount", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "IPv6ProcessedBytes", "IPv6RequestCount", "NewConnectionCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, + "AWS/ApplicationELB": {"ActiveConnectionCount", "ClientTLSNegotiationErrorCount", "HealthyHostCount", "HTTPCode_ELB_4XX_Count", "HTTPCode_ELB_5XX_Count", "HTTPCode_Target_2XX_Count", "HTTPCode_Target_3XX_Count", "HTTPCode_Target_4XX_Count", "HTTPCode_Target_5XX_Count", "IPv6ProcessedBytes", "IPv6RequestCount", "NewConnectionCount", "ProcessedBytes", "RejectedConnectionCount", "RequestCount", "RequestCountPerTarget", "TargetConnectionErrorCount", "TargetResponseTime", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, "AWS/AutoScaling": {"GroupMinSize", "GroupMaxSize", "GroupDesiredCapacity", "GroupInServiceInstances", "GroupPendingInstances", "GroupStandbyInstances", "GroupTerminatingInstances", "GroupTotalInstances"}, "AWS/Billing": {"EstimatedCharges"}, "AWS/CloudFront": {"Requests", "BytesDownloaded", "BytesUploaded", "TotalErrorRate", "4xxErrorRate", "5xxErrorRate"}, @@ -144,117 +152,355 @@ func init() { customMetricsDimensionsMap = make(map[string]map[string]map[string]*CustomMetricsCache) } +func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { + result := &tsdb.Response{ + Results: make(map[string]*tsdb.QueryResult), + } + firstQuery := queryContext.Queries[0] + queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: firstQuery.RefId} + + parameters := firstQuery.Model + subType := firstQuery.Model.Get("subtype").MustString() + var data []suggestData + var err error + switch subType { + case "regions": + data, err = e.handleGetRegions(ctx, parameters, queryContext) + break + case "namespaces": + data, err = e.handleGetNamespaces(ctx, parameters, queryContext) + break + case "metrics": + data, err = e.handleGetMetrics(ctx, parameters, queryContext) + break + case "dimension_keys": + data, err = e.handleGetDimensions(ctx, parameters, queryContext) + break + case "dimension_values": + data, err = e.handleGetDimensionValues(ctx, parameters, queryContext) + break + case "ebs_volume_ids": + data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext) + break + case "ec2_instance_attribute": + data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext) + break + } + + transformToTable(data, queryResult) + result.Results[firstQuery.RefId] = queryResult + return result, err +} + +func transformToTable(data []suggestData, result *tsdb.QueryResult) { + table := &tsdb.Table{ + Columns: make([]tsdb.TableColumn, 2), + Rows: make([]tsdb.RowValues, 0), + } + table.Columns[0].Text = "text" + table.Columns[1].Text = "value" + + for _, r := range data { + values := make([]interface{}, 2) + values[0] = r.Text + values[1] = r.Value + table.Rows = append(table.Rows, values) + } + result.Tables = append(result.Tables, table) + result.Meta.Set("rowCount", len(data)) +} + // Whenever this list is updated, frontend list should also be updated. // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html -func handleGetRegions(req *cwRequest, c *middleware.Context) { +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", "eu-central-1", "eu-west-1", "eu-west-2", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2", } - result := []interface{}{} + result := make([]suggestData, 0) for _, region := range regions { - result = append(result, util.DynMap{"text": region, "value": region}) + result = append(result, suggestData{Text: region, Value: region}) } - c.JSON(200, result) + return result, nil } -func handleGetNamespaces(req *cwRequest, c *middleware.Context) { +func (e *CloudWatchExecutor) handleGetNamespaces(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { keys := []string{} for key := range metricsMap { keys = append(keys, key) } - customNamespaces := req.DataSource.JsonData.Get("customMetricsNamespaces").MustString() + customNamespaces := e.DataSource.JsonData.Get("customMetricsNamespaces").MustString() if customNamespaces != "" { keys = append(keys, strings.Split(customNamespaces, ",")...) } sort.Sort(sort.StringSlice(keys)) - result := []interface{}{} + result := make([]suggestData, 0) for _, key := range keys { - result = append(result, util.DynMap{"text": key, "value": key}) + result = append(result, suggestData{Text: key, Value: key}) } - c.JSON(200, result) + return result, nil } -func handleGetMetrics(req *cwRequest, c *middleware.Context) { - reqParam := &struct { - Parameters struct { - Namespace string `json:"namespace"` - } `json:"parameters"` - }{} - - json.Unmarshal(req.Body, reqParam) +func (e *CloudWatchExecutor) handleGetMetrics(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + region := parameters.Get("region").MustString() + namespace := parameters.Get("namespace").MustString() var namespaceMetrics []string - if !isCustomMetrics(reqParam.Parameters.Namespace) { + if !isCustomMetrics(namespace) { var exists bool - if namespaceMetrics, exists = metricsMap[reqParam.Parameters.Namespace]; !exists { - c.JsonApiErr(404, "Unable to find namespace "+reqParam.Parameters.Namespace, nil) - return + if namespaceMetrics, exists = metricsMap[namespace]; !exists { + return nil, errors.New("Unable to find namespace " + namespace) } } else { var err error - cwData := req.GetDatasourceInfo() - cwData.Namespace = reqParam.Parameters.Namespace + dsInfo := e.getDsInfo(region) + dsInfo.Namespace = namespace - if namespaceMetrics, err = getMetricsForCustomMetrics(cwData, getAllMetrics); err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return + if namespaceMetrics, err = getMetricsForCustomMetrics(dsInfo, getAllMetrics); err != nil { + return nil, errors.New("Unable to call AWS API") } } sort.Sort(sort.StringSlice(namespaceMetrics)) - result := []interface{}{} + result := make([]suggestData, 0) for _, name := range namespaceMetrics { - result = append(result, util.DynMap{"text": name, "value": name}) + result = append(result, suggestData{Text: name, Value: name}) } - c.JSON(200, result) + return result, nil } -func handleGetDimensions(req *cwRequest, c *middleware.Context) { - reqParam := &struct { - Parameters struct { - Namespace string `json:"namespace"` - } `json:"parameters"` - }{} - - json.Unmarshal(req.Body, reqParam) +func (e *CloudWatchExecutor) handleGetDimensions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + region := parameters.Get("region").MustString() + namespace := parameters.Get("namespace").MustString() var dimensionValues []string - if !isCustomMetrics(reqParam.Parameters.Namespace) { + if !isCustomMetrics(namespace) { var exists bool - if dimensionValues, exists = dimensionsMap[reqParam.Parameters.Namespace]; !exists { - c.JsonApiErr(404, "Unable to find dimension "+reqParam.Parameters.Namespace, nil) - return + if dimensionValues, exists = dimensionsMap[namespace]; !exists { + return nil, errors.New("Unable to find dimension " + namespace) } } else { var err error - dsInfo := req.GetDatasourceInfo() - dsInfo.Namespace = reqParam.Parameters.Namespace + dsInfo := e.getDsInfo(region) + dsInfo.Namespace = namespace if dimensionValues, err = getDimensionsForCustomMetrics(dsInfo, getAllMetrics); err != nil { - c.JsonApiErr(500, "Unable to call AWS API", err) - return + return nil, errors.New("Unable to call AWS API") } } sort.Sort(sort.StringSlice(dimensionValues)) - result := []interface{}{} + result := make([]suggestData, 0) for _, name := range dimensionValues { - result = append(result, util.DynMap{"text": name, "value": name}) + result = append(result, suggestData{Text: name, Value: name}) } - c.JSON(200, result) + return result, nil } -func getAllMetrics(cwData *datasourceInfo) (cloudwatch.ListMetricsOutput, error) { - creds, err := getCredentials(cwData) +func (e *CloudWatchExecutor) handleGetDimensionValues(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + region := parameters.Get("region").MustString() + namespace := parameters.Get("namespace").MustString() + metricName := parameters.Get("metricName").MustString() + dimensionKey := parameters.Get("dimensionKey").MustString() + dimensionsJson := parameters.Get("dimensions").MustMap() + + var dimensions []*cloudwatch.DimensionFilter + for k, v := range dimensionsJson { + if vv, ok := v.(string); ok { + dimensions = append(dimensions, &cloudwatch.DimensionFilter{ + Name: aws.String(k), + Value: aws.String(vv), + }) + } + } + + metrics, err := e.cloudwatchListMetrics(region, namespace, metricName, dimensions) + if err != nil { + return nil, err + } + + result := make([]suggestData, 0) + dupCheck := make(map[string]bool) + for _, metric := range metrics.Metrics { + for _, dim := range metric.Dimensions { + if *dim.Name == dimensionKey { + if _, exists := dupCheck[*dim.Value]; exists { + continue + } + dupCheck[*dim.Value] = true + result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value}) + } + } + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Text < result[j].Text + }) + + return result, nil +} + +func (e *CloudWatchExecutor) handleGetEbsVolumeIds(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + region := parameters.Get("region").MustString() + instanceId := parameters.Get("instanceId").MustString() + + instanceIds := []*string{aws.String(instanceId)} + instances, err := e.ec2DescribeInstances(region, nil, instanceIds) + if err != nil { + return nil, err + } + + result := make([]suggestData, 0) + for _, mapping := range instances.Reservations[0].Instances[0].BlockDeviceMappings { + result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId}) + } + + return result, nil +} + +func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + region := parameters.Get("region").MustString() + attributeName := parameters.Get("attributeName").MustString() + filterJson := parameters.Get("filters").MustMap() + + var filters []*ec2.Filter + for k, v := range filterJson { + if vv, ok := v.([]string); ok { + var vvvv []*string + for _, vvv := range vv { + vvvv = append(vvvv, &vvv) + } + filters = append(filters, &ec2.Filter{ + Name: aws.String(k), + Values: vvvv, + }) + } + } + + instances, err := e.ec2DescribeInstances(region, filters, nil) + if err != nil { + return nil, err + } + + result := make([]suggestData, 0) + dupCheck := make(map[string]bool) + for _, reservation := range instances.Reservations { + for _, instance := range reservation.Instances { + tags := make(map[string]string) + for _, tag := range instance.Tags { + tags[*tag.Key] = *tag.Value + } + + var data string + if strings.Index(attributeName, "Tags.") == 0 { + tagName := attributeName[5:] + data = tags[tagName] + } else { + attributePath := strings.Split(attributeName, ".") + v := reflect.ValueOf(instance) + for _, key := range attributePath { + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil, errors.New("invalid attribute path") + } + v = v.FieldByName(key) + } + if attr, ok := v.Interface().(*string); ok { + data = *attr + } else { + return nil, errors.New("invalid attribute path") + } + } + + if _, exists := dupCheck[data]; exists { + continue + } + dupCheck[data] = true + result = append(result, suggestData{Text: data, Value: data}) + } + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Text < result[j].Text + }) + + return result, nil +} + +func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace string, metricName string, dimensions []*cloudwatch.DimensionFilter) (*cloudwatch.ListMetricsOutput, error) { + svc, err := e.getClient(region) + if err != nil { + return nil, err + } + + params := &cloudwatch.ListMetricsInput{ + Namespace: aws.String(namespace), + MetricName: aws.String(metricName), + Dimensions: dimensions, + } + + var resp cloudwatch.ListMetricsOutput + err = svc.ListMetricsPages(params, + func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { + metrics.M_Aws_CloudWatch_ListMetrics.Inc() + metrics, _ := awsutil.ValuesAtPath(page, "Metrics") + for _, metric := range metrics { + resp.Metrics = append(resp.Metrics, metric.(*cloudwatch.Metric)) + } + return !lastPage + }) + if err != nil { + return nil, errors.New("Failed to call cloudwatch:ListMetrics") + } + + return &resp, nil +} + +func (e *CloudWatchExecutor) ec2DescribeInstances(region string, filters []*ec2.Filter, instanceIds []*string) (*ec2.DescribeInstancesOutput, error) { + dsInfo := e.getDsInfo(region) + cfg, err := e.getAwsConfig(dsInfo) + if err != nil { + return nil, errors.New("Failed to call ec2:DescribeInstances") + } + sess, err := session.NewSession(cfg) + if err != nil { + return nil, errors.New("Failed to call ec2:DescribeInstances") + } + svc := ec2.New(sess, cfg) + + params := &ec2.DescribeInstancesInput{ + Filters: filters, + InstanceIds: instanceIds, + } + + var resp ec2.DescribeInstancesOutput + err = svc.DescribeInstancesPages(params, + func(page *ec2.DescribeInstancesOutput, lastPage bool) bool { + reservations, _ := awsutil.ValuesAtPath(page, "Reservations") + for _, reservation := range reservations { + resp.Reservations = append(resp.Reservations, reservation.(*ec2.Reservation)) + } + return !lastPage + }) + if err != nil { + return nil, errors.New("Failed to call ec2:DescribeInstances") + } + + return &resp, nil +} + +func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) { + creds, err := GetCredentials(cwData) if err != nil { return cloudwatch.ListMetricsOutput{}, err } @@ -291,7 +537,7 @@ func getAllMetrics(cwData *datasourceInfo) (cloudwatch.ListMetricsOutput, error) var metricsCacheLock sync.Mutex -func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*datasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { +func getMetricsForCustomMetrics(dsInfo *DatasourceInfo, getAllMetrics func(*DatasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { metricsCacheLock.Lock() defer metricsCacheLock.Unlock() @@ -328,7 +574,7 @@ func getMetricsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*data var dimensionsCacheLock sync.Mutex -func getDimensionsForCustomMetrics(dsInfo *datasourceInfo, getAllMetrics func(*datasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { +func getDimensionsForCustomMetrics(dsInfo *DatasourceInfo, getAllMetrics func(*DatasourceInfo) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { dimensionsCacheLock.Lock() defer dimensionsCacheLock.Unlock() diff --git a/pkg/api/cloudwatch/metrics_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go similarity index 88% rename from pkg/api/cloudwatch/metrics_test.go rename to pkg/tsdb/cloudwatch/metric_find_query_test.go index 4ac8a70a273..238e815fac1 100644 --- a/pkg/api/cloudwatch/metrics_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -11,13 +11,13 @@ import ( func TestCloudWatchMetrics(t *testing.T) { Convey("When calling getMetricsForCustomMetrics", t, func() { - dsInfo := &datasourceInfo{ + dsInfo := &DatasourceInfo{ Region: "us-east-1", Namespace: "Foo", Profile: "default", AssumeRoleArn: "", } - f := func(dsInfo *datasourceInfo) (cloudwatch.ListMetricsOutput, error) { + f := func(dsInfo *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) { return cloudwatch.ListMetricsOutput{ Metrics: []*cloudwatch.Metric{ { @@ -39,13 +39,13 @@ func TestCloudWatchMetrics(t *testing.T) { }) Convey("When calling getDimensionsForCustomMetrics", t, func() { - dsInfo := &datasourceInfo{ + dsInfo := &DatasourceInfo{ Region: "us-east-1", Namespace: "Foo", Profile: "default", AssumeRoleArn: "", } - f := func(dsInfo *datasourceInfo) (cloudwatch.ListMetricsOutput, error) { + f := func(dsInfo *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) { return cloudwatch.ListMetricsOutput{ Metrics: []*cloudwatch.Metric{ { diff --git a/pkg/tsdb/cloudwatch/types.go b/pkg/tsdb/cloudwatch/types.go new file mode 100644 index 00000000000..c2a5ab8c3d7 --- /dev/null +++ b/pkg/tsdb/cloudwatch/types.go @@ -0,0 +1,16 @@ +package cloudwatch + +import ( + "github.com/aws/aws-sdk-go/service/cloudwatch" +) + +type CloudWatchQuery struct { + Region string + Namespace string + MetricName string + Dimensions []*cloudwatch.Dimension + Statistics []*string + ExtendedStatistics []*string + Period int + Alias string +} diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index 5545168afbb..36c38804a01 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -11,26 +11,21 @@ import ( const rsIdentifier = `([_a-zA-Z0-9]+)` const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` -type SqlMacroEngine interface { - Interpolate(sql string) (string, error) -} - type MySqlMacroEngine struct { TimeRange *tsdb.TimeRange } -func NewMysqlMacroEngine(timeRange *tsdb.TimeRange) SqlMacroEngine { - return &MySqlMacroEngine{ - TimeRange: timeRange, - } +func NewMysqlMacroEngine() tsdb.SqlMacroEngine { + return &MySqlMacroEngine{} } -func (m *MySqlMacroEngine) Interpolate(sql string) (string, error) { +func (m *MySqlMacroEngine) Interpolate(timeRange *tsdb.TimeRange, sql string) (string, error) { + m.TimeRange = timeRange rExp, _ := regexp.Compile(sExpr) var macroError error - sql = ReplaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { - res, err := m.EvaluateMacro(groups[1], groups[2:]) + sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { + res, err := m.evaluateMacro(groups[1], groups[2:]) if err != nil && macroError == nil { macroError = err return "macro_error()" @@ -45,7 +40,7 @@ func (m *MySqlMacroEngine) Interpolate(sql string) (string, error) { return sql, nil } -func ReplaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { +func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { result := "" lastIndex := 0 @@ -62,7 +57,7 @@ func ReplaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]str return result + str[lastIndex:] } -func (m *MySqlMacroEngine) EvaluateMacro(name string, args []string) (string, error) { +func (m *MySqlMacroEngine) evaluateMacro(name string, args []string) (string, error) { switch name { case "__time": if len(args) == 0 { diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index d85133afa84..c92020d0aae 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -9,86 +9,60 @@ import ( func TestMacroEngine(t *testing.T) { Convey("MacroEngine", t, func() { + engine := &MySqlMacroEngine{} + timeRange := &tsdb.TimeRange{From: "5m", To: "now"} Convey("interpolate __time function", func() { - engine := &MySqlMacroEngine{} - - sql, err := engine.Interpolate("select $__time(time_column)") + sql, err := engine.Interpolate(nil, "select $__time(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select UNIX_TIMESTAMP(time_column) as time_sec") }) Convey("interpolate __time function wrapped in aggregation", func() { - engine := &MySqlMacroEngine{} - - sql, err := engine.Interpolate("select min($__time(time_column))") + sql, err := engine.Interpolate(nil, "select min($__time(time_column))") So(err, ShouldBeNil) So(sql, ShouldEqual, "select min(UNIX_TIMESTAMP(time_column) as time_sec)") }) Convey("interpolate __timeFilter function", func() { - engine := &MySqlMacroEngine{ - TimeRange: &tsdb.TimeRange{From: "5m", To: "now"}, - } - - sql, err := engine.Interpolate("WHERE $__timeFilter(time_column)") + sql, err := engine.Interpolate(timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "WHERE time_column >= FROM_UNIXTIME(18446744066914186738) AND time_column <= FROM_UNIXTIME(18446744066914187038)") }) Convey("interpolate __timeFrom function", func() { - engine := &MySqlMacroEngine{ - TimeRange: &tsdb.TimeRange{From: "5m", To: "now"}, - } - - sql, err := engine.Interpolate("select $__timeFrom(time_column)") + sql, err := engine.Interpolate(timeRange, "select $__timeFrom(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select FROM_UNIXTIME(18446744066914186738)") }) Convey("interpolate __timeTo function", func() { - engine := &MySqlMacroEngine{ - TimeRange: &tsdb.TimeRange{From: "5m", To: "now"}, - } - - sql, err := engine.Interpolate("select $__timeTo(time_column)") + sql, err := engine.Interpolate(timeRange, "select $__timeTo(time_column)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select FROM_UNIXTIME(18446744066914187038)") }) Convey("interpolate __unixEpochFilter function", func() { - engine := &MySqlMacroEngine{ - TimeRange: &tsdb.TimeRange{From: "5m", To: "now"}, - } - - sql, err := engine.Interpolate("select $__unixEpochFilter(18446744066914186738)") + sql, err := engine.Interpolate(timeRange, "select $__unixEpochFilter(18446744066914186738)") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914186738 >= 18446744066914186738 AND 18446744066914186738 <= 18446744066914187038") }) Convey("interpolate __unixEpochFrom function", func() { - engine := &MySqlMacroEngine{ - TimeRange: &tsdb.TimeRange{From: "5m", To: "now"}, - } - - sql, err := engine.Interpolate("select $__unixEpochFrom()") + sql, err := engine.Interpolate(timeRange, "select $__unixEpochFrom()") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914186738") }) Convey("interpolate __unixEpochTo function", func() { - engine := &MySqlMacroEngine{ - TimeRange: &tsdb.TimeRange{From: "5m", To: "now"}, - } - - sql, err := engine.Interpolate("select $__unixEpochTo()") + sql, err := engine.Interpolate(timeRange, "select $__unixEpochTo()") So(err, ShouldBeNil) So(sql, ShouldEqual, "select 18446744066914187038") diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 19aa50096b1..bdb48867b6e 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -6,142 +6,57 @@ import ( "database/sql" "fmt" "strconv" - "sync" "time" "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" - "github.com/go-xorm/xorm" "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/tsdb" ) -type MysqlExecutor struct { - engine *xorm.Engine - log log.Logger -} - -type engineCacheType struct { - cache map[int64]*xorm.Engine - versions map[int64]int - sync.Mutex -} - -var engineCache = engineCacheType{ - cache: make(map[int64]*xorm.Engine), - versions: make(map[int64]int), +type MysqlQueryEndpoint struct { + sqlEngine tsdb.SqlEngine + log log.Logger } func init() { - tsdb.RegisterTsdbQueryEndpoint("mysql", NewMysqlExecutor) + tsdb.RegisterTsdbQueryEndpoint("mysql", NewMysqlQueryEndpoint) } -func NewMysqlExecutor(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { - executor := &MysqlExecutor{ +func NewMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + endpoint := &MysqlQueryEndpoint{ log: log.New("tsdb.mysql"), } - err := executor.initEngine(datasource) - if err != nil { - return nil, err - } - - return executor, nil -} - -func (e *MysqlExecutor) initEngine(dsInfo *models.DataSource) error { - engineCache.Lock() - defer engineCache.Unlock() - - if engine, present := engineCache.cache[dsInfo.Id]; present { - if version, _ := engineCache.versions[dsInfo.Id]; version == dsInfo.Version { - e.engine = engine - return nil - } + endpoint.sqlEngine = &tsdb.DefaultSqlEngine{ + MacroEngine: NewMysqlMacroEngine(), } cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC", - dsInfo.User, - dsInfo.Password, + datasource.User, + datasource.Password, "tcp", - dsInfo.Url, - dsInfo.Database) + datasource.Url, + datasource.Database, + ) + endpoint.log.Debug("getEngine", "connection", cnnstr) - e.log.Debug("getEngine", "connection", cnnstr) - - engine, err := xorm.NewEngine("mysql", cnnstr) - engine.SetMaxOpenConns(10) - engine.SetMaxIdleConns(10) - if err != nil { - return err + if err := endpoint.sqlEngine.InitEngine("mysql", datasource, cnnstr); err != nil { + return nil, err } - engineCache.cache[dsInfo.Id] = engine - e.engine = engine - return nil + return endpoint, nil } -func (e *MysqlExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { - result := &tsdb.Response{ - Results: make(map[string]*tsdb.QueryResult), - } - - macroEngine := NewMysqlMacroEngine(tsdbQuery.TimeRange) - session := e.engine.NewSession() - defer session.Close() - db := session.DB() - - for _, query := range tsdbQuery.Queries { - rawSql := query.Model.Get("rawSql").MustString() - if rawSql == "" { - continue - } - - queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: query.RefId} - result.Results[query.RefId] = queryResult - - rawSql, err := macroEngine.Interpolate(rawSql) - if err != nil { - queryResult.Error = err - continue - } - - queryResult.Meta.Set("sql", rawSql) - - rows, err := db.Query(rawSql) - if err != nil { - queryResult.Error = err - continue - } - - defer rows.Close() - - format := query.Model.Get("format").MustString("time_series") - - switch format { - case "time_series": - err := e.TransformToTimeSeries(query, rows, queryResult) - if err != nil { - queryResult.Error = err - continue - } - case "table": - err := e.TransformToTable(query, rows, queryResult) - if err != nil { - queryResult.Error = err - continue - } - } - } - - return result, nil +// Query is the main function for the MysqlExecutor +func (e *MysqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { + return e.sqlEngine.Query(ctx, dsInfo, tsdbQuery, e.transformToTimeSeries, e.transformToTable) } -func (e MysqlExecutor) TransformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { +func (e MysqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { columnNames, err := rows.Columns() columnCount := len(columnNames) @@ -166,7 +81,7 @@ func (e MysqlExecutor) TransformToTable(query *tsdb.Query, rows *core.Rows, resu rowLimit := 1000000 rowCount := 0 - for ; rows.Next(); rowCount += 1 { + for ; rows.Next(); rowCount++ { if rowCount > rowLimit { return fmt.Errorf("MySQL query row limit exceeded, limit %d", rowLimit) } @@ -184,7 +99,7 @@ func (e MysqlExecutor) TransformToTable(query *tsdb.Query, rows *core.Rows, resu return nil } -func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) { +func (e MysqlQueryEndpoint) getTypedRowData(types []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) { values := make([]interface{}, len(types)) for i, stype := range types { @@ -248,7 +163,7 @@ func (e MysqlExecutor) getTypedRowData(types []*sql.ColumnType, rows *core.Rows) return values, nil } -func (e MysqlExecutor) TransformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { +func (e MysqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { pointsBySeries := make(map[string]*tsdb.TimeSeries) seriesByQueryOrder := list.New() columnNames, err := rows.Columns() @@ -261,7 +176,7 @@ func (e MysqlExecutor) TransformToTimeSeries(query *tsdb.Query, rows *core.Rows, rowLimit := 1000000 rowCount := 0 - for ; rows.Next(); rowCount += 1 { + for ; rows.Next(); rowCount++ { if rowCount > rowLimit { return fmt.Errorf("MySQL query row limit exceeded, limit %d", rowLimit) } diff --git a/pkg/tsdb/mysql/mysql_test.go b/pkg/tsdb/mysql/mysql_test.go index 988833a7a1f..55def0b4129 100644 --- a/pkg/tsdb/mysql/mysql_test.go +++ b/pkg/tsdb/mysql/mysql_test.go @@ -18,14 +18,16 @@ func TestMySQL(t *testing.T) { SkipConvey("MySQL", t, func() { x := InitMySQLTestDB(t) - executor := &MysqlExecutor{ - engine: x, - log: log.New("tsdb.mysql"), + endpoint := &MysqlQueryEndpoint{ + sqlEngine: &tsdb.DefaultSqlEngine{ + MacroEngine: NewMysqlMacroEngine(), + XormEngine: x, + }, + log: log.New("tsdb.mysql"), } sess := x.NewSession() defer sess.Close() - db := sess.DB() sql := "CREATE TABLE `mysql_types` (" sql += "`atinyint` tinyint(1)," @@ -70,14 +72,23 @@ func TestMySQL(t *testing.T) { _, err = sess.Exec(sql) So(err, ShouldBeNil) - Convey("TransformToTable should map MySQL column types to Go types", func() { - rows, err := db.Query("SELECT * FROM mysql_types") - defer rows.Close() + Convey("Query with Table format should map MySQL column types to Go types", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT * FROM mysql_types", + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["A"] So(err, ShouldBeNil) - queryResult := &tsdb.QueryResult{Meta: simplejson.New()} - err = executor.TransformToTable(nil, rows, queryResult) - So(err, ShouldBeNil) column := queryResult.Tables[0].Rows[0] So(*column[0].(*int8), ShouldEqual, 1) So(*column[1].(*string), ShouldEqual, "abc") diff --git a/pkg/tsdb/postgres/macros.go b/pkg/tsdb/postgres/macros.go new file mode 100644 index 00000000000..3ca31591cb0 --- /dev/null +++ b/pkg/tsdb/postgres/macros.go @@ -0,0 +1,99 @@ +package postgres + +import ( + "fmt" + "regexp" + "strings" + + "github.com/grafana/grafana/pkg/tsdb" +) + +//const rsString = `(?:"([^"]*)")`; +const rsIdentifier = `([_a-zA-Z0-9]+)` +const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` + +type PostgresMacroEngine struct { + TimeRange *tsdb.TimeRange +} + +func NewPostgresMacroEngine() tsdb.SqlMacroEngine { + return &PostgresMacroEngine{} +} + +func (m *PostgresMacroEngine) Interpolate(timeRange *tsdb.TimeRange, sql string) (string, error) { + m.TimeRange = timeRange + rExp, _ := regexp.Compile(sExpr) + var macroError error + + sql = replaceAllStringSubmatchFunc(rExp, sql, func(groups []string) string { + res, err := m.evaluateMacro(groups[1], strings.Split(groups[2], ",")) + if err != nil && macroError == nil { + macroError = err + return "macro_error()" + } + return res + }) + + if macroError != nil { + return "", macroError + } + + 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": + if len(args) == 0 { + return "", fmt.Errorf("missing time column argument for macro %v", name) + } + return fmt.Sprintf("%s AS \"time\"", args[0]), nil + case "__timeEpoch": + if len(args) == 0 { + return "", fmt.Errorf("missing time column argument for macro %v", name) + } + return fmt.Sprintf("extract(epoch from %s) as \"time\"", args[0]), nil + case "__timeFilter": + if len(args) == 0 { + return "", fmt.Errorf("missing time column argument for macro %v", name) + } + return fmt.Sprintf("%s >= to_timestamp(%d) AND %s <= to_timestamp(%d)", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + case "__timeFrom": + return fmt.Sprintf("to_timestamp(%d)", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + case "__timeTo": + return fmt.Sprintf("to_timestamp(%d)", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + case "__timeGroup": + if len(args) < 2 { + return "", fmt.Errorf("macro %v needs time column and interval", name) + } + return fmt.Sprintf("(extract(epoch from \"%s\")/extract(epoch from %s::interval))::int", args[0], args[1]), nil + case "__unixEpochFilter": + if len(args) == 0 { + return "", fmt.Errorf("missing time column argument for macro %v", name) + } + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], uint64(m.TimeRange.GetFromAsMsEpoch()/1000), args[0], uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + case "__unixEpochFrom": + return fmt.Sprintf("%d", uint64(m.TimeRange.GetFromAsMsEpoch()/1000)), nil + case "__unixEpochTo": + return fmt.Sprintf("%d", uint64(m.TimeRange.GetToAsMsEpoch()/1000)), nil + default: + return "", fmt.Errorf("Unknown macro %v", name) + } +} diff --git a/pkg/tsdb/postgres/macros_test.go b/pkg/tsdb/postgres/macros_test.go new file mode 100644 index 00000000000..f181780e6ea --- /dev/null +++ b/pkg/tsdb/postgres/macros_test.go @@ -0,0 +1,80 @@ +package postgres + +import ( + "testing" + + "github.com/grafana/grafana/pkg/tsdb" + . "github.com/smartystreets/goconvey/convey" +) + +func TestMacroEngine(t *testing.T) { + Convey("MacroEngine", t, func() { + engine := &PostgresMacroEngine{} + timeRange := &tsdb.TimeRange{From: "5m", To: "now"} + + Convey("interpolate __time function", func() { + sql, err := engine.Interpolate(nil, "select $__time(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select time_column AS \"time\"") + }) + + Convey("interpolate __time function wrapped in aggregation", func() { + sql, err := engine.Interpolate(nil, "select min($__time(time_column))") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select min(time_column AS \"time\")") + }) + + Convey("interpolate __timeFilter function", func() { + sql, err := engine.Interpolate(timeRange, "WHERE $__timeFilter(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "WHERE time_column >= to_timestamp(18446744066914186738) AND time_column <= to_timestamp(18446744066914187038)") + }) + + Convey("interpolate __timeFrom function", func() { + sql, err := engine.Interpolate(timeRange, "select $__timeFrom(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select to_timestamp(18446744066914186738)") + }) + + Convey("interpolate __timeGroup function", func() { + + sql, err := engine.Interpolate(timeRange, "GROUP BY $__timeGroup(time_column,'5m')") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "GROUP BY (extract(epoch from \"time_column\")/extract(epoch from '5m'::interval))::int") + }) + + Convey("interpolate __timeTo function", func() { + sql, err := engine.Interpolate(timeRange, "select $__timeTo(time_column)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select to_timestamp(18446744066914187038)") + }) + + Convey("interpolate __unixEpochFilter function", func() { + sql, err := engine.Interpolate(timeRange, "select $__unixEpochFilter(18446744066914186738)") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select 18446744066914186738 >= 18446744066914186738 AND 18446744066914186738 <= 18446744066914187038") + }) + + Convey("interpolate __unixEpochFrom function", func() { + sql, err := engine.Interpolate(timeRange, "select $__unixEpochFrom()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select 18446744066914186738") + }) + + Convey("interpolate __unixEpochTo function", func() { + sql, err := engine.Interpolate(timeRange, "select $__unixEpochTo()") + So(err, ShouldBeNil) + + So(sql, ShouldEqual, "select 18446744066914187038") + }) + + }) +} diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go new file mode 100644 index 00000000000..6fc9c89e7be --- /dev/null +++ b/pkg/tsdb/postgres/postgres.go @@ -0,0 +1,245 @@ +package postgres + +import ( + "container/list" + "context" + "fmt" + "strconv" + "time" + + "github.com/go-xorm/core" + "github.com/grafana/grafana/pkg/components/null" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/tsdb" +) + +type PostgresQueryEndpoint struct { + sqlEngine tsdb.SqlEngine + log log.Logger +} + +func init() { + tsdb.RegisterTsdbQueryEndpoint("postgres", NewPostgresQueryEndpoint) +} + +func NewPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { + endpoint := &PostgresQueryEndpoint{ + log: log.New("tsdb.postgres"), + } + + endpoint.sqlEngine = &tsdb.DefaultSqlEngine{ + MacroEngine: NewPostgresMacroEngine(), + } + + cnnstr := generateConnectionString(datasource) + endpoint.log.Debug("getEngine", "connection", cnnstr) + + if err := endpoint.sqlEngine.InitEngine("postgres", datasource, cnnstr); err != nil { + return nil, err + } + + return endpoint, nil +} + +func generateConnectionString(datasource *models.DataSource) string { + password := "" + for key, value := range datasource.SecureJsonData.Decrypt() { + if key == "password" { + password = value + break + } + } + + sslmode := datasource.JsonData.Get("sslmode").MustString("require") + return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", datasource.User, password, datasource.Url, datasource.Database, sslmode) +} + +func (e *PostgresQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { + return e.sqlEngine.Query(ctx, dsInfo, tsdbQuery, e.transformToTimeSeries, e.transformToTable) +} + +func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { + + columnNames, err := rows.Columns() + if err != nil { + return err + } + + table := &tsdb.Table{ + Columns: make([]tsdb.TableColumn, len(columnNames)), + Rows: make([]tsdb.RowValues, 0), + } + + for i, name := range columnNames { + table.Columns[i].Text = name + } + + rowLimit := 1000000 + rowCount := 0 + + for ; rows.Next(); rowCount++ { + if rowCount > rowLimit { + return fmt.Errorf("PostgreSQL query row limit exceeded, limit %d", rowLimit) + } + + values, err := e.getTypedRowData(rows) + if err != nil { + return err + } + + table.Rows = append(table.Rows, values) + } + + result.Tables = append(result.Tables, table) + result.Meta.Set("rowCount", rowCount) + return nil +} + +func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues, error) { + + types, err := rows.ColumnTypes() + if err != nil { + return nil, err + } + + values := make([]interface{}, len(types)) + valuePtrs := make([]interface{}, len(types)) + + for i := 0; i < len(types); i++ { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return nil, err + } + + // convert types not handled by lib/pq + // unhandled types are returned as []byte + for i := 0; i < len(types); i++ { + if value, ok := values[i].([]byte); ok == true { + switch types[i].DatabaseTypeName() { + case "NUMERIC": + if v, err := strconv.ParseFloat(string(value), 64); err == nil { + values[i] = v + } else { + e.log.Debug("Rows", "Error converting numeric to float", value) + } + case "UNKNOWN", "CIDR", "INET", "MACADDR": + // char literals have type UNKNOWN + values[i] = string(value) + default: + e.log.Debug("Rows", "Unknown database type", types[i].DatabaseTypeName(), "value", value) + values[i] = string(value) + } + } + } + + return values, nil +} + +func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error { + pointsBySeries := make(map[string]*tsdb.TimeSeries) + seriesByQueryOrder := list.New() + columnNames, err := rows.Columns() + + if err != nil { + return err + } + + rowLimit := 1000000 + rowCount := 0 + timeIndex := -1 + metricIndex := -1 + + // check columns of resultset + for i, col := range columnNames { + switch col { + case "time": + timeIndex = i + case "metric": + metricIndex = i + } + } + + if timeIndex == -1 { + return fmt.Errorf("Found no column named time") + } + + for rows.Next() { + var timestamp float64 + var value null.Float + var metric string + + if rowCount > rowLimit { + return fmt.Errorf("PostgreSQL query row limit exceeded, limit %d", rowLimit) + } + + values, err := e.getTypedRowData(rows) + if err != nil { + return err + } + + switch columnValue := values[timeIndex].(type) { + case int64: + timestamp = float64(columnValue * 1000) + case float64: + timestamp = columnValue * 1000 + case time.Time: + timestamp = float64(columnValue.Unix() * 1000) + default: + return fmt.Errorf("Invalid type for column time, must be of type timestamp or unix timestamp") + } + + if metricIndex >= 0 { + if columnValue, ok := values[metricIndex].(string); ok == true { + metric = columnValue + } else { + return fmt.Errorf("Column metric must be of type char,varchar or text") + } + } + + for i, col := range columnNames { + if i == timeIndex || i == metricIndex { + continue + } + + switch columnValue := values[i].(type) { + case int64: + value = null.FloatFrom(float64(columnValue)) + case float64: + value = null.FloatFrom(columnValue) + case nil: + value.Valid = false + default: + return fmt.Errorf("Value column must have numeric datatype, column: %s type: %T value: %v", col, columnValue, columnValue) + } + if metricIndex == -1 { + metric = col + } + e.appendTimePoint(pointsBySeries, seriesByQueryOrder, metric, timestamp, value) + rowCount++ + + } + } + + for elem := seriesByQueryOrder.Front(); elem != nil; elem = elem.Next() { + key := elem.Value.(string) + result.Series = append(result.Series, pointsBySeries[key]) + } + + result.Meta.Set("rowCount", rowCount) + return nil +} + +func (e PostgresQueryEndpoint) appendTimePoint(pointsBySeries map[string]*tsdb.TimeSeries, seriesByQueryOrder *list.List, metric string, timestamp float64, value null.Float) { + if series, exist := pointsBySeries[metric]; exist { + series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) + } else { + series := &tsdb.TimeSeries{Name: metric} + series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)}) + pointsBySeries[metric] = series + seriesByQueryOrder.PushBack(metric) + } + e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value) +} diff --git a/pkg/tsdb/postgres/postgres_test.go b/pkg/tsdb/postgres/postgres_test.go new file mode 100644 index 00000000000..75e8cb77f2e --- /dev/null +++ b/pkg/tsdb/postgres/postgres_test.go @@ -0,0 +1,125 @@ +package postgres + +import ( + "testing" + "time" + + "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/tsdb" + _ "github.com/lib/pq" + . "github.com/smartystreets/goconvey/convey" +) + +// To run this test, remove the Skip from SkipConvey +// and set up a PostgreSQL db named grafanatest and a user/password grafanatest/grafanatest +func TestPostgres(t *testing.T) { + SkipConvey("PostgreSQL", t, func() { + x := InitPostgresTestDB(t) + + endpoint := &PostgresQueryEndpoint{ + sqlEngine: &tsdb.DefaultSqlEngine{ + MacroEngine: NewPostgresMacroEngine(), + XormEngine: x, + }, + log: log.New("tsdb.postgres"), + } + + sess := x.NewSession() + defer sess.Close() + + sql := ` + CREATE TABLE postgres_types( + c00_smallint smallint, + c01_integer integer, + c02_bigint bigint, + + c03_real real, + c04_double double precision, + c05_decimal decimal(10,2), + c06_numeric numeric(10,2), + + c07_char char(10), + c08_varchar varchar(10), + c09_text text, + + c10_timestamp timestamp without time zone, + c11_timestamptz timestamp with time zone, + c12_date date, + c13_time time without time zone, + c14_timetz time with time zone, + c15_interval interval + ); + ` + _, err := sess.Exec(sql) + So(err, ShouldBeNil) + + sql = ` + INSERT INTO postgres_types VALUES( + 1,2,3, + 4.5,6.7,1.1,1.2, + 'char10','varchar10','text', + + now(),now(),now(),now(),now(),'15m'::interval + ); + ` + _, err = sess.Exec(sql) + So(err, ShouldBeNil) + + Convey("Query with Table format should map PostgreSQL column types to Go types", func() { + query := &tsdb.TsdbQuery{ + Queries: []*tsdb.Query{ + { + Model: simplejson.NewFromAny(map[string]interface{}{ + "rawSql": "SELECT * FROM postgres_types", + "format": "table", + }), + RefId: "A", + }, + }, + } + + resp, err := endpoint.Query(nil, nil, query) + queryResult := resp.Results["A"] + So(err, ShouldBeNil) + + column := queryResult.Tables[0].Rows[0] + So(column[0].(int64), ShouldEqual, 1) + So(column[1].(int64), ShouldEqual, 2) + So(column[2].(int64), ShouldEqual, 3) + So(column[3].(float64), ShouldEqual, 4.5) + So(column[4].(float64), ShouldEqual, 6.7) + // libpq doesnt properly convert decimal, numeric and char to go types but returns []uint8 instead + // So(column[5].(float64), ShouldEqual, 1.1) + // So(column[6].(float64), ShouldEqual, 1.2) + // So(column[7].(string), ShouldEqual, "char") + So(column[8].(string), ShouldEqual, "varchar10") + So(column[9].(string), ShouldEqual, "text") + + So(column[10].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[11].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[12].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[13].(time.Time), ShouldHaveSameTypeAs, time.Now()) + So(column[14].(time.Time), ShouldHaveSameTypeAs, time.Now()) + + // libpq doesnt properly convert interval to go types but returns []uint8 instead + // So(column[15].(time.Time), ShouldHaveSameTypeAs, time.Now()) + }) + }) +} + +func InitPostgresTestDB(t *testing.T) *xorm.Engine { + x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr) + + // x.ShowSQL() + + if err != nil { + t.Fatalf("Failed to init postgres db %v", err) + } + + sqlutil.CleanDB(x) + + return x +} diff --git a/pkg/tsdb/sql_engine.go b/pkg/tsdb/sql_engine.go new file mode 100644 index 00000000000..d79ca938bb4 --- /dev/null +++ b/pkg/tsdb/sql_engine.go @@ -0,0 +1,134 @@ +package tsdb + +import ( + "context" + "sync" + + "github.com/go-xorm/core" + "github.com/go-xorm/xorm" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" +) + +// SqlEngine is a wrapper class around xorm for relational database data sources. +type SqlEngine interface { + InitEngine(driverName string, dsInfo *models.DataSource, cnnstr string) error + Query( + ctx context.Context, + ds *models.DataSource, + query *TsdbQuery, + transformToTimeSeries func(query *Query, rows *core.Rows, result *QueryResult) error, + transformToTable func(query *Query, rows *core.Rows, result *QueryResult) error, + ) (*Response, error) +} + +// SqlMacroEngine interpolates macros into sql. It takes in the timeRange to be able to +// generate queries that use from and to. +type SqlMacroEngine interface { + Interpolate(timeRange *TimeRange, sql string) (string, error) +} + +type DefaultSqlEngine struct { + MacroEngine SqlMacroEngine + XormEngine *xorm.Engine +} + +type engineCacheType struct { + cache map[int64]*xorm.Engine + versions map[int64]int + sync.Mutex +} + +var engineCache = engineCacheType{ + cache: make(map[int64]*xorm.Engine), + versions: make(map[int64]int), +} + +// InitEngine creates the db connection and inits the xorm engine or loads it from the engine cache +func (e *DefaultSqlEngine) InitEngine(driverName string, dsInfo *models.DataSource, cnnstr string) error { + engineCache.Lock() + defer engineCache.Unlock() + + if engine, present := engineCache.cache[dsInfo.Id]; present { + if version, _ := engineCache.versions[dsInfo.Id]; version == dsInfo.Version { + e.XormEngine = engine + return nil + } + } + + engine, err := xorm.NewEngine(driverName, cnnstr) + engine.SetMaxOpenConns(10) + engine.SetMaxIdleConns(10) + if err != nil { + return err + } + + engineCache.cache[dsInfo.Id] = engine + e.XormEngine = engine + + return nil +} + +// Query is a default implementation of the Query method for an SQL data source. +// The caller of this function must implement transformToTimeSeries and transformToTable and +// pass them in as parameters. +func (e *DefaultSqlEngine) Query( + ctx context.Context, + dsInfo *models.DataSource, + tsdbQuery *TsdbQuery, + transformToTimeSeries func(query *Query, rows *core.Rows, result *QueryResult) error, + transformToTable func(query *Query, rows *core.Rows, result *QueryResult) error, +) (*Response, error) { + result := &Response{ + Results: make(map[string]*QueryResult), + } + + session := e.XormEngine.NewSession() + defer session.Close() + db := session.DB() + + for _, query := range tsdbQuery.Queries { + rawSql := query.Model.Get("rawSql").MustString() + if rawSql == "" { + continue + } + + queryResult := &QueryResult{Meta: simplejson.New(), RefId: query.RefId} + result.Results[query.RefId] = queryResult + + rawSql, err := e.MacroEngine.Interpolate(tsdbQuery.TimeRange, rawSql) + if err != nil { + queryResult.Error = err + continue + } + + queryResult.Meta.Set("sql", rawSql) + + rows, err := db.Query(rawSql) + if err != nil { + queryResult.Error = err + continue + } + + defer rows.Close() + + format := query.Model.Get("format").MustString("time_series") + + switch format { + case "time_series": + err := transformToTimeSeries(query, rows, queryResult) + if err != nil { + queryResult.Error = err + continue + } + case "table": + err := transformToTable(query, rows, queryResult) + if err != nil { + queryResult.Error = err + continue + } + } + } + + return result, nil +} diff --git a/pkg/tsdb/testdata/scenarios.go b/pkg/tsdb/testdata/scenarios.go index 073c220e838..0a7f1467933 100644 --- a/pkg/tsdb/testdata/scenarios.go +++ b/pkg/tsdb/testdata/scenarios.go @@ -29,6 +29,67 @@ func init() { logger.Debug("Initializing TestData Scenario") + registerScenario(&Scenario{ + Id: "exponential_heatmap_bucket_data", + Name: "Exponential heatmap bucket data", + + Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult { + to := context.TimeRange.GetToAsMsEpoch() + + var series []*tsdb.TimeSeries + start := 1 + factor := 2 + for i := 0; i < 10; i++ { + timeWalkerMs := context.TimeRange.GetFromAsMsEpoch() + serie := &tsdb.TimeSeries{Name: strconv.Itoa(start)} + start *= factor + + points := make(tsdb.TimeSeriesPoints, 0) + for j := int64(0); j < 100 && timeWalkerMs < to; j++ { + v := float64(rand.Int63n(100)) + points = append(points, tsdb.NewTimePoint(null.FloatFrom(v), float64(timeWalkerMs))) + timeWalkerMs += query.IntervalMs * 50 + } + + serie.Points = points + series = append(series, serie) + } + + queryRes := tsdb.NewQueryResult() + queryRes.Series = append(queryRes.Series, series...) + return queryRes + }, + }) + + registerScenario(&Scenario{ + Id: "linear_heatmap_bucket_data", + Name: "Linear heatmap bucket data", + + Handler: func(query *tsdb.Query, context *tsdb.TsdbQuery) *tsdb.QueryResult { + to := context.TimeRange.GetToAsMsEpoch() + + var series []*tsdb.TimeSeries + for i := 0; i < 10; i++ { + timeWalkerMs := context.TimeRange.GetFromAsMsEpoch() + serie := &tsdb.TimeSeries{Name: strconv.Itoa(i * 10)} + + points := make(tsdb.TimeSeriesPoints, 0) + for j := int64(0); j < 100 && timeWalkerMs < to; j++ { + v := float64(rand.Int63n(100)) + points = append(points, tsdb.NewTimePoint(null.FloatFrom(v), float64(timeWalkerMs))) + timeWalkerMs += query.IntervalMs * 50 + } + + serie.Points = points + series = append(series, serie) + } + + queryRes := tsdb.NewQueryResult() + queryRes.Series = append(queryRes.Series, series...) + return queryRes + }, + }) + registerScenario(&Scenario{ Id: "random_walk", Name: "Random Walk", diff --git a/public/app/app.ts b/public/app/app.ts index 059c7a6b3f0..369dce5d16f 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -1,23 +1,32 @@ -/// - -import 'bootstrap'; -import 'vendor/filesaver'; -import 'lodash-src'; -import 'angular-strap'; +import 'babel-polyfill'; +import 'file-saver'; +import 'lodash'; +import 'jquery'; +import 'angular'; import 'angular-route'; import 'angular-sanitize'; -import 'angular-dragdrop'; +import 'angular-native-dragdrop'; import 'angular-bindonce'; -import 'angular-ui'; import 'react'; import 'react-dom'; import 'ngreact'; +import 'vendor/bootstrap/bootstrap'; +import 'vendor/angular-ui/ui-bootstrap-tpls'; +import 'vendor/angular-other/angular-strap'; + import $ from 'jquery'; import angular from 'angular'; import config from 'app/core/config'; import _ from 'lodash'; import moment from 'moment'; + +// add move to lodash for backward compatabiltiy +_.move = function (array, fromIndex, toIndex) { + array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); + return array; +}; + import {coreModule} from './core/core'; export class GrafanaApp { diff --git a/public/app/boot.js b/public/app/boot.js deleted file mode 100644 index 26922e9d612..00000000000 --- a/public/app/boot.js +++ /dev/null @@ -1,19 +0,0 @@ -(function bootGrafana() { - 'use strict'; - - var systemLocate = System.locate; - System.locate = function(load) { - var System = this; - return Promise.resolve(systemLocate.call(this, load)).then(function(address) { - return address + System.cacheBust; - }); - }; - System.cacheBust = '?bust=' + Date.now(); - - System.import('app/app').then(function(app) { - app.default.init(); - }).catch(function(err) { - console.log('Loading app module failed: ', err); - }); - -})(); diff --git a/public/app/core/components/PasswordStrength.tsx b/public/app/core/components/PasswordStrength.tsx index e5e30f5d721..3f690f3d239 100644 --- a/public/app/core/components/PasswordStrength.tsx +++ b/public/app/core/components/PasswordStrength.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; -import coreModule from '../core_module'; +import React from 'react'; +import { react2AngularDirective } from 'app/core/utils/react2angular'; export interface IProps { password: string; @@ -15,16 +15,16 @@ export class PasswordStrength extends React.Component { let strengthText = "strength: strong like a bull."; let strengthClass = "password-strength-good"; - if (this.props.password.length < 4) { - strengthText = "strength: weak sauce."; - strengthClass = "password-strength-bad"; - } - if (this.props.password.length <= 8) { strengthText = "strength: you can do better."; strengthClass = "password-strength-ok"; } + if (this.props.password.length < 4) { + strengthText = "strength: weak sauce."; + strengthClass = "password-strength-bad"; + } + return (
{strengthText} @@ -33,6 +33,5 @@ export class PasswordStrength extends React.Component { } } -coreModule.directive('passwordStrength', function(reactDirective) { - return reactDirective(PasswordStrength, ['password']); -}); +react2AngularDirective('passwordStrength', PasswordStrength, ['password']); + diff --git a/public/app/core/components/code_editor/code_editor.ts b/public/app/core/components/code_editor/code_editor.ts index 61ebb17d9b3..2615a635c7e 100644 --- a/public/app/core/components/code_editor/code_editor.ts +++ b/public/app/core/components/code_editor/code_editor.ts @@ -26,60 +26,31 @@ * Ctrl-Enter (Command-Enter): run onChange() function */ -/// -import _ from 'lodash'; import coreModule from 'app/core/core_module'; import config from 'app/core/config'; -import ace from 'ace'; +import ace from 'brace'; +import './theme-grafana-dark'; +import 'brace/ext/language_tools'; +import 'brace/theme/textmate'; +import 'brace/mode/text'; +import 'brace/snippets/text'; +import 'brace/mode/sql'; +import 'brace/snippets/sql'; -const ACE_SRC_BASE = "public/vendor/npm/ace-builds/src-noconflict/"; -const DEFAULT_THEME_DARK = "grafana-dark"; -const DEFAULT_THEME_LIGHT = "textmate"; +const DEFAULT_THEME_DARK = "ace/theme/grafana-dark"; +const DEFAULT_THEME_LIGHT = "ace/theme/textmate"; const DEFAULT_MODE = "text"; const DEFAULT_MAX_LINES = 10; const DEFAULT_TAB_SIZE = 2; const DEFAULT_BEHAVIOURS = true; -const GRAFANA_MODULES = ['theme-grafana-dark']; -const GRAFANA_MODULE_BASE = "public/app/core/components/code_editor/"; - -// Trick for loading additional modules -function setModuleUrl(moduleType, name, pluginBaseUrl = null) { - let baseUrl = ACE_SRC_BASE; - let aceModeName = `ace/${moduleType}/${name}`; - let moduleName = `${moduleType}-${name}`; - let componentName = `${moduleName}.js`; - - if (_.includes(GRAFANA_MODULES, moduleName)) { - baseUrl = GRAFANA_MODULE_BASE; - } - - if (pluginBaseUrl) { - baseUrl = pluginBaseUrl + '/'; - } - - if (moduleType === 'snippets') { - componentName = `${moduleType}/${name}.js`; - } - - ace.config.setModuleUrl(aceModeName, baseUrl + componentName); -} - -setModuleUrl("ext", "language_tools"); -setModuleUrl("mode", "text"); -setModuleUrl("snippets", "text"); - let editorTemplate = `
`; function link(scope, elem, attrs) { - let lightTheme = config.bootData.user.lightTheme; - let default_theme = lightTheme ? DEFAULT_THEME_LIGHT : DEFAULT_THEME_DARK; - // Options let langMode = attrs.mode || DEFAULT_MODE; let maxLines = attrs.maxLines || DEFAULT_MAX_LINES; let showGutter = attrs.showGutter !== undefined; - let theme = attrs.theme || default_theme; let tabSize = attrs.tabSize || DEFAULT_TAB_SIZE; let behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS; @@ -103,10 +74,10 @@ function link(scope, elem, attrs) { // disable depreacation warning codeEditor.$blockScrolling = Infinity; // Padding hacks - codeEditor.renderer.setScrollMargin(15, 15); + (codeEditor.renderer).setScrollMargin(15, 15); codeEditor.renderer.setPadding(10); - setThemeMode(theme); + setThemeMode(); setLangMode(langMode); setEditorContent(scope.content); @@ -162,44 +133,31 @@ function link(scope, elem, attrs) { }); function setLangMode(lang) { - let aceModeName = `ace/mode/${lang}`; - setModuleUrl("mode", lang, scope.datasource.meta.baseUrl || null); - setModuleUrl("snippets", lang, scope.datasource.meta.baseUrl || null); - editorSession.setMode(aceModeName); - - ace.config.loadModule("ace/ext/language_tools", (language_tools) => { - codeEditor.setOptions({ - enableBasicAutocompletion: true, - enableLiveAutocompletion: true, - enableSnippets: true - }); - - if (scope.getCompleter()) { - // make copy of array as ace seems to share completers array between instances - codeEditor.completers = codeEditor.completers.slice(); - codeEditor.completers.push(scope.getCompleter()); - } + ace.acequire("ace/ext/language_tools"); + codeEditor.setOptions({ + enableBasicAutocompletion: true, + enableLiveAutocompletion: true, + enableSnippets: true }); + + if (scope.getCompleter()) { + // make copy of array as ace seems to share completers array between instances + const anyEditor = codeEditor; + anyEditor.completers = anyEditor.completers.slice(); + anyEditor.completers.push(scope.getCompleter()); + } + + let aceModeName = `ace/mode/${lang}`; + editorSession.setMode(aceModeName); } - function setThemeMode(theme) { - setModuleUrl("theme", theme); - let themeModule = `ace/theme/${theme}`; - ace.config.loadModule(themeModule, (theme_module) => { - // Check is theme light or dark and fix if needed - let lightTheme = config.bootData.user.lightTheme; - let fixedTheme = theme; - if (lightTheme && theme_module.isDark) { - fixedTheme = DEFAULT_THEME_LIGHT; - } else if (!lightTheme && !theme_module.isDark) { - fixedTheme = DEFAULT_THEME_DARK; - } - setModuleUrl("theme", fixedTheme); - themeModule = `ace/theme/${fixedTheme}`; - codeEditor.setTheme(themeModule); + function setThemeMode() { + let theme = DEFAULT_THEME_DARK; + if (config.bootData.user.lightTheme) { + theme = DEFAULT_THEME_LIGHT; + } - elem.addClass("gf-code-editor--theme-loaded"); - }); + codeEditor.setTheme(theme); } function setEditorContent(value) { diff --git a/public/app/core/components/code_editor/theme-grafana-dark.js b/public/app/core/components/code_editor/theme-grafana-dark.js index 621ec2c5efb..a48715e698e 100644 --- a/public/app/core/components/code_editor/theme-grafana-dark.js +++ b/public/app/core/components/code_editor/theme-grafana-dark.js @@ -1,6 +1,6 @@ /* jshint ignore:start */ -ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"], function(require, exports, module) { +ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"], function(acequire, exports, module) { "use strict"; exports.isDark = true; @@ -109,7 +109,7 @@ ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"] background: url(data:image/png;base64,ivborw0kggoaaaansuheugaaaaeaaaaccayaaaczgbynaaaaekleqvqimwpq0fd0zxbzd/wpaajvaoxesgneaaaaaelftksuqmcc) right repeat-y\ }"; - var dom = require("../lib/dom"); + var dom = acequire("../lib/dom"); dom.importCssString(exports.cssText, exports.cssClass); }); diff --git a/public/app/core/components/colorpicker.ts b/public/app/core/components/colorpicker.ts deleted file mode 100644 index f64903ad0b6..00000000000 --- a/public/app/core/components/colorpicker.ts +++ /dev/null @@ -1,69 +0,0 @@ -/// - -import coreModule from 'app/core/core_module'; - -var template = ` -
-
- - - -
- -

-   -

-
-`; - -export class ColorPickerCtrl { - colors: any; - autoClose: boolean; - series: any; - showAxisControls: boolean; - - /** @ngInject */ - constructor(private $scope, $rootScope) { - this.colors = $rootScope.colors; - this.autoClose = $scope.autoClose; - this.series = $scope.series; - } - - toggleAxis(yaxis) { - this.$scope.toggleAxis(); - - if (this.$scope.autoClose) { - this.$scope.dismiss(); - } - } - - colorSelected(color) { - this.$scope.colorSelected(color); - if (this.$scope.autoClose) { - this.$scope.dismiss(); - } - } -} - -export function colorPicker() { - return { - restrict: 'E', - controller: ColorPickerCtrl, - bindToController: true, - controllerAs: 'ctrl', - template: template, - }; -} - -coreModule.directive('gfColorPicker', colorPicker); diff --git a/public/app/core/components/colorpicker/ColorPalette.tsx b/public/app/core/components/colorpicker/ColorPalette.tsx new file mode 100644 index 00000000000..127f2d328ca --- /dev/null +++ b/public/app/core/components/colorpicker/ColorPalette.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { sortedColors } from 'app/core/utils/colors'; + +export interface IProps { + color: string; + onColorSelect: (c: string) => void; +} + +export class GfColorPalette extends React.Component { + paletteColors: string[]; + + constructor(props) { + super(props); + this.paletteColors = sortedColors; + this.onColorSelect = this.onColorSelect.bind(this); + } + + onColorSelect(color) { + return () => { + this.props.onColorSelect(color); + }; + } + + render() { + const colorPaletteItems = this.paletteColors.map(paletteColor => { + const cssClass = paletteColor.toLowerCase() === this.props.color.toLowerCase() ? 'fa-circle-o' : 'fa-circle'; + return ( + +   + + ); + }); + return ( +
+

{colorPaletteItems}

+
+ ); + } +} + diff --git a/public/app/core/components/colorpicker/ColorPicker.tsx b/public/app/core/components/colorpicker/ColorPicker.tsx new file mode 100644 index 00000000000..dbba75636d0 --- /dev/null +++ b/public/app/core/components/colorpicker/ColorPicker.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import $ from 'jquery'; +import Drop from 'tether-drop'; +import { ColorPickerPopover } from './ColorPickerPopover'; +import { react2AngularDirective } from 'app/core/utils/react2angular'; + +export interface IProps { + color: string; + onChange: (c: string) => void; +} + +export class ColorPicker extends React.Component { + pickerElem: any; + colorPickerDrop: any; + + constructor(props) { + super(props); + this.openColorPicker = this.openColorPicker.bind(this); + this.closeColorPicker = this.closeColorPicker.bind(this); + this.setPickerElem = this.setPickerElem.bind(this); + this.onColorSelect = this.onColorSelect.bind(this); + } + + setPickerElem(elem) { + this.pickerElem = $(elem); + } + + openColorPicker() { + const dropContent = ; + + let dropContentElem = document.createElement('div'); + ReactDOM.render(dropContent, dropContentElem); + + let drop = new Drop({ + target: this.pickerElem[0], + content: dropContentElem, + position: 'top center', + classes: 'drop-popover', + openOn: 'click', + hoverCloseDelay: 200, + tetherOptions: { + constraints: [{ to: 'scrollParent', attachment: 'none both' }], + }, + }); + + drop.on('close', this.closeColorPicker); + + this.colorPickerDrop = drop; + this.colorPickerDrop.open(); + } + + closeColorPicker() { + setTimeout(() => { + if (this.colorPickerDrop && this.colorPickerDrop.tether) { + this.colorPickerDrop.destroy(); + } + }, 100); + } + + onColorSelect(color) { + this.props.onChange(color); + } + + render() { + return ( +
+
+
+
+
+ ); + } +} + +react2AngularDirective('colorPicker', ColorPicker, [ + 'color', + ['onChange', { watchDepth: 'reference', wrapApply: true }], +]); diff --git a/public/app/core/components/colorpicker/ColorPickerPopover.tsx b/public/app/core/components/colorpicker/ColorPickerPopover.tsx new file mode 100644 index 00000000000..270e82ffdd6 --- /dev/null +++ b/public/app/core/components/colorpicker/ColorPickerPopover.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import $ from 'jquery'; +import tinycolor from 'tinycolor2'; +import { GfColorPalette } from './ColorPalette'; +import { GfSpectrumPicker } from './SpectrumPicker'; + +const DEFAULT_COLOR = '#000000'; + +export interface IProps { + color: string; + onColorSelect: (c: string) => void; +} + +export class ColorPickerPopover extends React.Component { + pickerNavElem: any; + + constructor(props) { + super(props); + this.state = { + tab: 'palette', + color: this.props.color || DEFAULT_COLOR, + colorString: this.props.color || DEFAULT_COLOR + }; + } + + setPickerNavElem(elem) { + this.pickerNavElem = $(elem); + } + + setColor(color) { + let newColor = tinycolor(color); + if (newColor.isValid()) { + this.setState({ + color: newColor.toString(), + colorString: newColor.toString() + }); + this.props.onColorSelect(color); + } + } + + sampleColorSelected(color) { + this.setColor(color); + } + + spectrumColorSelected(color) { + let rgbColor = color.toRgbString(); + this.setColor(rgbColor); + } + + onColorStringChange(e) { + let colorString = e.target.value; + this.setState({ + colorString: colorString + }); + + let newColor = tinycolor(colorString); + if (newColor.isValid()) { + // Update only color state + this.setState({ + color: newColor.toString(), + }); + this.props.onColorSelect(newColor); + } + } + + onColorStringBlur(e) { + let colorString = e.target.value; + this.setColor(colorString); + } + + componentDidMount() { + this.pickerNavElem.find('li:first').addClass('active'); + this.pickerNavElem.on('show', (e) => { + // use href attr (#name => name) + let tab = e.target.hash.slice(1); + this.setState({ + tab: tab + }); + }); + } + + render() { + const paletteTab = ( +
+ +
+ ); + const spectrumTab = ( +
+ +
+ ); + const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab; + + return ( +
+ +
+ {currentTab} +
+
+ + +
+
+ ); + } +} diff --git a/public/app/core/components/colorpicker/SeriesColorPicker.tsx b/public/app/core/components/colorpicker/SeriesColorPicker.tsx new file mode 100644 index 00000000000..6b6d387a2b2 --- /dev/null +++ b/public/app/core/components/colorpicker/SeriesColorPicker.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { ColorPickerPopover } from './ColorPickerPopover'; +import { react2AngularDirective } from 'app/core/utils/react2angular'; + +export interface IProps { + series: any; + onColorChange: (color: string) => void; + onToggleAxis: () => void; +} + +export class SeriesColorPicker extends React.Component { + constructor(props) { + super(props); + this.onColorChange = this.onColorChange.bind(this); + this.onToggleAxis = this.onToggleAxis.bind(this); + } + + onColorChange(color) { + this.props.onColorChange(color); + } + + onToggleAxis() { + this.props.onToggleAxis(); + } + + renderAxisSelection() { + const leftButtonClass = this.props.series.yaxis === 1 ? 'btn-success' : 'btn-inverse'; + const rightButtonClass = this.props.series.yaxis === 2 ? 'btn-success' : 'btn-inverse'; + + return ( +
+ + + +
+ ); + } + + render() { + return ( +
+ {this.props.series && this.renderAxisSelection()} + +
+ ); + } +} + +react2AngularDirective('seriesColorPicker', SeriesColorPicker, ['series', 'onColorChange', 'onToggleAxis']); diff --git a/public/app/core/components/colorpicker/SpectrumPicker.tsx b/public/app/core/components/colorpicker/SpectrumPicker.tsx new file mode 100644 index 00000000000..254f06cf145 --- /dev/null +++ b/public/app/core/components/colorpicker/SpectrumPicker.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import _ from 'lodash'; +import $ from 'jquery'; +import 'vendor/spectrum'; + +export interface IProps { + color: string; + options: object; + onColorSelect: (c: string) => void; +} + +export class GfSpectrumPicker extends React.Component { + elem: any; + isMoving: boolean; + + constructor(props) { + super(props); + this.onSpectrumMove = this.onSpectrumMove.bind(this); + this.setComponentElem = this.setComponentElem.bind(this); + } + + setComponentElem(elem) { + this.elem = $(elem); + } + + onSpectrumMove(color) { + this.isMoving = true; + this.props.onColorSelect(color); + } + + componentDidMount() { + let spectrumOptions = _.assignIn({ + flat: true, + showAlpha: true, + showButtons: false, + color: this.props.color, + appendTo: this.elem, + move: this.onSpectrumMove, + }, this.props.options); + + this.elem.spectrum(spectrumOptions); + this.elem.spectrum('show'); + this.elem.spectrum('set', this.props.color); + } + + componentWillUpdate(nextProps) { + // If user move pointer over spectrum field this produce 'move' event and component + // may update props.color. We don't want to update spectrum color in this case, so we can use + // isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which + // is called after updating occurs (when user finished moving). + if (!this.isMoving) { + this.elem.spectrum('set', nextProps.color); + } + } + + componentDidUpdate() { + if (this.isMoving) { + this.isMoving = false; + } + } + + componentWillUnmount() { + this.elem.spectrum('destroy'); + } + + render() { + return ( +
+ ); + } +} + diff --git a/public/app/core/components/colorpicker/spectrum_picker.ts b/public/app/core/components/colorpicker/spectrum_picker.ts new file mode 100644 index 00000000000..183cebffe2b --- /dev/null +++ b/public/app/core/components/colorpicker/spectrum_picker.ts @@ -0,0 +1,24 @@ +/** + * Wrapper for the new ngReact directive for backward compatibility. + * Allows remaining untouched in outdated plugins. + * Technically, it's just a wrapper for react component with two-way data binding support. + */ +import coreModule from '../../core_module'; + +/** @ngInject */ +export function spectrumPicker() { + return { + restrict: 'E', + require: 'ngModel', + scope: true, + replace: true, + template: '', + link: function(scope, element, attrs, ngModel) { + scope.ngModel = ngModel; + scope.onColorChange = (color) => { + ngModel.$setViewValue(color); + }; + } + }; +} +coreModule.directive('spectrumPicker', spectrumPicker); diff --git a/public/app/core/components/dashboard_selector.ts b/public/app/core/components/dashboard_selector.ts index f68e70a17c0..7ec9f681520 100644 --- a/public/app/core/components/dashboard_selector.ts +++ b/public/app/core/components/dashboard_selector.ts @@ -4,9 +4,6 @@ import coreModule from 'app/core/core_module'; var template = ` - - Not finding dashboard you want? Star it first, then it should appear in this select box. - `; export class DashboardSelectorCtrl { diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 0eee3ae43fd..8852da4a436 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -7,6 +7,7 @@ import $ from 'jquery'; import coreModule from 'app/core/core_module'; import {profiler} from 'app/core/profiler'; import appEvents from 'app/core/app_events'; +import Drop from 'tether-drop'; export class GrafanaCtrl { @@ -117,6 +118,11 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { if (data.params.kiosk) { appEvents.emit('toggle-kiosk-mode'); } + + // close all drops + for (let drop of Drop.drops) { + drop.destroy(); + } }); // handle kiosk mode diff --git a/public/app/core/components/info_popover.ts b/public/app/core/components/info_popover.ts index a6ea853b7bb..954e84a3baa 100644 --- a/public/app/core/components/info_popover.ts +++ b/public/app/core/components/info_popover.ts @@ -27,6 +27,8 @@ export function infoPopover() { transclude(function(clone, newScope) { var content = document.createElement("div"); + content.className = 'markdown-html'; + _.each(clone, (node) => { content.appendChild(node); }); diff --git a/public/app/core/components/jsontree/jsontree.ts b/public/app/core/components/jsontree/jsontree.ts index 5c39e2ab53c..52fb64e1c87 100644 --- a/public/app/core/components/jsontree/jsontree.ts +++ b/public/app/core/components/jsontree/jsontree.ts @@ -1,201 +1,22 @@ - -/** Created by: Alex Wendland (me@alexwendland.com), 2014-08-06 - * - * angular-json-tree - * - * Directive for creating a tree-view out of a JS Object. Only loads - * sub-nodes on demand in order to improve performance of rendering large - * objects. - * - * Attributes: - * - object (Object, 2-way): JS object to build the tree from - * - start-expanded (Boolean, 1-way, ?=true): should the tree default to expanded - * - * Usage: - * // In the controller - * scope.someObject = { - * test: 'hello', - * array: [1,1,2,3,5,8] - * }; - * // In the html - * - * - * Dependencies: - * - utils (json-tree.js) - * - ajsRecursiveDirectiveHelper (json-tree.js) - * - * Test: json-tree-test.js - */ - -import angular from 'angular'; import coreModule from 'app/core/core_module'; - -var utils = { - /* See link for possible type values to check against. - * http://stackoverflow.com/questions/4622952/json-object-containing-array - * - * Value Class Type - * ------------------------------------- - * "foo" String string - * new String("foo") String object - * 1.2 Number number - * new Number(1.2) Number object - * true Boolean boolean - * new Boolean(true) Boolean object - * new Date() Date object - * new Error() Error object - * [1,2,3] Array object - * new Array(1, 2, 3) Array object - * new Function("") Function function - * /abc/g RegExp object (function in Nitro/V8) - * new RegExp("meow") RegExp object (function in Nitro/V8) - * {} Object object - * new Object() Object object - */ - is: function is(obj, clazz) { - return Object.prototype.toString.call(obj).slice(8, -1) === clazz; - }, - - // See above for possible values - whatClass: function whatClass(obj) { - return Object.prototype.toString.call(obj).slice(8, -1); - }, - - // Iterate over an objects keyset - forKeys: function forKeys(obj, f) { - for (var key in obj) { - if (obj.hasOwnProperty(key) && typeof obj[key] !== 'function') { - if (f(key, obj[key])) { - break; - } - } - } - } -}; +import {JsonExplorer} from '../json_explorer/json_explorer'; coreModule.directive('jsonTree', [function jsonTreeDirective() { - return { + return{ restrict: 'E', scope: { object: '=', startExpanded: '@', rootName: '@', }, - template: '' - }; -}]); + link: function(scope, elem) { -coreModule.directive('jsonNode', ['ajsRecursiveDirectiveHelper', function jsonNodeDirective(ajsRecursiveDirectiveHelper) { - return { - restrict: 'E', - scope: { - key: '=', - value: '=', - startExpanded: '@' - }, - compile: function jsonNodeDirectiveCompile(elem) { - return ajsRecursiveDirectiveHelper.compile(elem, this); - }, - template: ' {{key}}' + - ' {{value}}' + - ' ' + - ' {{preview}}' + - '
    ' + - '
  • ' + - ' ' + - '
  • ' + - '
', - pre: function jsonNodeDirectiveLink(scope, elem, attrs) { - // Set value's type as Class for CSS styling - elem.addClass(utils.whatClass(scope.value).toLowerCase()); - // If the value is an Array or Object, use expandable view type - if (utils.is(scope.value, 'Object') || utils.is(scope.value, 'Array')) { - scope.isExpandable = true; - // Add expandable class for CSS usage - elem.addClass('expandable'); - // Setup preview text - var isArray = utils.is(scope.value, 'Array'); - scope.preview = isArray ? '[ ' : '{ '; - utils.forKeys(scope.value, function jsonNodeDirectiveLinkForKeys(key, value) { - if (value === null) { scope.value[key] = 'null'; } - if (isArray) { - scope.preview += value + ', '; - } else { - scope.preview += key + ': ' + value + ', '; - } - }); - scope.preview = scope.preview.substring(0, scope.preview.length - (scope.preview.length > 2 ? 2 : 0)) + (isArray ? ' ]' : ' }'); - // If directive initially has isExpanded set, also set shouldRender to true - if (scope.startExpanded) { - scope.shouldRender = true; - elem.addClass('expanded'); - } - // Setup isExpanded state handling - scope.isExpanded = scope.startExpanded; - scope.toggleExpanded = function jsonNodeDirectiveToggleExpanded() { - scope.isExpanded = !scope.isExpanded; - if (scope.isExpanded) { - elem.addClass('expanded'); - } else { - elem.removeClass('expanded'); - } - // For delaying subnode render until requested - scope.shouldRender = true; - }; - } else { - scope.isExpandable = false; - // Add expandable class for CSS usage - elem.addClass('not-expandable'); - } - } - }; -}]); + var jsonExp = new JsonExplorer(scope.object, 3, { + animateOpen: true + }); -/** Added by: Alex Wendland (me@alexwendland.com), 2014-08-09 - * Source: http://stackoverflow.com/questions/14430655/recursion-in-angular-directives - * - * Used to allow for recursion within directives - */ -coreModule.factory('ajsRecursiveDirectiveHelper', ['$compile', function RecursiveDirectiveHelper($compile) { - return { - /** - * Manually compiles the element, fixing the recursion loop. - * @param element - * @param [link] A post-link function, or an object with function(s) registered via pre and post properties. - * @returns An object containing the linking functions. - */ - compile: function RecursiveDirectiveHelperCompile(element, link) { - // Normalize the link parameter - if (angular.isFunction(link)) { - link = { - post: link - }; - } - - // Break the recursion loop by removing the contents - var contents = element.contents().remove(); - var compiledContents; - return { - pre: (link && link.pre) ? link.pre : null, - /** - * Compiles and re-adds the contents - */ - post: function RecursiveDirectiveHelperCompilePost(scope, element) { - // Compile the contents - if (!compiledContents) { - compiledContents = $compile(contents); - } - // Re-add the compiled contents to the element - compiledContents(scope, function (clone) { - element.append(clone); - }); - - // Call the post-linking function, if any - if (link && link.post) { - link.post.apply(null, arguments); - } - } - }; + const html = jsonExp.render(true); + elem.html(html); } }; }]); diff --git a/public/app/core/controllers/error_ctrl.js b/public/app/core/controllers/error_ctrl.js index cc711f07a22..fd4081186be 100644 --- a/public/app/core/controllers/error_ctrl.js +++ b/public/app/core/controllers/error_ctrl.js @@ -1,19 +1,21 @@ define([ 'angular', + 'app/core/config', '../core_module', ], -function (angular, coreModule) { +function (angular, config, coreModule) { 'use strict'; coreModule.default.controller('ErrorCtrl', function($scope, contextSrv, navModelSrv) { $scope.navModel = navModelSrv.getNotFoundNav(); + $scope.appSubUrl = config.appSubUrl; var showSideMenu = contextSrv.sidemenu; contextSrv.sidemenu = false; $scope.$on('$destroy', function() { - $scope.contextSrv.sidemenu = showSideMenu; + contextSrv.sidemenu = showSideMenu; }); }); diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 526504380ad..e28f8d2d7eb 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -1,6 +1,3 @@ -/// -/// - import "./directives/dash_class"; import "./directives/confirm_click"; import "./directives/dash_edit_link"; @@ -8,10 +5,8 @@ import "./directives/dropdown_typeahead"; import "./directives/metric_segment"; import "./directives/misc"; import "./directives/ng_model_on_blur"; -import "./directives/spectrum_picker"; import "./directives/tags"; import "./directives/value_select_dropdown"; -import "./directives/plugin_component"; import "./directives/rebuild_on_change"; import "./directives/give_focus"; import "./directives/diff-view"; @@ -20,12 +15,14 @@ import './partials'; import './components/jsontree/jsontree'; import './components/code_editor/code_editor'; import './utils/outline'; +import './components/colorpicker/ColorPicker'; +import './components/colorpicker/SeriesColorPicker'; +import './components/colorpicker/spectrum_picker'; import {grafanaAppDirective} from './components/grafana_app'; import {sideMenuDirective} from './components/sidemenu/sidemenu'; import {searchDirective} from './components/search/search'; import {infoPopover} from './components/info_popover'; -import {colorPicker} from './components/colorpicker'; import {navbarDirective} from './components/navbar/navbar'; import {arrayJoin} from './directives/array_join'; import {liveSrv} from './live/live_srv'; @@ -57,7 +54,6 @@ export { sideMenuDirective, navbarDirective, searchDirective, - colorPicker, liveSrv, layoutSelector, switchDirective, diff --git a/public/app/core/core_module.ts b/public/app/core/core_module.ts index 8ac4ae6e91f..f6c30e6cf15 100644 --- a/public/app/core/core_module.ts +++ b/public/app/core/core_module.ts @@ -1,4 +1,2 @@ -/// - import angular from 'angular'; export default angular.module('grafana.core', ['ngRoute']); diff --git a/public/app/core/directives/metric_segment.js b/public/app/core/directives/metric_segment.js index 2e9442c15a0..37352556819 100644 --- a/public/app/core/directives/metric_segment.js +++ b/public/app/core/directives/metric_segment.js @@ -79,7 +79,9 @@ function (_, $, coreModule) { $scope.$apply(function() { $scope.getOptions({ $query: query }).then(function(altSegments) { $scope.altSegments = altSegments; - options = _.map($scope.altSegments, function(alt) { return alt.value; }); + options = _.map($scope.altSegments, function(alt) { + return _.escape(alt.value); + }); // add custom values if (segment.custom !== 'false') { diff --git a/public/app/core/directives/rebuild_on_change.ts b/public/app/core/directives/rebuild_on_change.ts index 56f945f129a..db2634869c0 100644 --- a/public/app/core/directives/rebuild_on_change.ts +++ b/public/app/core/directives/rebuild_on_change.ts @@ -1,7 +1,4 @@ -/// - import $ from 'jquery'; - import coreModule from '../core_module'; function getBlockNodes(nodes) { @@ -21,6 +18,7 @@ function getBlockNodes(nodes) { return blockNodes || nodes; } +/** @ngInject **/ function rebuildOnChange($animate) { return { diff --git a/public/app/core/directives/spectrum_picker.js b/public/app/core/directives/spectrum_picker.js deleted file mode 100644 index fa1d0a82a47..00000000000 --- a/public/app/core/directives/spectrum_picker.js +++ /dev/null @@ -1,41 +0,0 @@ -define([ - 'angular', - '../core_module', - 'spectrum', -], -function (angular, coreModule) { - 'use strict'; - - coreModule.default.directive('spectrumPicker', function() { - return { - restrict: 'E', - require: 'ngModel', - scope: false, - replace: true, - template: "", - link: function(scope, element, attrs, ngModel) { - var input = element.find('input'); - var options = angular.extend({ - showAlpha: true, - showButtons: false, - color: ngModel.$viewValue, - change: function(color) { - scope.$apply(function() { - ngModel.$setViewValue(color.toRgbString()); - }); - } - }, scope.$eval(attrs.options)); - - ngModel.$render = function() { - input.spectrum('set', ngModel.$viewValue || ''); - }; - - input.spectrum(options); - - scope.$on('$destroy', function() { - input.spectrum('destroy'); - }); - } - }; - }); -}); diff --git a/public/app/core/directives/tags.js b/public/app/core/directives/tags.js index 627d516128a..a322673a342 100644 --- a/public/app/core/directives/tags.js +++ b/public/app/core/directives/tags.js @@ -2,7 +2,7 @@ define([ 'angular', 'jquery', '../core_module', - 'bootstrap-tagsinput', + 'vendor/tagsinput/bootstrap-tagsinput.js', ], function (angular, $, coreModule) { 'use strict'; @@ -88,6 +88,7 @@ function (angular, $, coreModule) { typeahead: { source: angular.isFunction(scope.$parent[attrs.typeaheadSource]) ? scope.$parent[attrs.typeaheadSource] : null }, + widthClass: attrs.widthClass, itemValue: getItemProperty(scope, attrs.itemvalue), itemText : getItemProperty(scope, attrs.itemtext), tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ? diff --git a/public/app/core/filters/filters.ts b/public/app/core/filters/filters.ts index 63f2bf28bc3..0f53df8b4fb 100644 --- a/public/app/core/filters/filters.ts +++ b/public/app/core/filters/filters.ts @@ -57,7 +57,8 @@ coreModule.filter('noXml', function() { }; }); -coreModule.filter('interpolateTemplateVars', function (templateSrv) { +/** @ngInject */ +function interpolateTemplateVars(templateSrv) { var filterFunc: any = function(text, scope) { var scopedVars; if (scope.ctrl) { @@ -71,6 +72,7 @@ coreModule.filter('interpolateTemplateVars', function (templateSrv) { filterFunc.$stateful = true; return filterFunc; -}); +} +coreModule.filter('interpolateTemplateVars', interpolateTemplateVars); export default {}; diff --git a/public/app/core/live/live_srv.ts b/public/app/core/live/live_srv.ts index 7b570f05df3..8e808e60e1a 100644 --- a/public/app/core/live/live_srv.ts +++ b/public/app/core/live/live_srv.ts @@ -1,9 +1,7 @@ -/// - import _ from 'lodash'; import config from 'app/core/config'; -import {Observable} from 'vendor/npm/rxjs/Observable'; +import {Observable} from 'rxjs/Observable'; export class LiveSrv { conn: any; diff --git a/public/app/core/nav_model_srv.ts b/public/app/core/nav_model_srv.ts index 4771f8c191c..dd61db5d346 100644 --- a/public/app/core/nav_model_srv.ts +++ b/public/app/core/nav_model_srv.ts @@ -163,7 +163,7 @@ export class NavModelSrv { menu.push({ title: 'Annotations', - icon: 'fa fa-fw fa-bolt', + icon: 'fa fa-fw fa-comment', clickHandler: () => dashNavCtrl.openEditView('annotations') }); diff --git a/public/app/core/partials.js b/public/app/core/partials.js deleted file mode 100644 index 4efe276393b..00000000000 --- a/public/app/core/partials.js +++ /dev/null @@ -1,2 +0,0 @@ -define([ -], function () {}); diff --git a/public/app/core/partials.ts b/public/app/core/partials.ts new file mode 100644 index 00000000000..fb70e30f96a --- /dev/null +++ b/public/app/core/partials.ts @@ -0,0 +1,4 @@ +var templates = (require).context('../', true, /\.html$/); +templates.keys().forEach(function(key) { + templates(key); +}); diff --git a/public/app/core/profiler.ts b/public/app/core/profiler.ts index 7b732e7fb43..e762b3fc4b3 100644 --- a/public/app/core/profiler.ts +++ b/public/app/core/profiler.ts @@ -1,5 +1,3 @@ -/// - import $ from 'jquery'; import angular from 'angular'; diff --git a/public/app/core/routes/bundle_loader.ts b/public/app/core/routes/bundle_loader.ts index 473ac66b4b9..b86f9fb1495 100644 --- a/public/app/core/routes/bundle_loader.ts +++ b/public/app/core/routes/bundle_loader.ts @@ -1,5 +1,3 @@ -/// - export class BundleLoader { lazy: any; diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts index 85301ec692c..1f9dbcae57c 100644 --- a/public/app/core/routes/routes.ts +++ b/public/app/core/routes/routes.ts @@ -1,18 +1,27 @@ -/// - import './dashboard_loaders'; - import coreModule from 'app/core/core_module'; -import {BundleLoader} from './bundle_loader'; /** @ngInject **/ function setupAngularRoutes($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); - var loadOrgBundle = new BundleLoader('app/features/org/all'); - var loadPluginsBundle = new BundleLoader('app/features/plugins/all'); - var loadAdminBundle = new BundleLoader('app/features/admin/admin'); - var loadAlertingBundle = new BundleLoader('app/features/alerting/all'); + var loadOrgBundle = { + lazy: ["$q", "$route", "$rootScope", ($q, $route, $rootScope) => { + return System.import('app/features/org/all'); + }] + }; + + var loadAdminBundle = { + lazy: ["$q", "$route", "$rootScope", ($q, $route, $rootScope) => { + return System.import('app/features/admin/admin'); + }] + }; + + var loadAlertingBundle = { + lazy: ["$q", "$route", "$rootScope", ($q, $route, $rootScope) => { + return System.import('app/features/alerting/all'); + }] + }; $routeProvider .when('/', { @@ -47,19 +56,16 @@ function setupAngularRoutes($routeProvider, $locationProvider) { templateUrl: 'public/app/features/plugins/partials/ds_list.html', controller : 'DataSourcesCtrl', controllerAs: 'ctrl', - resolve: loadPluginsBundle, }) .when('/datasources/edit/:id', { templateUrl: 'public/app/features/plugins/partials/ds_edit.html', controller : 'DataSourceEditCtrl', controllerAs: 'ctrl', - resolve: loadPluginsBundle, }) .when('/datasources/new', { templateUrl: 'public/app/features/plugins/partials/ds_edit.html', controller : 'DataSourceEditCtrl', controllerAs: 'ctrl', - resolve: loadPluginsBundle, }) .when('/org', { templateUrl: 'public/app/features/org/partials/orgDetails.html', @@ -175,19 +181,16 @@ function setupAngularRoutes($routeProvider, $locationProvider) { templateUrl: 'public/app/features/plugins/partials/plugin_list.html', controller: 'PluginListCtrl', controllerAs: 'ctrl', - resolve: loadPluginsBundle, }) .when('/plugins/:pluginId/edit', { templateUrl: 'public/app/features/plugins/partials/plugin_edit.html', controller: 'PluginEditCtrl', controllerAs: 'ctrl', - resolve: loadPluginsBundle, }) .when('/plugins/:pluginId/page/:slug', { templateUrl: 'public/app/features/plugins/partials/plugin_page.html', controller: 'AppPageCtrl', controllerAs: 'ctrl', - resolve: loadPluginsBundle, }) .when('/styleguide/:page?', { controller: 'StyleGuideCtrl', diff --git a/public/app/core/services/all.js b/public/app/core/services/all.js index 96a4ec77737..1fea3e5a248 100644 --- a/public/app/core/services/all.js +++ b/public/app/core/services/all.js @@ -1,7 +1,6 @@ define([ './alert_srv', './util_srv', - './datasource_srv', './context_srv', './timer', './keyboard_manager', diff --git a/public/app/core/specs/PasswordStrength_specs.tsx b/public/app/core/specs/PasswordStrength_specs.tsx index 0ba239b2637..7a46d6b153f 100644 --- a/public/app/core/specs/PasswordStrength_specs.tsx +++ b/public/app/core/specs/PasswordStrength_specs.tsx @@ -1,14 +1,25 @@ -// import React from 'react'; -// import {describe, beforeEach, it, sinon, expect} from 'test/lib/common'; -// import {shallow} from 'enzyme'; -// -// import {PasswordStrength} from '../components/PasswordStrength'; -// -// describe('PasswordStrength', () => { -// -// it.skip('should have class bad if length below 4', () => { -// const wrapper = shallow(); -// expect(wrapper.find(".password-strength-bad")).to.have.length(3); -// }); -// }); -// +import React from 'react'; +import {describe, it, expect} from 'test/lib/common'; +import {shallow} from 'enzyme'; + +import {PasswordStrength} from '../components/PasswordStrength'; + +describe('PasswordStrength', () => { + + it('should have class bad if length below 4', () => { + const wrapper = shallow(); + expect(wrapper.find(".password-strength-bad")).to.have.length(1); + }); + + it('should have class ok if length below 8', () => { + const wrapper = shallow(); + expect(wrapper.find(".password-strength-ok")).to.have.length(1); + }); + + it('should have class good if length above 8', () => { + const wrapper = shallow(); + expect(wrapper.find(".password-strength-good")).to.have.length(1); + }); + +}); + diff --git a/public/app/core/specs/backend_srv_specs.ts b/public/app/core/specs/backend_srv_specs.ts new file mode 100644 index 00000000000..0e78007f210 --- /dev/null +++ b/public/app/core/specs/backend_srv_specs.ts @@ -0,0 +1,29 @@ +import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; +import 'app/core/services/backend_srv'; + +describe('backend_srv', function() { + var _backendSrv; + var _http; + var _httpBackend; + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(angularMocks.inject(function ($httpBackend, $http, backendSrv) { + _httpBackend = $httpBackend; + _http = $http; + _backendSrv = backendSrv; + })); + + describe('when handling errors', function() { + it('should return the http status code', function(done) { + _httpBackend.whenGET('gateway-error').respond(502); + _backendSrv.datasourceRequest({ + url: 'gateway-error' + }).catch(function(err) { + expect(err.status).to.be(502); + done(); + }); + _httpBackend.flush(); + }); + }); +}); diff --git a/public/app/core/specs/time_series_specs.ts b/public/app/core/specs/time_series_specs.ts new file mode 100644 index 00000000000..ddf08a9f704 --- /dev/null +++ b/public/app/core/specs/time_series_specs.ts @@ -0,0 +1,299 @@ +import {describe, beforeEach, it, expect} from 'test/lib/common'; +import TimeSeries from 'app/core/time_series2'; + +describe("TimeSeries", function() { + var points, series; + var yAxisFormats = ['short', 'ms']; + var testData; + + beforeEach(function() { + testData = { + alias: 'test', + datapoints: [ + [1,2],[null,3],[10,4],[8,5] + ] + }; + }); + + describe('when getting flot pairs', function() { + it('with connected style, should ignore nulls', function() { + series = new TimeSeries(testData); + points = series.getFlotPairs('connected', yAxisFormats); + expect(points.length).to.be(3); + }); + + it('with null as zero style, should replace nulls with zero', function() { + series = new TimeSeries(testData); + points = series.getFlotPairs('null as zero', yAxisFormats); + expect(points.length).to.be(4); + expect(points[1][1]).to.be(0); + }); + + it('if last is null current should pick next to last', function() { + series = new TimeSeries({ + datapoints: [[10,1], [null, 2]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.current).to.be(10); + }); + + it('max value should work for negative values', function() { + series = new TimeSeries({ + datapoints: [[-10,1], [-4, 2]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.max).to.be(-4); + }); + + it('average value should ignore nulls', function() { + series = new TimeSeries(testData); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.avg).to.be(6.333333333333333); + }); + + it('the delta value should account for nulls', function() { + series = new TimeSeries({ + datapoints: [[1,2],[3,3],[null,4],[10,5],[15,6]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.delta).to.be(14); + }); + + it('the delta value should account for nulls on first', function() { + series = new TimeSeries({ + datapoints: [[null,2],[1,3],[10,4],[15,5]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.delta).to.be(14); + }); + + it('the delta value should account for nulls on last', function() { + series = new TimeSeries({ + datapoints: [[1,2],[5,3],[10,4],[null,5]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.delta).to.be(9); + }); + + it('the delta value should account for resets', function() { + series = new TimeSeries({ + datapoints: [[1,2],[5,3],[10,4],[0,5],[10,6]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.delta).to.be(19); + }); + + it('the delta value should account for resets on last', function() { + series = new TimeSeries({ + datapoints: [[1,2],[2,3],[10,4],[8,5]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.delta).to.be(17); + }); + + it('the range value should be max - min', function() { + series = new TimeSeries(testData); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.range).to.be(9); + }); + + it('first value should ingone nulls', function() { + series = new TimeSeries(testData); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.first).to.be(1); + series = new TimeSeries({ + datapoints: [[null,2],[1,3],[10,4],[8,5]] + }); + series.getFlotPairs('null', yAxisFormats); + expect(series.stats.first).to.be(1); + }); + + it('with null as zero style, average value should treat nulls as 0', function() { + series = new TimeSeries(testData); + series.getFlotPairs('null as zero', yAxisFormats); + expect(series.stats.avg).to.be(4.75); + }); + + it('average value should be null if all values is null', function() { + series = new TimeSeries({ + datapoints: [[null,2],[null,3],[null,4],[null,5]] + }); + series.getFlotPairs('null'); + expect(series.stats.avg).to.be(null); + }); + }); + + describe('When checking if ms resolution is needed', function() { + describe('msResolution with second resolution timestamps', function() { + beforeEach(function() { + series = new TimeSeries({datapoints: [[45, 1234567890], [60, 1234567899]]}); + }); + + it('should set hasMsResolution to false', function() { + expect(series.hasMsResolution).to.be(false); + }); + }); + + describe('msResolution with millisecond resolution timestamps', function() { + beforeEach(function() { + series = new TimeSeries({datapoints: [[55, 1236547890001], [90, 1234456709000]]}); + }); + + it('should show millisecond resolution tooltip', function() { + expect(series.hasMsResolution).to.be(true); + }); + }); + + describe('msResolution with millisecond resolution timestamps but with trailing zeroes', function() { + beforeEach(function() { + series = new TimeSeries({datapoints: [[45, 1234567890000], [60, 1234567899000]]}); + }); + + it('should not show millisecond resolution tooltip', function() { + expect(series.hasMsResolution).to.be(false); + }); + }); + }); + + describe('can detect if series contains ms precision', function() { + var fakedata; + + beforeEach(function() { + fakedata = testData; + }); + + it('missing datapoint with ms precision', function() { + fakedata.datapoints[0] = [1337, 1234567890000]; + series = new TimeSeries(fakedata); + expect(series.isMsResolutionNeeded()).to.be(false); + }); + + it('contains datapoint with ms precision', function() { + fakedata.datapoints[0] = [1337, 1236547890001]; + series = new TimeSeries(fakedata); + expect(series.isMsResolutionNeeded()).to.be(true); + }); + }); + + describe('series overrides', function() { + var series; + beforeEach(function() { + series = new TimeSeries(testData); + }); + + describe('fill & points', function() { + beforeEach(function() { + series.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', fill: 0, points: true }]); + }); + + it('should set fill zero, and enable points', function() { + expect(series.lines.fill).to.be(0.001); + expect(series.points.show).to.be(true); + }); + }); + + describe('series option overrides, bars, true & lines false', function() { + beforeEach(function() { + series.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', bars: true, lines: false }]); + }); + + it('should disable lines, and enable bars', function() { + expect(series.lines.show).to.be(false); + expect(series.bars.show).to.be(true); + }); + }); + + describe('series option overrides, linewidth, stack', function() { + beforeEach(function() { + series.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', linewidth: 5, stack: false }]); + }); + + it('should disable stack, and set lineWidth', function() { + expect(series.stack).to.be(false); + expect(series.lines.lineWidth).to.be(5); + }); + }); + + describe('series option overrides, dashes and lineWidth', function() { + beforeEach(function() { + series.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', linewidth: 5, dashes: true }]); + }); + + it('should enable dashes, set dashes lineWidth to 5 and lines lineWidth to 0', function() { + expect(series.dashes.show).to.be(true); + expect(series.dashes.lineWidth).to.be(5); + expect(series.lines.lineWidth).to.be(0); + }); + }); + + describe('series option overrides, fill below to', function() { + beforeEach(function() { + series.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', fillBelowTo: 'min' }]); + }); + + it('should disable line fill and add fillBelowTo', function() { + expect(series.fillBelowTo).to.be('min'); + }); + }); + + describe('series option overrides, pointradius, steppedLine', function() { + beforeEach(function() { + series.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', pointradius: 5, steppedLine: true }]); + }); + + it('should set pointradius, and set steppedLine', function() { + expect(series.points.radius).to.be(5); + expect(series.lines.steps).to.be(true); + }); + }); + + describe('override match on regex', function() { + beforeEach(function() { + series.alias = 'test_01'; + series.applySeriesOverrides([{ alias: '/.*01/', lines: false }]); + }); + + it('should match second series', function() { + expect(series.lines.show).to.be(false); + }); + }); + + describe('override series y-axis, and z-index', function() { + beforeEach(function() { + series.alias = 'test'; + series.applySeriesOverrides([{ alias: 'test', yaxis: 2, zindex: 2 }]); + }); + + it('should set yaxis', function() { + expect(series.yaxis).to.be(2); + }); + + it('should set zindex', function() { + expect(series.zindex).to.be(2); + }); + }); + + }); + + describe('value formatter', function() { + var series; + beforeEach(function() { + series = new TimeSeries(testData); + }); + + it('should format non-numeric values as empty string', function() { + expect(series.formatValue(null)).to.be(""); + expect(series.formatValue(undefined)).to.be(""); + expect(series.formatValue(NaN)).to.be(""); + expect(series.formatValue(Infinity)).to.be(""); + expect(series.formatValue(-Infinity)).to.be(""); + }); + }); + +}); diff --git a/public/app/core/specs/value_select_dropdown_specs.ts b/public/app/core/specs/value_select_dropdown_specs.ts new file mode 100644 index 00000000000..644320e7e0d --- /dev/null +++ b/public/app/core/specs/value_select_dropdown_specs.ts @@ -0,0 +1,167 @@ +import {describe, beforeEach, it, expect, angularMocks, sinon} from 'test/lib/common'; +import 'app/core/directives/value_select_dropdown'; + +describe("SelectDropdownCtrl", function() { + var scope; + var ctrl; + var tagValuesMap: any = {}; + var rootScope; + var q; + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.inject(function($controller, $rootScope, $q, $httpBackend) { + rootScope = $rootScope; + q = $q; + scope = $rootScope.$new(); + ctrl = $controller('ValueSelectDropdownCtrl', {$scope: scope}); + ctrl.onUpdated = sinon.spy(); + $httpBackend.when('GET', /\.html$/).respond(''); + })); + + describe("Given simple variable", function() { + beforeEach(function() { + ctrl.variable = { + current: {text: 'hej', value: 'hej' }, + getValuesForTag: function(key) { + return q.when(tagValuesMap[key]); + }, + }; + ctrl.init(); + }); + + it("Should init labelText and linkText", function() { + expect(ctrl.linkText).to.be("hej"); + }); + }); + + describe("Given variable with tags and dropdown is opened", function() { + beforeEach(function() { + ctrl.variable = { + current: {text: 'server-1', value: 'server-1'}, + options: [ + {text: 'server-1', value: 'server-1', selected: true}, + {text: 'server-2', value: 'server-2'}, + {text: 'server-3', value: 'server-3'}, + ], + tags: ["key1", "key2", "key3"], + getValuesForTag: function(key) { + return q.when(tagValuesMap[key]); + }, + multi: true + }; + tagValuesMap.key1 = ['server-1', 'server-3']; + tagValuesMap.key2 = ['server-2', 'server-3']; + tagValuesMap.key3 = ['server-1', 'server-2', 'server-3']; + ctrl.init(); + ctrl.show(); + }); + + it("should init tags model", function() { + expect(ctrl.tags.length).to.be(3); + expect(ctrl.tags[0].text).to.be("key1"); + }); + + it("should init options model", function() { + expect(ctrl.options.length).to.be(3); + }); + + it("should init selected values array", function() { + expect(ctrl.selectedValues.length).to.be(1); + }); + + it("should set linkText", function() { + expect(ctrl.linkText).to.be('server-1'); + }); + + describe('after adititional value is selected', function() { + beforeEach(function() { + ctrl.selectValue(ctrl.options[2], {}); + ctrl.commitChanges(); + }); + + it('should update link text', function() { + expect(ctrl.linkText).to.be('server-1 + server-3'); + }); + }); + + describe('When tag is selected', function() { + beforeEach(function() { + ctrl.selectTag(ctrl.tags[0]); + rootScope.$digest(); + ctrl.commitChanges(); + }); + + it("should select tag", function() { + expect(ctrl.selectedTags.length).to.be(1); + }); + + it("should select values", function() { + expect(ctrl.options[0].selected).to.be(true); + expect(ctrl.options[2].selected).to.be(true); + }); + + it("link text should not include tag values", function() { + expect(ctrl.linkText).to.be(''); + }); + + describe('and then dropdown is opened and closed without changes', function() { + beforeEach(function() { + ctrl.show(); + ctrl.commitChanges(); + rootScope.$digest(); + }); + + it("should still have selected tag", function() { + expect(ctrl.selectedTags.length).to.be(1); + }); + }); + + describe('and then unselected', function() { + beforeEach(function() { + ctrl.selectTag(ctrl.tags[0]); + rootScope.$digest(); + }); + + it("should deselect tag", function() { + expect(ctrl.selectedTags.length).to.be(0); + }); + }); + + describe('and then value is unselected', function() { + beforeEach(function() { + ctrl.selectValue(ctrl.options[0], {}); + }); + + it("should deselect tag", function() { + expect(ctrl.selectedTags.length).to.be(0); + }); + }); + }); + }); + + describe("Given variable with selected tags", function() { + beforeEach(function() { + ctrl.variable = { + current: {text: 'server-1', value: 'server-1', tags: [{text: 'key1', selected: true}] }, + options: [ + {text: 'server-1', value: 'server-1'}, + {text: 'server-2', value: 'server-2'}, + {text: 'server-3', value: 'server-3'}, + ], + tags: ["key1", "key2", "key3"], + getValuesForTag: function(key) { + return q.when(tagValuesMap[key]); + }, + multi: true + }; + ctrl.init(); + ctrl.show(); + }); + + it("should set tag as selected", function() { + expect(ctrl.tags[0].selected).to.be(true); + }); + + }); +}); + diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index c8ab8d9efcf..1c4c94da6fb 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -203,7 +203,7 @@ export default class TimeSeries { if (this.stats.max === -Number.MAX_VALUE) { this.stats.max = null; } if (this.stats.min === Number.MAX_VALUE) { this.stats.min = null; } - if (result.length) { + if (result.length && !this.allIsNull) { this.stats.avg = (this.stats.total / nonNulls); this.stats.current = result[result.length-1][1]; if (this.stats.current === null && result.length > 1) { @@ -228,6 +228,9 @@ export default class TimeSeries { } formatValue(value) { + if (!_.isFinite(value)) { + value = null; // Prevent NaN formatting + } return this.valueFormater(value, this.decimals, this.scaledDecimals); } diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts index bd774ea02ea..a38c92a6476 100644 --- a/public/app/core/utils/colors.ts +++ b/public/app/core/utils/colors.ts @@ -1,6 +1,15 @@ +import _ from 'lodash'; +import tinycolor from 'tinycolor2'; +export const PALETTE_ROWS = 4; +export const PALETTE_COLUMNS = 14; +export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)'; +export const OK_COLOR = "rgba(11, 237, 50, 1)"; +export const ALERTING_COLOR = "rgba(237, 46, 24, 1)"; +export const NO_DATA_COLOR = "rgba(150, 150, 150, 1)"; +export const REGION_FILL_ALPHA = 0.09; -export default [ +let colors = [ "#7EB26D","#EAB839","#6ED0E0","#EF843C","#E24D42","#1F78C1","#BA43A9","#705DA0", "#508642","#CCA300","#447EBC","#C15C17","#890F02","#0A437C","#6D1F62","#584477", "#B7DBAB","#F4D598","#70DBED","#F9BA8F","#F29191","#82B5D8","#E5A8E2","#AEA2E0", @@ -10,3 +19,26 @@ export default [ "#E0F9D7","#FCEACA","#CFFAFF","#F9E2D2","#FCE2DE","#BADFF4","#F9D9F9","#DEDAF7" ]; +export function sortColorsByHue(hexColors) { + let hslColors = _.map(hexColors, hexToHsl); + + let sortedHSLColors = _.sortBy(hslColors, ['h']); + sortedHSLColors = _.chunk(sortedHSLColors, PALETTE_ROWS); + sortedHSLColors = _.map(sortedHSLColors, chunk => { + return _.sortBy(chunk, 'l'); + }); + sortedHSLColors = _.flattenDeep(_.zip(...sortedHSLColors)); + + return _.map(sortedHSLColors, hslToHex); +} + +export function hexToHsl(color) { + return tinycolor(color).toHsl(); +} + +export function hslToHex(color) { + return tinycolor(color).toHexString(); +} + +export let sortedColors = sortColorsByHue(colors); +export default colors; diff --git a/public/app/core/utils/datemath.ts b/public/app/core/utils/datemath.ts index 2aa793016ba..f5608443f49 100644 --- a/public/app/core/utils/datemath.ts +++ b/public/app/core/utils/datemath.ts @@ -5,7 +5,7 @@ import moment from 'moment'; var units = ['y', 'M', 'w', 'd', 'h', 'm', 's']; -export function parse(text, roundUp?) { +export function parse(text, roundUp?, timezone?) { if (!text) { return undefined; } if (moment.isMoment(text)) { return text; } if (_.isDate(text)) { return moment(text); } @@ -16,7 +16,11 @@ export function parse(text, roundUp?) { var parseString; if (text.substring(0, 3) === 'now') { - time = moment(); + if (timezone === 'utc') { + time = moment.utc(); + } else { + time = moment(); + } mathString = text.substring('now'.length); } else { index = text.indexOf('||'); diff --git a/public/app/core/utils/file_export.ts b/public/app/core/utils/file_export.ts index 96af397fadf..03fcd147a3e 100644 --- a/public/app/core/utils/file_export.ts +++ b/public/app/core/utils/file_export.ts @@ -1,14 +1,11 @@ -/// - import _ from 'lodash'; import moment from 'moment'; +import {saveAs} from 'file-saver'; -declare var window: any; - -const DEFAULT_DATETIME_FORMAT: String = 'YYYY-MM-DDTHH:mm:ssZ'; +const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ'; export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { - var text = excel ? 'sep=;\n' : '' + 'Series;Time;Value\n'; + var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n'; _.each(seriesList, function(series) { _.each(series.datapoints, function(dp) { text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n'; @@ -18,7 +15,7 @@ export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATET } export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { - var text = excel ? 'sep=;\n' : '' + 'Time;'; + var text = (excel ? 'sep=;\n' : '') + 'Time;'; // add header _.each(seriesList, function(series) { text += series.alias + ';'; @@ -71,5 +68,5 @@ export function exportTableDataToCsv(table, excel = false) { export function saveSaveBlob(payload, fname) { var blob = new Blob([payload], { type: "text/csv;charset=utf-8" }); - window.saveAs(blob, fname); + saveAs(blob, fname); } diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index 38498ff1462..1daaa2f8ba3 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -401,6 +401,11 @@ function($, _, moment) { kbn.valueFormats.currencyJPY = kbn.formatBuilders.currency('¥'); kbn.valueFormats.currencyRUB = kbn.formatBuilders.currency('₽'); kbn.valueFormats.currencyUAH = kbn.formatBuilders.currency('₴'); + kbn.valueFormats.currencyBRL = kbn.formatBuilders.currency('R$'); + kbn.valueFormats.currencyDKK = kbn.formatBuilders.currency('kr'); + kbn.valueFormats.currencyISK = kbn.formatBuilders.currency('kr'); + kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr'); + kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr'); // Data (Binary) kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b'); @@ -754,6 +759,11 @@ function($, _, moment) { {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 Krone (kr)', value: 'currencyISK'}, + {text: 'Norwegian Krone (kr)', value: 'currencyNOK'}, + {text: 'Swedish Krone (kr)', value: 'currencySEK'}, ] }, { diff --git a/public/app/core/utils/react2angular.ts b/public/app/core/utils/react2angular.ts new file mode 100644 index 00000000000..ad6f7476d6a --- /dev/null +++ b/public/app/core/utils/react2angular.ts @@ -0,0 +1,10 @@ +import coreModule from 'app/core/core_module'; + +export function react2AngularDirective(name: string, component: any, options: any) { + + coreModule.directive(name, ['reactDirective', reactDirective => { + return reactDirective(component, options); + }]); + +} + diff --git a/public/app/features/admin/partials/edit_user.html b/public/app/features/admin/partials/edit_user.html index b907f794e76..384601affb7 100644 --- a/public/app/features/admin/partials/edit_user.html +++ b/public/app/features/admin/partials/edit_user.html @@ -41,7 +41,7 @@
- +
diff --git a/public/app/features/alerting/alert_def.ts b/public/app/features/alerting/alert_def.ts index d80c299ec6d..84481b60ce0 100644 --- a/public/app/features/alerting/alert_def.ts +++ b/public/app/features/alerting/alert_def.ts @@ -49,6 +49,9 @@ var reducerTypes = [ {text: 'count()', value: 'count'}, {text: 'last()', value: 'last'}, {text: 'median()', value: 'median'}, + {text: 'diff()', value: 'diff'}, + {text: 'percent_diff()', value: 'percent_diff'}, + {text: 'count_non_null()', value: 'count_non_null'}, ]; var noDataModes = [ @@ -106,6 +109,8 @@ function getStateDisplayModel(state) { }; } } + + throw {message: 'Unknown alert state'}; } function joinEvalMatches(matches, separator: string) { @@ -124,7 +129,6 @@ function joinEvalMatches(matches, separator: string) { } function getAlertAnnotationInfo(ah) { - // backward compatability, can be removed in grafana 5.x // old way stored evalMatches in data property directly, // new way stores it in evalMatches property on new data object diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/alert_tab_ctrl.ts index 57cb13cc993..2c273c93a01 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/alert_tab_ctrl.ts @@ -84,7 +84,7 @@ export class AlertTabCtrl { }); } - getNotificationIcon(type) { + getNotificationIcon(type): string { switch (type) { case "email": return "fa fa-envelope"; case "slack": return "fa fa-slack"; @@ -94,7 +94,9 @@ export class AlertTabCtrl { case "opsgenie": return "fa fa-bell"; case "hipchat": return "fa fa-mail-forward"; case "pushover": return "fa fa-mobile"; + case "kafka": return "fa fa-random"; } + return 'fa fa-bell'; } getNotifications() { @@ -381,6 +383,7 @@ export class AlertTabCtrl { test() { this.testing = true; + this.testResult = false; var payload = { dashboard: this.dashboardSrv.getCurrent().getSaveModelClone(), diff --git a/public/app/features/alerting/partials/alert_list.html b/public/app/features/alerting/partials/alert_list.html index cb76e381bec..aed99432a9f 100644 --- a/public/app/features/alerting/partials/alert_list.html +++ b/public/app/features/alerting/partials/alert_list.html @@ -1,18 +1,18 @@
-
@@ -34,9 +34,10 @@
diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index 2e7956d4a6e..9b5a535c727 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -12,7 +12,7 @@
  • State history
  • -
  • +
  • Delete
  • @@ -41,10 +41,10 @@ WHEN
    -
    - +
    + - OF + OF
    @@ -53,8 +53,8 @@
    - - + +
    -
    - If no data or all values are null - SET STATE TO + If no data or all values are null + SET STATE TO
    @@ -91,8 +90,8 @@
    - If execution error or timeout - SET STATE TO + If execution error or timeout + SET STATE TO
    @@ -135,35 +134,31 @@
    -
    +
    State history (last 50 state changes)
    -
    -
    - No state changes recorded -
    +
    +
    + No state changes recorded +
    1. -
      -
      -
      -
      -
      -
      -
      -
      - - - {{ah.stateModel.text}} - {{ah.time}} -
      -
      - {{ah.info}} -
      +
      +
      +
      +
      +
      + {{ah.stateModel.text}} + {{ah.info}} +
      +
      +
    2. diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html index 9b716e02ce6..b1e72a30fbb 100644 --- a/public/app/features/alerting/partials/notification_edit.html +++ b/public/app/features/alerting/partials/notification_edit.html @@ -40,13 +40,11 @@
      -
      - +
      +
      -
      -
      - -
      +
      +
      diff --git a/public/app/features/all.js b/public/app/features/all.js index 96c28288e8e..f039cb0ae90 100644 --- a/public/app/features/all.js +++ b/public/app/features/all.js @@ -3,6 +3,7 @@ define([ './dashlinks/module', './annotations/all', './templating/all', + './plugins/all', './dashboard/all', './playlist/all', './snapshot/all', diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index 39c2ff84acb..c950d3edd55 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -1,12 +1,10 @@ -/// - import _ from 'lodash'; import $ from 'jquery'; import coreModule from 'app/core/core_module'; import alertDef from '../alerting/alert_def'; /** @ngInject **/ -export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) { +export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, popoverSrv, $compile) { function sanitizeString(str) { try { @@ -21,6 +19,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) { restrict: 'E', scope: { "event": "=", + "onEdit": "&" }, link: function(scope, element) { var event = scope.event; @@ -31,33 +30,46 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) { var tooltip = '
      '; var titleStateClass = ''; - if (event.source.name === 'panel-alert') { + if (event.alertId) { var stateModel = alertDef.getStateDisplayModel(event.newState); titleStateClass = stateModel.stateClass; title = ` ${stateModel.text}`; text = alertDef.getAlertAnnotationInfo(event); + if (event.text) { + text = text + '
      ' + event.text; + } + } else if (title) { + text = title + '
      ' + text; + title = ''; } - tooltip += ` -
      - ${sanitizeString(title)} - ${dashboard.formatDate(event.min)} -
      + var header = `
      `; + if (event.login) { + header += `
      `; + } + header += ` + ${sanitizeString(title)} + ${dashboard.formatDate(event.min)} `; - tooltip += '
      '; + // Show edit icon only for users with at least Editor role + if (event.id && contextSrv.isEditor) { + header += ` + + + + `; + } + + header += `
      `; + tooltip += header; + tooltip += '
      '; if (text) { - tooltip += sanitizeString(text).replace(/\n/g, '
      ') + '
      '; + tooltip += '
      ' + sanitizeString(text.replace(/\n/g, '
      ')) + '
      '; } var tags = event.tags; - if (_.isString(event.tags)) { - tags = event.tags.split(','); - if (tags.length === 1) { - tags = event.tags.split(' '); - } - } if (tags && tags.length) { scope.tags = tags; @@ -65,6 +77,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) { } tooltip += "
      "; + tooltip += '
      '; var $tooltip = $(tooltip); $tooltip.appendTo(element); diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index e6a4c8660ae..2863ecdd843 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -1,5 +1,3 @@ -/// - import './editor_ctrl'; import angular from 'angular'; @@ -11,11 +9,7 @@ export class AnnotationsSrv { alertStatesPromise: any; /** @ngInject */ - constructor(private $rootScope, - private $q, - private datasourceSrv, - private backendSrv, - private timeSrv) { + constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) { $rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope); $rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope); } @@ -26,64 +20,40 @@ export class AnnotationsSrv { } getAnnotations(options) { - return this.$q.all([ - this.getGlobalAnnotations(options), - this.getPanelAnnotations(options), - this.getAlertStates(options) - ]).then(results => { + return this.$q + .all([this.getGlobalAnnotations(options), this.getAlertStates(options)]) + .then(results => { + // combine the annotations and flatten results + var annotations = _.flattenDeep(results[0]); - // combine the annotations and flatten results - var annotations = _.flattenDeep([results[0], results[1]]); - - // filter out annotations that do not belong to requesting panel - annotations = _.filter(annotations, item => { - // shownIn === 1 requires annotation matching panel id - if (item.source.showIn === 1) { - if (item.panelId && options.panel.id === item.panelId) { - return true; + // filter out annotations that do not belong to requesting panel + annotations = _.filter(annotations, item => { + // if event has panel id and query is of type dashboard then panel and requesting panel id must match + if (item.panelId && item.source.type === 'dashboard') { + return item.panelId === options.panel.id; } - return false; + return true; + }); + + annotations = dedupAnnotations(annotations); + annotations = makeRegions(annotations, options); + + // look for alert state for this panel + var alertState = _.find(results[1], {panelId: options.panel.id}); + + return { + annotations: annotations, + alertState: alertState, + }; + }) + .catch(err => { + if (!err.message && err.data && err.data.message) { + err.message = err.data.message; } - return true; + console.log('AnnotationSrv.query error', err); + this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', err.message || err]); + return []; }); - - // look for alert state for this panel - var alertState = _.find(results[2], {panelId: options.panel.id}); - - return { - annotations: annotations, - alertState: alertState, - }; - - }).catch(err => { - if (!err.message && err.data && err.data.message) { - err.message = err.data.message; - } - this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', (err.message || err)]); - - return []; - }); - } - - getPanelAnnotations(options) { - var panel = options.panel; - var dashboard = options.dashboard; - - if (dashboard.id && panel && panel.alert) { - return this.backendSrv.get('/api/annotations', { - from: options.range.from.valueOf(), - to: options.range.to.valueOf(), - limit: 100, - panelId: panel.id, - dashboardId: dashboard.id, - }).then(results => { - // this built in annotation source name `panel-alert` is used in annotation tooltip - // to know that this annotation is from panel alert - return this.translateQueryResult({iconColor: '#AA0000', name: 'panel-alert'}, results); - }); - } - - return this.$q.when([]); } getAlertStates(options) { @@ -104,43 +74,55 @@ export class AnnotationsSrv { return this.alertStatesPromise; } - this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', {dashboardId: options.dashboard.id}); + this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', { + dashboardId: options.dashboard.id, + }); return this.alertStatesPromise; } getGlobalAnnotations(options) { var dashboard = options.dashboard; - if (dashboard.annotations.list.length === 0) { - return this.$q.when([]); - } - if (this.globalAnnotationsPromise) { return this.globalAnnotationsPromise; } - var annotations = _.filter(dashboard.annotations.list, {enable: true}); var range = this.timeSrv.timeRange(); + var promises = []; + + for (let annotation of dashboard.annotations.list) { + if (!annotation.enable) { + continue; + } - this.globalAnnotationsPromise = this.$q.all(_.map(annotations, annotation => { if (annotation.snapshotData) { return this.translateQueryResult(annotation, annotation.snapshotData); } - return this.datasourceSrv.get(annotation.datasource).then(datasource => { - // issue query against data source - return datasource.annotationQuery({range: range, rangeRaw: range.raw, annotation: annotation}); - }) - .then(results => { - // store response in annotation object if this is a snapshot call - if (dashboard.snapshot) { - annotation.snapshotData = angular.copy(results); - } - // translate result - return this.translateQueryResult(annotation, results); - }); - })); + promises.push( + this.datasourceSrv + .get(annotation.datasource) + .then(datasource => { + // issue query against data source + return datasource.annotationQuery({ + range: range, + rangeRaw: range.raw, + annotation: annotation, + dashboard: dashboard, + }); + }) + .then(results => { + // store response in annotation object if this is a snapshot call + if (dashboard.snapshot) { + annotation.snapshotData = angular.copy(results); + } + // translate result + return this.translateQueryResult(annotation, results); + }), + ); + } + this.globalAnnotationsPromise = this.$q.all(promises); return this.globalAnnotationsPromise; } @@ -149,6 +131,21 @@ export class AnnotationsSrv { return this.backendSrv.post('/api/annotations', annotation); } + updateAnnotationEvent(annotation) { + this.globalAnnotationsPromise = null; + return this.backendSrv.put(`/api/annotations/${annotation.id}`, annotation); + } + + deleteAnnotationEvent(annotation) { + this.globalAnnotationsPromise = null; + let deleteUrl = `/api/annotations/${annotation.id}`; + if (annotation.isRegion) { + deleteUrl = `/api/annotations/region/${annotation.regionId}`; + } + + return this.backendSrv.delete(deleteUrl); + } + translateQueryResult(annotation, results) { // if annotation has snapshotData // make clone and remove it @@ -159,13 +156,88 @@ export class AnnotationsSrv { for (var item of results) { item.source = annotation; - item.min = item.time; - item.max = item.time; - item.scope = 1; - item.eventType = annotation.name; } return results; } } +/** + * This function converts annotation events into set + * of single events and regions (event consist of two) + * @param annotations + * @param options + */ +function makeRegions(annotations, options) { + let [regionEvents, singleEvents] = _.partition(annotations, 'regionId'); + let regions = getRegions(regionEvents, options.range); + annotations = _.concat(regions, singleEvents); + return annotations; +} + +function getRegions(events, range) { + let region_events = _.filter(events, event => { + return event.regionId; + }); + let regions = _.groupBy(region_events, 'regionId'); + regions = _.compact( + _.map(regions, region_events => { + let region_obj = _.head(region_events); + if (region_events && region_events.length > 1) { + region_obj.timeEnd = region_events[1].time; + region_obj.isRegion = true; + return region_obj; + } else { + if (region_events && region_events.length) { + // Don't change proper region object + if (!region_obj.time || !region_obj.timeEnd) { + // This is cut region + if (isStartOfRegion(region_obj)) { + region_obj.timeEnd = range.to.valueOf() - 1; + } else { + // Start time = null + region_obj.timeEnd = region_obj.time; + region_obj.time = range.from.valueOf() + 1; + } + region_obj.isRegion = true; + } + + return region_obj; + } + } + }), + ); + + return regions; +} + +function isStartOfRegion(event): boolean { + return event.id && event.id === event.regionId; +} + +function dedupAnnotations(annotations) { + let dedup = []; + + // Split events by annotationId property existance + let events = _.partition(annotations, 'id'); + + let eventsById = _.groupBy(events[0], 'id'); + dedup = _.map(eventsById, eventGroup => { + if (eventGroup.length > 1 && !_.every(eventGroup, isPanelAlert)) { + // Get first non-panel alert + return _.find(eventGroup, event => { + return event.eventType !== 'panel-alert'; + }); + } else { + return _.head(eventGroup); + } + }); + + dedup = _.concat(dedup, events[1]); + return dedup; +} + +function isPanelAlert(event) { + return event.eventType === 'panel-alert'; +} + coreModule.service('annotationsSrv', AnnotationsSrv); diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts index 74c4768b5ad..a52e241ce35 100644 --- a/public/app/features/annotations/editor_ctrl.ts +++ b/public/app/features/annotations/editor_ctrl.ts @@ -1,5 +1,3 @@ -/// - import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; @@ -36,11 +34,7 @@ export class AnnotationsEditorCtrl { this.annotations = $scope.dashboard.annotations.list; this.reset(); - $scope.$watch('mode', newVal => { - if (newVal === 'new') { - this.reset(); - } - }); + this.onColorChange = this.onColorChange.bind(this); } datasourceChanged() { @@ -71,6 +65,11 @@ export class AnnotationsEditorCtrl { this.$scope.broadcastRefresh(); } + setupNew() { + this.mode = 'new'; + this.reset(); + } + add() { this.annotations.push(this.currentAnnotation); this.reset(); @@ -85,6 +84,18 @@ export class AnnotationsEditorCtrl { this.$scope.dashboard.updateSubmenuVisibility(); this.$scope.broadcastRefresh(); } + + onColorChange(newColor) { + this.currentAnnotation.iconColor = newColor; + } + + annotationEnabledChange() { + this.$scope.broadcastRefresh(); + } + + annotationHiddenChanged() { + this.$scope.dashboard.updateSubmenuVisibility(); + } } coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl); diff --git a/public/app/features/annotations/event.ts b/public/app/features/annotations/event.ts index 53afbea5b07..24d0edbe1a2 100644 --- a/public/app/features/annotations/event.ts +++ b/public/app/features/annotations/event.ts @@ -2,9 +2,11 @@ export class AnnotationEvent { dashboardId: number; panelId: number; + userId: number; time: any; timeEnd: any; isRegion: boolean; - title: string; text: string; + type: string; + tags: string; } diff --git a/public/app/features/annotations/event_editor.ts b/public/app/features/annotations/event_editor.ts index e5311ef8c76..b8e0a40a7bd 100644 --- a/public/app/features/annotations/event_editor.ts +++ b/public/app/features/annotations/event_editor.ts @@ -1,6 +1,5 @@ -/// - import _ from 'lodash'; +import moment from 'moment'; import {coreModule} from 'app/core/core'; import {MetricsPanelCtrl} from 'app/plugins/sdk'; import {AnnotationEvent} from './event'; @@ -11,11 +10,20 @@ export class EventEditorCtrl { timeRange: {from: number, to: number}; form: any; close: any; + timeFormated: string; /** @ngInject **/ constructor(private annotationsSrv) { this.event.panelId = this.panelCtrl.panel.id; this.event.dashboardId = this.panelCtrl.dashboard.id; + + // Annotations query returns time as Unix timestamp in milliseconds + this.event.time = tryEpochToMoment(this.event.time); + if (this.event.isRegion) { + this.event.timeEnd = tryEpochToMoment(this.event.timeEnd); + } + + this.timeFormated = this.panelCtrl.dashboard.formatDate(this.event.time); } save() { @@ -28,7 +36,7 @@ export class EventEditorCtrl { saveModel.timeEnd = 0; if (saveModel.isRegion) { - saveModel.timeEnd = saveModel.timeEnd.valueOf(); + saveModel.timeEnd = this.event.timeEnd.valueOf(); if (saveModel.timeEnd < saveModel.time) { console.log('invalid time'); @@ -36,14 +44,48 @@ export class EventEditorCtrl { } } - this.annotationsSrv.saveAnnotationEvent(saveModel).then(() => { + if (saveModel.id) { + this.annotationsSrv.updateAnnotationEvent(saveModel) + .then(() => { + this.panelCtrl.refresh(); + this.close(); + }) + .catch(() => { + this.panelCtrl.refresh(); + this.close(); + }); + } else { + this.annotationsSrv.saveAnnotationEvent(saveModel) + .then(() => { + this.panelCtrl.refresh(); + this.close(); + }) + .catch(() => { + this.panelCtrl.refresh(); + this.close(); + }); + } + } + + delete() { + return this.annotationsSrv.deleteAnnotationEvent(this.event) + .then(() => { + this.panelCtrl.refresh(); + this.close(); + }) + .catch(() => { this.panelCtrl.refresh(); this.close(); }); } +} - timeChanged() { - this.panelCtrl.render(); +function tryEpochToMoment(timestamp) { + if (timestamp && _.isNumber(timestamp)) { + let epoch = Number(timestamp); + return moment(epoch); + } else { + return timestamp; } } diff --git a/public/app/features/annotations/event_manager.ts b/public/app/features/annotations/event_manager.ts index 6b8a58f0b57..e8ddd2d95d6 100644 --- a/public/app/features/annotations/event_manager.ts +++ b/public/app/features/annotations/event_manager.ts @@ -1,27 +1,28 @@ import _ from 'lodash'; import moment from 'moment'; +import tinycolor from 'tinycolor2'; import {MetricsPanelCtrl} from 'app/plugins/sdk'; import {AnnotationEvent} from './event'; +import {OK_COLOR, ALERTING_COLOR, NO_DATA_COLOR, DEFAULT_ANNOTATION_COLOR, REGION_FILL_ALPHA} from 'app/core/utils/colors'; export class EventManager { event: AnnotationEvent; + editorOpen: boolean; - constructor(private panelCtrl: MetricsPanelCtrl, private elem, private popoverSrv) { - } + constructor(private panelCtrl: MetricsPanelCtrl) {} editorClosed() { - console.log('editorClosed'); this.event = null; + this.editorOpen = false; this.panelCtrl.render(); } - updateTime(range) { - let newEvent = true; + editorOpened() { + this.editorOpen = true; + } - if (this.event) { - newEvent = false; - } else { - // init new event + updateTime(range) { + if (!this.event) { this.event = new AnnotationEvent(); this.event.dashboardId = this.panelCtrl.dashboard.id; this.event.panelId = this.panelCtrl.panel.id; @@ -35,25 +36,11 @@ export class EventManager { this.event.isRegion = true; } - // newEvent means the editor is not visible - if (!newEvent) { - this.panelCtrl.render(); - return; - } - - this.popoverSrv.show({ - element: this.elem[0], - classNames: 'drop-popover drop-popover--form', - position: 'bottom center', - openOn: null, - template: '', - onClose: this.editorClosed.bind(this), - model: { - event: this.event, - panelCtrl: this.panelCtrl, - }, - }); + this.panelCtrl.render(); + } + editEvent(event, elem?) { + this.event = event; this.panelCtrl.render(); } @@ -63,36 +50,60 @@ export class EventManager { } var types = { - '$__alerting': { - color: 'rgba(237, 46, 24, 1)', + $__alerting: { + color: ALERTING_COLOR, position: 'BOTTOM', markerSize: 5, }, - '$__ok': { - color: 'rgba(11, 237, 50, 1)', + $__ok: { + color: OK_COLOR, position: 'BOTTOM', markerSize: 5, }, - '$__no_data': { - color: 'rgba(150, 150, 150, 1)', + $__no_data: { + color: NO_DATA_COLOR, + position: 'BOTTOM', + markerSize: 5, + }, + $__editing: { + color: DEFAULT_ANNOTATION_COLOR, position: 'BOTTOM', markerSize: 5, }, }; if (this.event) { - annotations = [ - { - min: this.event.time.valueOf(), - title: this.event.title, - text: this.event.text, - eventType: '$__alerting', - } - ]; + if (this.event.isRegion) { + annotations = [ + { + isRegion: true, + min: this.event.time.valueOf(), + timeEnd: this.event.timeEnd.valueOf(), + text: this.event.text, + eventType: '$__editing', + editModel: this.event, + }, + ]; + } else { + annotations = [ + { + min: this.event.time.valueOf(), + text: this.event.text, + editModel: this.event, + eventType: '$__editing', + }, + ]; + } } else { // annotations from query for (var i = 0; i < annotations.length; i++) { var item = annotations[i]; + + // add properties used by jquery flot events + item.min = item.time; + item.max = item.time; + item.eventType = item.source.name; + if (item.newState) { item.eventType = '$__' + item.newState; continue; @@ -108,10 +119,50 @@ export class EventManager { } } + let regions = getRegions(annotations); + addRegionMarking(regions, flotOptions); + + let eventSectionHeight = 20; + let eventSectionMargin = 7; + flotOptions.grid.eventSectionHeight = eventSectionMargin; + flotOptions.xaxis.eventSectionHeight = eventSectionHeight; + flotOptions.events = { levels: _.keys(types).length + 1, data: annotations, types: types, + manager: this, }; } } + +function getRegions(events) { + return _.filter(events, 'isRegion'); +} + +function addRegionMarking(regions, flotOptions) { + let markings = flotOptions.grid.markings; + let defaultColor = DEFAULT_ANNOTATION_COLOR; + let fillColor; + + _.each(regions, region => { + if (region.source) { + fillColor = region.source.iconColor || defaultColor; + } else { + fillColor = defaultColor; + } + + fillColor = addAlphaToRGB(fillColor, REGION_FILL_ALPHA); + markings.push({xaxis: {from: region.min, to: region.timeEnd}, color: fillColor}); + }); +} + +function addAlphaToRGB(colorString: string, alpha: number): string { + let color = tinycolor(colorString); + if (color.isValid()) { + color.setAlpha(alpha); + return color.toRgbString(); + } else { + return colorString; + } +} diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html index 1506e1a0dc5..4c0b8f7b127 100644 --- a/public/app/features/annotations/partials/editor.html +++ b/public/app/features/annotations/partials/editor.html @@ -40,10 +40,11 @@ Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines and icons on all graph panels. When you hover over an annotation icon you can get title, tags, and text information for the event. In the Queries tab you can add queries that return annotation events. -
      -
      - Checkout the Annotations documentation for more information.

      +

      + You can add annotations directly from grafana by holding CTRL or CMD + click on graph (or drag region). These will be stored in Grafana's annotation database. +

      + Checkout the Annotations documentation for more information.
      @@ -53,13 +54,16 @@
      - + - @@ -77,60 +81,65 @@
      -
      -
      Options
      +
      +
      +
      General
      - Name - + Name +
      - Data source -
      + Data source +
      -
      -
      - - - - - - - - -
      +
      + +
      +
      + + + +
      - + + +
      +
      -
      Query
      - - - - +
      Query
      + + + + -
      -
      - - -
      +
      +
      + +
      +
      -
      diff --git a/public/app/features/annotations/partials/event_editor.html b/public/app/features/annotations/partials/event_editor.html index 6e44b6f768d..529434755f1 100644 --- a/public/app/features/annotations/partials/event_editor.html +++ b/public/app/features/annotations/partials/event_editor.html @@ -1,38 +1,35 @@ -
      Add annotation
      - - -
      -
      - Title - +
      +
      +
      - -
      -
      - Time - -
      -
      - -
      -
      - Start - -
      -
      - End - -
      -
      -
      - Description - -
      -
      - - Cancel -
      -
      - +
      + Add Annotation + Edit Annotation +
      + +
      {{ctrl.timeFormated}}
      +
      + +
      +
      +
      + Description + +
      + +
      + Tags + + +
      + +
      + + + Cancel +
      +
      + +
      diff --git a/public/app/features/annotations/specs/annotations_srv_specs.ts b/public/app/features/annotations/specs/annotations_srv_specs.ts new file mode 100644 index 00000000000..3c0142ed87f --- /dev/null +++ b/public/app/features/annotations/specs/annotations_srv_specs.ts @@ -0,0 +1,40 @@ +import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; +import '../annotations_srv'; +import helpers from 'test/specs/helpers'; + +describe('AnnotationsSrv', function() { + var ctx = new helpers.ServiceTestContext(); + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(() => { + ctx.createService('annotationsSrv'); + }); + describe('When translating the query result', () => { + const annotationSource = { + datasource: '-- Grafana --', + enable: true, + hide: false, + limit: 200, + name: 'test', + scope: 'global', + tags: [ + 'test' + ], + type: 'event', + }; + + const time = 1507039543000; + const annotations = [{id: 1, panelId: 1, text: 'text', time: time}]; + let translatedAnnotations; + + beforeEach(() => { + translatedAnnotations = ctx.service.translateQueryResult(annotationSource, annotations); + }); + + it('should set defaults', () => { + expect(translatedAnnotations[0].source).to.eql(annotationSource); + }); + }); +}); + diff --git a/public/app/features/dashboard/export/export_modal.html b/public/app/features/dashboard/export/export_modal.html index 01e47849439..0598c612fd6 100644 --- a/public/app/features/dashboard/export/export_modal.html +++ b/public/app/features/dashboard/export/export_modal.html @@ -11,7 +11,7 @@
      diff --git a/public/app/features/dashboard/export_data/export_data_modal.html b/public/app/features/dashboard/export_data/export_data_modal.html index 8e46d121e95..8b766889c33 100644 --- a/public/app/features/dashboard/export_data/export_data_modal.html +++ b/public/app/features/dashboard/export_data/export_data_modal.html @@ -23,7 +23,7 @@
      diff --git a/public/app/features/dashboard/export_data/export_data_modal.ts b/public/app/features/dashboard/export_data/export_data_modal.ts index f4bc5d3f2ea..da39611f901 100644 --- a/public/app/features/dashboard/export_data/export_data_modal.ts +++ b/public/app/features/dashboard/export_data/export_data_modal.ts @@ -1,5 +1,3 @@ -/// - import angular from 'angular'; import * as fileExport from 'app/core/utils/file_export'; import appEvents from 'app/core/app_events'; @@ -8,7 +6,7 @@ export class ExportDataModalCtrl { private data: any[]; private panel: string; asRows: Boolean = true; - dateTimeFormat: String = 'YYYY-MM-DDTHH:mm:ssZ'; + dateTimeFormat = 'YYYY-MM-DDTHH:mm:ssZ'; excel: false; export() { diff --git a/public/app/features/dashboard/model.ts b/public/app/features/dashboard/model.ts index 3eb3d25f4fe..7c4cdfac59e 100644 --- a/public/app/features/dashboard/model.ts +++ b/public/app/features/dashboard/model.ts @@ -5,6 +5,7 @@ import moment from 'moment'; import _ from 'lodash'; import $ from 'jquery'; +import {DEFAULT_ANNOTATION_COLOR} from 'app/core/utils/colors'; import {Emitter, contextSrv, appEvents} from 'app/core/core'; import {DashboardRow} from './row/row_model'; import sortByKeys from 'app/core/utils/sort_by_keys'; @@ -71,10 +72,35 @@ export class DashboardModel { } } + this.addBuiltInAnnotationQuery(); this.updateSchema(data); this.initMeta(meta); } + addBuiltInAnnotationQuery() { + let found = false; + for (let item of this.annotations.list) { + if (item.builtIn === 1) { + found = true; + break; + } + } + + if (found) { + return; + } + + this.annotations.list.unshift({ + datasource: '-- Grafana --', + name: 'Annotations & Alerts', + type: 'dashboard', + iconColor: DEFAULT_ANNOTATION_COLOR, + enable: true, + hide: true, + builtIn: 1, + }); + } + private initMeta(meta) { meta = meta || {}; diff --git a/public/app/features/dashboard/partials/settings.html b/public/app/features/dashboard/partials/settings.html index b8d8303a51b..e9f30c81b55 100644 --- a/public/app/features/dashboard/partials/settings.html +++ b/public/app/features/dashboard/partials/settings.html @@ -93,13 +93,13 @@
      - - -
      diff --git a/public/app/features/dashboard/repeat_option/repeat_option.ts b/public/app/features/dashboard/repeat_option/repeat_option.ts index 380c2861a28..056cfded0b5 100644 --- a/public/app/features/dashboard/repeat_option/repeat_option.ts +++ b/public/app/features/dashboard/repeat_option/repeat_option.ts @@ -9,7 +9,8 @@ var template = `
      `; -coreModule.directive('dashRepeatOption', function(variableSrv) { +/** @ngInject **/ +function dashRepeatOptionDirective(variableSrv) { return { restrict: 'E', template: template, @@ -30,5 +31,6 @@ coreModule.directive('dashRepeatOption', function(variableSrv) { scope.variables.unshift({text: 'Disabled', value: null}); } }; -}); +} +coreModule.directive('dashRepeatOption', dashRepeatOptionDirective); diff --git a/public/app/features/dashboard/row/row_ctrl.ts b/public/app/features/dashboard/row/row_ctrl.ts index 496335eaac5..e5c936ef4ed 100644 --- a/public/app/features/dashboard/row/row_ctrl.ts +++ b/public/app/features/dashboard/row/row_ctrl.ts @@ -113,7 +113,8 @@ export class DashRowCtrl { } } -coreModule.directive('dashRow', function($rootScope) { +/** @ngInject */ +function dashRowDirective($rootScope) { return { restrict: 'E', templateUrl: 'public/app/features/dashboard/row/row.html', @@ -142,9 +143,10 @@ coreModule.directive('dashRow', function($rootScope) { }, scope); } }; -}); +} -coreModule.directive('panelWidth', function($rootScope) { +/** @ngInject */ +function panelWidthDirective($rootScope) { return function(scope, element) { var fullscreen = false; @@ -180,10 +182,10 @@ coreModule.directive('panelWidth', function($rootScope) { element.hide(); } }; -}); +} - -coreModule.directive('panelDropZone', function($timeout) { +/** @ngInject */ +function panelDropZoneDirective($timeout) { return function(scope, element) { var row = scope.ctrl.row; var indrag = false; @@ -237,5 +239,9 @@ coreModule.directive('panelDropZone', function($timeout) { updateState(); }; -}); +} + +coreModule.directive('dashRow', dashRowDirective); +coreModule.directive('panelWidth', panelWidthDirective); +coreModule.directive('panelDropZone', panelDropZoneDirective); diff --git a/public/app/features/dashboard/specs/dash_import_ctrl_specs.ts b/public/app/features/dashboard/specs/dash_import_ctrl_specs.ts index 97983d60dc9..c541aca34b2 100644 --- a/public/app/features/dashboard/specs/dash_import_ctrl_specs.ts +++ b/public/app/features/dashboard/specs/dash_import_ctrl_specs.ts @@ -53,9 +53,10 @@ describe('DashImportCtrl', function() { // setup api mock backendSrv.get = sinon.spy(() => { return Promise.resolve({ + json: {} }); }); - ctx.ctrl.checkGnetDashboard(); + return ctx.ctrl.checkGnetDashboard(); }); it('should call gnet api with correct dashboard id', function() { @@ -69,9 +70,10 @@ describe('DashImportCtrl', function() { // setup api mock backendSrv.get = sinon.spy(() => { return Promise.resolve({ + json: {} }); }); - ctx.ctrl.checkGnetDashboard(); + return ctx.ctrl.checkGnetDashboard(); }); it('should call gnet api with correct dashboard id', function() { diff --git a/public/app/features/dashboard/specs/dashboard_model_specs.ts b/public/app/features/dashboard/specs/dashboard_model_specs.ts index 6ca84ba89f3..ca5482bbfc5 100644 --- a/public/app/features/dashboard/specs/dashboard_model_specs.ts +++ b/public/app/features/dashboard/specs/dashboard_model_specs.ts @@ -46,8 +46,8 @@ describe('DashboardModel', function() { var saveModel = model.getSaveModelClone(); var keys = _.keys(saveModel); - expect(keys[0]).to.be('addEmptyRow'); - expect(keys[1]).to.be('addPanel'); + expect(keys[0]).to.be('addBuiltInAnnotationQuery'); + expect(keys[1]).to.be('addEmptyRow'); }); }); @@ -220,26 +220,6 @@ describe('DashboardModel', function() { }); }); - describe('when creating dashboard model with missing list for annoations or templating', function() { - var model; - - beforeEach(function() { - model = new DashboardModel({ - annotations: { - enable: true, - }, - templating: { - enable: true - } - }); - }); - - it('should add empty list', function() { - expect(model.annotations.list.length).to.be(0); - expect(model.templating.list.length).to.be(0); - }); - }); - describe('Given editable false dashboard', function() { var model; @@ -339,7 +319,12 @@ describe('DashboardModel', function() { }); it('should add empty list', function() { - expect(model.annotations.list.length).to.be(0); + expect(model.annotations.list.length).to.be(1); + expect(model.templating.list.length).to.be(0); + }); + + it('should add builtin annotation query', function() { + expect(model.annotations.list[0].builtIn).to.be(1); expect(model.templating.list.length).to.be(0); }); }); diff --git a/public/app/features/dashboard/specs/exporter_specs.ts b/public/app/features/dashboard/specs/exporter_specs.ts index 9364cea8c47..cc2b1ddaf97 100644 --- a/public/app/features/dashboard/specs/exporter_specs.ts +++ b/public/app/features/dashboard/specs/exporter_specs.ts @@ -80,6 +80,10 @@ describe('given dashboard with repeated panels', function() { name: 'mixed', meta: {id: "mixed", info: {version: "1.2.1"}, name: "Mixed", builtIn: true} })); + datasourceSrvStub.get.withArgs('-- Grafana --').returns(Promise.resolve({ + name: '-- Grafana --', + meta: {id: "grafana", info: {version: "1.2.1"}, name: "grafana", builtIn: true} + })); config.panels['graph'] = { id: "graph", @@ -116,7 +120,7 @@ describe('given dashboard with repeated panels', function() { }); it('should replace datasource in annotation query', function() { - expect(exported.annotations.list[0].datasource).to.be("${DS_GFDB}"); + expect(exported.annotations.list[1].datasource).to.be("${DS_GFDB}"); }); it('should add datasource as input', function() { diff --git a/public/app/features/dashboard/specs/history_ctrl_specs.ts b/public/app/features/dashboard/specs/history_ctrl_specs.ts index d6365470887..42e34f97f79 100644 --- a/public/app/features/dashboard/specs/history_ctrl_specs.ts +++ b/public/app/features/dashboard/specs/history_ctrl_specs.ts @@ -2,7 +2,7 @@ import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/co import _ from 'lodash'; import {HistoryListCtrl} from 'app/features/dashboard/history/history'; -import { versions, compare, restore } from 'test/mocks/history-mocks'; +import {versions, compare, restore} from './history_mocks'; describe('HistoryListCtrl', function() { var RESTORE_ID = 4; diff --git a/public/app/features/dashboard/specs/history_mocks.ts b/public/app/features/dashboard/specs/history_mocks.ts new file mode 100644 index 00000000000..9c89d71479b --- /dev/null +++ b/public/app/features/dashboard/specs/history_mocks.ts @@ -0,0 +1,193 @@ + +export function versions() { + return [{ + id: 4, + dashboardId: 1, + parentVersion: 3, + restoredFrom: 0, + version: 4, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 3, + dashboardId: 1, + parentVersion: 1, + restoredFrom: 1, + version: 3, + created: '2017-02-22T17:43:01-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 2, + dashboardId: 1, + parentVersion: 0, + restoredFrom: -1, + version: 2, + created: '2017-02-22T17:29:52-08:00', + createdBy: 'admin', + message: '', + }, + { + id: 1, + dashboardId: 1, + parentVersion: 0, + restoredFrom: -1, + slug: 'history-dashboard', + version: 1, + created: '2017-02-22T17:06:37-08:00', + createdBy: 'admin', + message: '', + }]; +} + +export function compare(type) { + return type === 'basic' ? '
      ' : '
      '; +} + +export function restore(version, restoredFrom?) { + return { + dashboard: { + meta: { + type: 'db', + canSave: true, + canEdit: true, + canStar: true, + slug: 'history-dashboard', + expires: '0001-01-01T00:00:00Z', + created: '2017-02-21T18:40:45-08:00', + updated: '2017-04-11T21:31:22.59219665-07:00', + updatedBy: 'admin', + createdBy: 'admin', + version: version, + }, + dashboard: { + annotations: { + list: [] + }, + description: 'A random dashboard for implementing the history list', + editable: true, + gnetId: null, + graphTooltip: 0, + hideControls: false, + id: 1, + links: [], + restoredFrom: restoredFrom, + rows: [{ + collapse: false, + height: '250px', + panels: [{ + aliasColors: {}, + bars: false, + datasource: null, + fill: 1, + id: 1, + 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: [], + span: 12, + stack: false, + steppedLine: false, + targets: [{}], + thresholds: [], + timeFrom: null, + timeShift: null, + title: 'Panel Title', + tooltip: { + shared: true, + sort: 0, + value_type: 'individual' + }, + type: 'graph', + xaxis: { + 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 + }] + }], + repeat: null, + repeatIteration: null, + repeatRowId: null, + showTitle: false, + title: 'Dashboard Row', + titleSize: 'h6' + } + ], + schemaVersion: 14, + style: 'dark', + tags: [ + 'development' + ], + 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: 'utc', + title: 'History Dashboard', + version: version, + } + }, + message: 'Dashboard restored to version ' + version, + version: version + }; +} diff --git a/public/app/features/dashboard/specs/history_srv_specs.ts b/public/app/features/dashboard/specs/history_srv_specs.ts index e72a626467e..354f41e4a15 100644 --- a/public/app/features/dashboard/specs/history_srv_specs.ts +++ b/public/app/features/dashboard/specs/history_srv_specs.ts @@ -2,7 +2,7 @@ import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; import helpers from 'test/specs/helpers'; import '../history/history_srv'; -import {versions, restore} from 'test/mocks/history-mocks'; +import {versions, restore} from './history_mocks'; describe('historySrv', function() { var ctx = new helpers.ServiceTestContext(); @@ -21,50 +21,48 @@ describe('historySrv', function() { return [200, restoreResponse(parsedData.version)]; }); })); + beforeEach(ctx.createService('historySrv')); + function wrapPromise(ctx, angularPromise) { + return new Promise((resolve, reject) => { + angularPromise.then(resolve, reject); + ctx.$httpBackend.flush(); + }); + } + describe('getHistoryList', function() { - it('should return a versions array for the given dashboard id', function(done) { - ctx.service.getHistoryList({ id: 1 }).then(function(versions) { + it('should return a versions array for the given dashboard id', function() { + return wrapPromise(ctx, ctx.service.getHistoryList({ id: 1 }).then(function(versions) { expect(versions).to.eql(versionsResponse); - done(); - }); - ctx.$httpBackend.flush(); + })); }); - it('should return an empty array when not given an id', function(done) { - ctx.service.getHistoryList({ }).then(function(versions) { + it('should return an empty array when not given an id', function() { + return wrapPromise(ctx, ctx.service.getHistoryList({ }).then(function(versions) { expect(versions).to.eql([]); - done(); - }); - ctx.$httpBackend.flush(); + })); }); - it('should return an empty array when not given a dashboard', function(done) { - ctx.service.getHistoryList().then(function(versions) { + it('should return an empty array when not given a dashboard', function() { + return wrapPromise(ctx, ctx.service.getHistoryList().then(function(versions) { expect(versions).to.eql([]); - done(); - }); - ctx.$httpBackend.flush(); + })); }); }); describe('restoreDashboard', function() { - it('should return a success response given valid parameters', function(done) { - var version = 6; - ctx.service.restoreDashboard({ id: 1 }, version).then(function(response) { + it('should return a success response given valid parameters', function() { + let version = 6; + return wrapPromise(ctx, ctx.service.restoreDashboard({ id: 1 }, version).then(function(response) { expect(response).to.eql(restoreResponse(version)); - done(); - }); - ctx.$httpBackend.flush(); + })); }); - it('should return an empty object when not given an id', function(done) { - ctx.service.restoreDashboard({}, 6).then(function(response) { + it('should return an empty object when not given an id', function() { + return wrapPromise(ctx, ctx.service.restoreDashboard({}, 6).then(function(response) { expect(response).to.eql({}); - done(); - }); - ctx.$httpBackend.flush(); + })); }); }); }); diff --git a/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts b/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts new file mode 100644 index 00000000000..7a04f5f7579 --- /dev/null +++ b/public/app/features/dashboard/specs/share_modal_ctrl_specs.ts @@ -0,0 +1,110 @@ +import {describe, beforeEach, it, expect, sinon, angularMocks} from 'test/lib/common'; +import helpers from 'test/specs/helpers'; +import '../shareModalCtrl'; +import config from 'app/core/config'; +import 'app/features/panellinks/linkSrv'; + +describe('ShareModalCtrl', function() { + var ctx = new helpers.ControllerTestContext(); + + function setTime(range) { + ctx.timeSrv.timeRange = sinon.stub().returns(range); + } + + beforeEach(function() { + config.bootData = { + user: { + orgId: 1 + } + }; + }); + + setTime({ from: new Date(1000), to: new Date(2000) }); + + beforeEach(angularMocks.module('grafana.controllers')); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(angularMocks.module(function($compileProvider) { + $compileProvider.preAssignBindingsEnabled(true); + })); + + beforeEach(ctx.providePhase()); + + beforeEach(ctx.createControllerPhase('ShareModalCtrl')); + + describe('shareUrl with current time range and panel', function() { + it('should generate share url absolute time', function() { + ctx.$location.path('/test'); + ctx.scope.panel = { id: 22 }; + + ctx.scope.init(); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen'); + }); + + it('should generate render url', function() { + ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/db/my-dash'; + + ctx.scope.panel = { id: 22 }; + + ctx.scope.init(); + var base = 'http://dashboards.grafana.com/render/dashboard-solo/db/my-dash'; + var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC'; + expect(ctx.scope.imageUrl).to.contain(base + params); + }); + + it('should remove panel id when no panel in scope', function() { + ctx.$location.path('/test'); + ctx.scope.options.forCurrent = true; + ctx.scope.panel = null; + + ctx.scope.init(); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1'); + }); + + it('should add theme when specified', function() { + ctx.$location.path('/test'); + ctx.scope.options.theme = 'light'; + ctx.scope.panel = null; + + ctx.scope.init(); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light'); + }); + + it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', function() { + ctx.$location.url('/test?fullscreen&edit'); + ctx.scope.modeSharePanel = true; + ctx.scope.panel = { id: 1 }; + + ctx.scope.buildUrl(); + + expect(ctx.scope.shareUrl).to.contain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1'); + expect(ctx.scope.imageUrl).to.contain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + + }); + + it('should remove edit from image url when is first param in querystring and modeSharePanel is true', function() { + ctx.$location.url('/test?edit&fullscreen'); + ctx.scope.modeSharePanel = true; + ctx.scope.panel = { id: 1 }; + + ctx.scope.buildUrl(); + + expect(ctx.scope.shareUrl).to.contain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1'); + expect(ctx.scope.imageUrl).to.contain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC'); + + }); + + it('should include template variables in url', function() { + ctx.$location.path('/test'); + ctx.scope.options.includeTemplateVars = true; + + ctx.templateSrv.fillVariableValuesForUrl = function(params) { + params['var-app'] = 'mupp'; + params['var-server'] = 'srv-01'; + }; + + ctx.scope.buildUrl(); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01'); + }); + }); +}); + diff --git a/public/app/features/dashboard/specs/time_srv_specs.ts b/public/app/features/dashboard/specs/time_srv_specs.ts index 3c0f2637f0e..474297597c9 100644 --- a/public/app/features/dashboard/specs/time_srv_specs.ts +++ b/public/app/features/dashboard/specs/time_srv_specs.ts @@ -1,4 +1,4 @@ -import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; +import {describe, beforeEach, it, expect, sinon, angularMocks} from 'test/lib/common'; import helpers from 'test/specs/helpers'; import '../time_srv'; @@ -8,6 +8,7 @@ describe('timeSrv', function() { var ctx = new helpers.ServiceTestContext(); var _dashboard: any = { time: {from: 'now-6h', to: 'now'}, + getTimezone: sinon.stub().returns('browser') }; beforeEach(angularMocks.module('grafana.core')); diff --git a/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts b/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts new file mode 100644 index 00000000000..135a81c166e --- /dev/null +++ b/public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts @@ -0,0 +1,82 @@ +import {describe, beforeEach, it, expect, sinon, angularMocks} from 'test/lib/common'; +import 'app/features/dashboard/unsavedChangesSrv'; +import 'app/features/dashboard/dashboard_srv'; + +describe("unsavedChangesSrv", function() { + var _unsavedChangesSrv; + var _dashboardSrv; + var _location; + var _contextSrvStub = { isEditor: true }; + var _rootScope; + var tracker; + var dash; + var scope; + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(angularMocks.module(function($provide) { + $provide.value('contextSrv', _contextSrvStub); + $provide.value('$window', {}); + })); + + beforeEach(angularMocks.inject(function(unsavedChangesSrv, $location, $rootScope, dashboardSrv) { + _unsavedChangesSrv = unsavedChangesSrv; + _dashboardSrv = dashboardSrv; + _location = $location; + _rootScope = $rootScope; + })); + + beforeEach(function() { + dash = _dashboardSrv.create({ + refresh: false, + rows: [ + { + panels: [{ test: "asd", legend: { } }] + } + ] + }); + scope = _rootScope.$new(); + scope.appEvent = sinon.spy(); + scope.onAppEvent = sinon.spy(); + + tracker = new _unsavedChangesSrv.Tracker(dash, scope); + }); + + it('No changes should not have changes', function() { + expect(tracker.hasChanges()).to.be(false); + }); + + it('Simple change should be registered', function() { + dash.property = "google"; + expect(tracker.hasChanges()).to.be(true); + }); + + it('Should ignore a lot of changes', function() { + dash.time = {from: '1h'}; + dash.refresh = true; + dash.schemaVersion = 10; + expect(tracker.hasChanges()).to.be(false); + }); + + it('Should ignore row collapse change', function() { + dash.rows[0].collapse = true; + expect(tracker.hasChanges()).to.be(false); + }); + + it('Should ignore panel legend changes', function() { + dash.rows[0].panels[0].legend.sortDesc = true; + dash.rows[0].panels[0].legend.sort = "avg"; + expect(tracker.hasChanges()).to.be(false); + }); + + it('Should ignore panel repeats', function() { + dash.rows[0].panels.push({repeatPanelId: 10}); + expect(tracker.hasChanges()).to.be(false); + }); + + it('Should ignore row repeats', function() { + dash.addEmptyRow(); + dash.rows[1].repeatRowId = 10; + expect(tracker.hasChanges()).to.be(false); + }); +}); diff --git a/public/app/features/dashboard/specs/viewstate_srv_specs.ts b/public/app/features/dashboard/specs/viewstate_srv_specs.ts new file mode 100644 index 00000000000..6112f1bd41f --- /dev/null +++ b/public/app/features/dashboard/specs/viewstate_srv_specs.ts @@ -0,0 +1,53 @@ +import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; +import 'app/features/dashboard/viewStateSrv'; +import config from 'app/core/config'; + +describe('when updating view state', function() { + var viewState, location; + var timeSrv = {}; + var templateSrv = {}; + var contextSrv = { + user: { + orgId: 19 + } + }; + beforeEach(function() { + config.bootData = { + user: { + orgId: 1 + } + }; + }); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(angularMocks.module(function($provide) { + $provide.value('timeSrv', timeSrv); + $provide.value('templateSrv', templateSrv); + $provide.value('contextSrv', contextSrv); + })); + + beforeEach(angularMocks.inject(function(dashboardViewStateSrv, $location, $rootScope) { + $rootScope.onAppEvent = function() {}; + $rootScope.dashboard = {meta: {}}; + viewState = dashboardViewStateSrv.create($rootScope); + location = $location; + })); + + describe('to fullscreen true and edit true', function() { + it('should update querystring and view state', function() { + var updateState = {fullscreen: true, edit: true, panelId: 1}; + viewState.update(updateState); + expect(location.search()).to.eql({fullscreen: true, edit: true, panelId: 1, orgId: 1}); + expect(viewState.dashboard.meta.fullscreen).to.be(true); + expect(viewState.state.fullscreen).to.be(true); + }); + }); + + describe('to fullscreen false', function() { + it('should remove params from query string', function() { + viewState.update({fullscreen: true, panelId: 1, edit: true}); + viewState.update({fullscreen: false}); + expect(viewState.dashboard.meta.fullscreen).to.be(false); + expect(viewState.state.fullscreen).to.be(null); + }); + }); +}); diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/submenu/submenu.html index ce3c61f1cc3..9e9d22fb495 100644 --- a/public/app/features/dashboard/submenu/submenu.html +++ b/public/app/features/dashboard/submenu/submenu.html @@ -12,7 +12,7 @@
      diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/time_srv.ts index db6ba437e73..dfed156afae 100644 --- a/public/app/features/dashboard/time_srv.ts +++ b/public/app/features/dashboard/time_srv.ts @@ -196,9 +196,11 @@ class TimeSrv { to: moment.isMoment(this.time.to) ? moment(this.time.to) : this.time.to, }; + var timezone = this.dashboard && this.dashboard.getTimezone(); + return { - from: dateMath.parse(raw.from, false), - to: dateMath.parse(raw.to, true), + from: dateMath.parse(raw.from, false, timezone), + to: dateMath.parse(raw.to, true, timezone), raw: raw }; } diff --git a/public/app/features/dashboard/unsaved_changes_modal.ts b/public/app/features/dashboard/unsaved_changes_modal.ts index cacfbe0f045..ab3ece1b8a2 100644 --- a/public/app/features/dashboard/unsaved_changes_modal.ts +++ b/public/app/features/dashboard/unsaved_changes_modal.ts @@ -18,7 +18,7 @@ const template = `
      - + diff --git a/public/app/features/plugins/all.ts b/public/app/features/plugins/all.ts index 346fb2b30ef..a0d4e61962c 100644 --- a/public/app/features/plugins/all.ts +++ b/public/app/features/plugins/all.ts @@ -4,3 +4,5 @@ import './plugin_list_ctrl'; import './import_list/import_list'; import './ds_edit_ctrl'; import './ds_list_ctrl'; +import './datasource_srv'; +import './plugin_component'; diff --git a/public/app/features/plugins/buit_in_plugins.ts b/public/app/features/plugins/buit_in_plugins.ts new file mode 100644 index 00000000000..0603df492b0 --- /dev/null +++ b/public/app/features/plugins/buit_in_plugins.ts @@ -0,0 +1,49 @@ +import * as graphitePlugin from 'app/plugins/datasource/graphite/module'; +import * as cloudwatchPlugin from 'app/plugins/datasource/cloudwatch/module'; +import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/module'; +import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module'; +import * as grafanaPlugin from 'app/plugins/datasource/grafana/module'; +import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module'; +import * as mixedPlugin from 'app/plugins/datasource/mixed/module'; +import * as mysqlPlugin from 'app/plugins/datasource/mysql/module'; +import * as postgresPlugin from 'app/plugins/datasource/postgres/module'; +import * as prometheusPlugin from 'app/plugins/datasource/prometheus/module'; + +import * as textPanel from 'app/plugins/panel/text/module'; +import * as graphPanel from 'app/plugins/panel/graph/module'; +import * as dashListPanel from 'app/plugins/panel/dashlist/module'; +import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; +import * as alertListPanel from 'app/plugins/panel/alertlist/module'; +import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; +import * as tablePanel from 'app/plugins/panel/table/module'; +import * as singlestatPanel from 'app/plugins/panel/singlestat/module'; +import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; +import * as testDataAppPlugin from 'app/plugins/app/testdata/module'; +import * as testDataDSPlugin from 'app/plugins/app/testdata/datasource/module'; + +const builtInPlugins = { + "app/plugins/datasource/graphite/module": graphitePlugin, + "app/plugins/datasource/cloudwatch/module": cloudwatchPlugin, + "app/plugins/datasource/elasticsearch/module": elasticsearchPlugin, + "app/plugins/datasource/opentsdb/module": opentsdbPlugin, + "app/plugins/datasource/grafana/module": grafanaPlugin, + "app/plugins/datasource/influxdb/module": influxdbPlugin, + "app/plugins/datasource/mixed/module": mixedPlugin, + "app/plugins/datasource/mysql/module": mysqlPlugin, + "app/plugins/datasource/postgres/module": postgresPlugin, + "app/plugins/datasource/prometheus/module": prometheusPlugin, + "app/plugins/app/testdata/module": testDataAppPlugin, + "app/plugins/app/testdata/datasource/module": testDataDSPlugin, + + "app/plugins/panel/text/module": textPanel, + "app/plugins/panel/graph/module": graphPanel, + "app/plugins/panel/dashlist/module": dashListPanel, + "app/plugins/panel/pluginlist/module": pluginsListPanel, + "app/plugins/panel/alertlist/module": alertListPanel, + "app/plugins/panel/heatmap/module": heatmapPanel, + "app/plugins/panel/table/module": tablePanel, + "app/plugins/panel/singlestat/module": singlestatPanel, + "app/plugins/panel/gettingstarted/module": gettingStartedPanel, +}; + +export default builtInPlugins; diff --git a/public/app/core/services/datasource_srv.js b/public/app/features/plugins/datasource_srv.js similarity index 95% rename from public/app/core/services/datasource_srv.js rename to public/app/features/plugins/datasource_srv.js index db8485bb2f1..db74c13331a 100644 --- a/public/app/core/services/datasource_srv.js +++ b/public/app/features/plugins/datasource_srv.js @@ -1,10 +1,11 @@ define([ 'angular', 'lodash', - '../core_module', + 'app/core/core_module', 'app/core/config', + './plugin_loader', ], -function (angular, _, coreModule, config) { +function (angular, _, coreModule, config, pluginLoader) { 'use strict'; coreModule.default.service('datasourceSrv', function($q, $injector, $rootScope, templateSrv) { @@ -41,7 +42,7 @@ function (angular, _, coreModule, config) { var deferred = $q.defer(); var pluginDef = dsConfig.meta; - System.import(pluginDef.module).then(function(plugin) { + pluginLoader.importPluginModule(pluginDef.module).then(function(plugin) { // check if its in cache now if (self.datasources[name]) { deferred.resolve(self.datasources[name]); diff --git a/public/app/features/plugins/import_list/import_list.ts b/public/app/features/plugins/import_list/import_list.ts index 08156cb506f..a7dca22cd80 100644 --- a/public/app/features/plugins/import_list/import_list.ts +++ b/public/app/features/plugins/import_list/import_list.ts @@ -38,6 +38,8 @@ export class DashImportListCtrl { }); }, 500); }); + } else { + return Promise.resolve(); } }); } diff --git a/public/app/features/plugins/partials/ds_edit.html b/public/app/features/plugins/partials/ds_edit.html index f01ca47153b..ebe6ff48bc5 100644 --- a/public/app/features/plugins/partials/ds_edit.html +++ b/public/app/features/plugins/partials/ds_edit.html @@ -2,7 +2,7 @@
      - -
      +
      -
      -
      -
      + +
      +
      Name @@ -39,7 +39,7 @@
      -
      +
      Type
      @@ -61,8 +61,8 @@
      Testing....
      - - + +
      {{ctrl.testing.message}}
      @@ -71,9 +71,9 @@
      - - - + + Cancel diff --git a/public/app/features/plugins/partials/ds_http_settings.html b/public/app/features/plugins/partials/ds_http_settings.html index 62c3e477446..39fc7296b32 100644 --- a/public/app/features/plugins/partials/ds_http_settings.html +++ b/public/app/features/plugins/partials/ds_http_settings.html @@ -1,23 +1,23 @@
      -

      Http settings

      +

      HTTP settings

      - Url + URL -

      Specify a complete HTTP url (for example http://your_server:8080)

      +

      Specify a complete HTTP URL (for example http://your_server:8080)

      - Your access method is Direct, this means the url + Your access method is Direct, this means the URL needs to be accessible from the browser. - Your access method is currently Proxy, this means the url + Your access method is currently Proxy, this means the URL needs to be accessible from the grafana backend.
      @@ -30,7 +30,7 @@
      - Direct = url is used directly from browser
      + Direct = URL is used directly from browser
      Proxy = Grafana backend will proxy the request
      @@ -38,27 +38,21 @@
      -

      Http Auth

      +

      HTTP Auth

      +
      +
      + + +
      +
      + + +
      +
      -
      - - - - -
      -
      - - - - +
      + +
      @@ -79,7 +73,7 @@
      -
      +
      TLS Auth Details
      TLS Certs are encrypted and stored in the Grafana database. @@ -90,7 +84,7 @@
      - +
      @@ -100,29 +94,31 @@
      -
      -
      - +
      +
      +
      + +
      +
      + +
      +
      + + reset +
      -
      - -
      -
      - - reset -
      -
      -
      -
      - -
      -
      - -
      -
      - - reset +
      +
      + +
      +
      + +
      +
      + + reset +
      diff --git a/public/app/core/directives/plugin_component.ts b/public/app/features/plugins/plugin_component.ts similarity index 92% rename from public/app/core/directives/plugin_component.ts rename to public/app/features/plugins/plugin_component.ts index d0f21501e7e..e585bca0785 100644 --- a/public/app/core/directives/plugin_component.ts +++ b/public/app/features/plugins/plugin_component.ts @@ -1,10 +1,10 @@ -/// - import angular from 'angular'; import _ from 'lodash'; import config from 'app/core/config'; import coreModule from 'app/core/core_module'; +import {importPluginModule} from './plugin_loader'; + import {UnknownPanelCtrl} from 'app/plugins/panel/unknown/module'; /** @ngInject **/ @@ -64,7 +64,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ let panelInfo = config.panels[scope.panel.type]; var panelCtrlPromise = Promise.resolve(UnknownPanelCtrl); if (panelInfo) { - panelCtrlPromise = System.import(panelInfo.module).then(function(panelModule) { + panelCtrlPromise = importPluginModule(panelInfo.module).then(function(panelModule) { return panelModule.PanelCtrl; }); } @@ -104,7 +104,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ return datasourceSrv.get(datasource).then(ds => { scope.datasource = ds; - return System.import(ds.meta.module).then(dsModule => { + return importPluginModule(ds.meta.module).then(dsModule => { return { baseUrl: ds.meta.baseUrl, name: 'query-ctrl-' + ds.meta.id, @@ -118,7 +118,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ // QueryOptionsCtrl case "query-options-ctrl": { return datasourceSrv.get(scope.ctrl.panel.datasource).then(ds => { - return System.import(ds.meta.module).then((dsModule): any => { + return importPluginModule(ds.meta.module).then((dsModule): any => { if (!dsModule.QueryOptionsCtrl) { return {notFound: true}; } @@ -135,7 +135,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ } // Annotations case "annotations-query-ctrl": { - return System.import(scope.ctrl.currentDatasource.meta.module).then(function(dsModule) { + return importPluginModule(scope.ctrl.currentDatasource.meta.module).then(function(dsModule) { return { baseUrl: scope.ctrl.currentDatasource.meta.baseUrl, name: 'annotations-query-ctrl-' + scope.ctrl.currentDatasource.meta.id, @@ -148,7 +148,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ // Datasource ConfigCtrl case 'datasource-config-ctrl': { var dsMeta = scope.ctrl.datasourceMeta; - return System.import(dsMeta.module).then(function(dsModule): any { + return importPluginModule(dsMeta.module).then(function(dsModule): any { if (!dsModule.ConfigCtrl) { return {notFound: true}; } @@ -165,7 +165,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ // AppConfigCtrl case 'app-config-ctrl': { let model = scope.ctrl.model; - return System.import(model.module).then(function(appModule) { + return importPluginModule(model.module).then(function(appModule) { return { baseUrl: model.baseUrl, name: 'app-config-' + model.id, @@ -178,7 +178,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ // App Page case 'app-page': { let appModel = scope.ctrl.appModel; - return System.import(appModel.module).then(function(appModule) { + return importPluginModule(appModel.module).then(function(appModule) { return { baseUrl: appModel.baseUrl, name: 'app-page-' + appModel.id + '-' + scope.ctrl.page.slug, diff --git a/public/app/features/plugins/plugin_edit_ctrl.ts b/public/app/features/plugins/plugin_edit_ctrl.ts index 0b8119f2125..ba8a909a2cf 100644 --- a/public/app/features/plugins/plugin_edit_ctrl.ts +++ b/public/app/features/plugins/plugin_edit_ctrl.ts @@ -79,6 +79,7 @@ export class PluginEditCtrl { case 'app': return 'icon-gf icon-gf-apps'; case 'page': return 'icon-gf icon-gf-endpoint-tiny'; case 'dashboard': return 'icon-gf icon-gf-dashboard'; + default: return 'icon-gf icon-gf-apps'; } } diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts new file mode 100644 index 00000000000..99f9783aeca --- /dev/null +++ b/public/app/features/plugins/plugin_loader.ts @@ -0,0 +1,123 @@ +import System from 'systemjs/dist/system.js'; +import _ from 'lodash'; +import * as sdk from 'app/plugins/sdk'; +import kbn from 'app/core/utils/kbn'; +import moment from 'moment'; +import angular from 'angular'; +import jquery from 'jquery'; +import config from 'app/core/config'; +import TimeSeries from 'app/core/time_series2'; +import TableModel from 'app/core/table_model'; +import {coreModule, appEvents, contextSrv} from 'app/core/core'; +import * as datemath from 'app/core/utils/datemath'; +import * as fileExport from 'app/core/utils/file_export'; +import * as flatten from 'app/core/utils/flatten'; +import * as ticks from 'app/core/utils/ticks'; +import builtInPlugins from './buit_in_plugins'; +import d3 from 'vendor/d3/d3'; + +// rxjs +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; + +// these imports add functions to Observable +import 'rxjs/add/observable/empty'; +import 'rxjs/add/observable/from'; +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/combineAll'; + +System.config({ + baseURL: 'public', + defaultExtension: 'js', + packages: { + 'plugins': { + defaultExtension: 'js' + } + }, + map: { + text: 'vendor/plugin-text/text.js', + css: 'vendor/plugin-css/css.js' + }, + meta: { + '*': {esModule: true} + } +}); + +// add cache busting +var systemLocate = System.locate; +System.cacheBust = '?bust=' + Date.now(); +System.locate = function(load) { + var System = this; + return Promise.resolve(systemLocate.call(this, load)).then(function(address) { + return address + System.cacheBust; + }); +}; + +function exposeToPlugin(name: string, component: any) { + System.registerDynamic(name, [], true, function(require, exports, module) { + module.exports = component; + }); +} + +exposeToPlugin('lodash', _); +exposeToPlugin('moment', moment); +exposeToPlugin('jquery', jquery); +exposeToPlugin('angular', angular); +exposeToPlugin('rxjs/Subject', Subject); +exposeToPlugin('rxjs/Observable', Observable); +exposeToPlugin('d3', d3); + +exposeToPlugin('app/plugins/sdk', sdk); +exposeToPlugin('app/core/utils/datemath', datemath); +exposeToPlugin('app/core/utils/file_export', fileExport); +exposeToPlugin('app/core/utils/flatten', flatten); +exposeToPlugin('app/core/utils/kbn', kbn); +exposeToPlugin('app/core/utils/ticks', ticks); + +exposeToPlugin('app/core/config', config); +exposeToPlugin('app/core/time_series', TimeSeries); +exposeToPlugin('app/core/time_series2', TimeSeries); +exposeToPlugin('app/core/table_model', TableModel); +exposeToPlugin('app/core/app_events', appEvents); +exposeToPlugin('app/core/core_module', coreModule); +exposeToPlugin('app/core/core', { + coreModule: coreModule, + appEvents: appEvents, + contextSrv: contextSrv, + __esModule: true +}); + +import 'vendor/flot/jquery.flot'; +import 'vendor/flot/jquery.flot.selection'; +import 'vendor/flot/jquery.flot.time'; +import 'vendor/flot/jquery.flot.stack'; +import 'vendor/flot/jquery.flot.pie'; +import 'vendor/flot/jquery.flot.stackpercent'; +import 'vendor/flot/jquery.flot.fillbelow'; +import 'vendor/flot/jquery.flot.crosshair'; +import 'vendor/flot/jquery.flot.dashes'; + +const flotDeps = [ + 'jquery.flot', 'jquery.flot.pie', 'jquery.flot.time', 'jquery.flot.fillbelow', 'jquery.flot.crosshair', + 'jquery.flot.stack', 'jquery.flot.selection', 'jquery.flot.stackpercent', 'jquery.flot.events' +]; +for (let flotDep of flotDeps) { + exposeToPlugin(flotDep, {fakeDep: 1}); +} + +export function importPluginModule(path: string): Promise { + let builtIn = builtInPlugins[path]; + if (builtIn) { + return Promise.resolve(builtIn); + } + return System.import(path); +} + +export function loadPluginCss(options) { + if (config.bootData.user.lightTheme) { + System.import(options.light + '!css'); + } else { + System.import(options.dark + '!css'); + } +} + diff --git a/public/app/features/plugins/specs/datasource_srv_specs.ts b/public/app/features/plugins/specs/datasource_srv_specs.ts new file mode 100644 index 00000000000..f4bb22b24a1 --- /dev/null +++ b/public/app/features/plugins/specs/datasource_srv_specs.ts @@ -0,0 +1,60 @@ +import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; +import config from 'app/core/config'; +import 'app/features/plugins/datasource_srv'; + +describe('datasource_srv', function() { + var _datasourceSrv; + var metricSources; + var templateSrv = {}; + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module(function($provide) { + $provide.value('templateSrv', templateSrv); + })); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(angularMocks.inject(function(datasourceSrv) { + _datasourceSrv = datasourceSrv; + })); + + describe('when loading metric sources', function() { + var unsortedDatasources = { + 'mmm': { + type: 'test-db', + meta: { metrics: {m: 1} } + }, + '--Grafana--': { + type: 'grafana', + meta: {builtIn: true, metrics: {m: 1}, id: "grafana"} + }, + '--Mixed--': { + type: 'test-db', + meta: {builtIn: true, metrics: {m: 1}, id: "mixed"} + }, + 'ZZZ': { + type: 'test-db', + meta: {metrics: {m: 1} } + }, + 'aaa': { + type: 'test-db', + meta: { metrics: {m: 1} } + }, + 'BBB': { + type: 'test-db', + meta: { metrics: {m: 1} } + }, + }; + beforeEach(function() { + config.datasources = unsortedDatasources; + metricSources = _datasourceSrv.getMetricSources({skipVariables: true}); + }); + + it('should return a list of sources sorted case insensitively with builtin sources last', function() { + expect(metricSources[0].name).to.be('aaa'); + expect(metricSources[1].name).to.be('BBB'); + expect(metricSources[2].name).to.be('mmm'); + expect(metricSources[3].name).to.be('ZZZ'); + expect(metricSources[4].name).to.be('--Grafana--'); + expect(metricSources[5].name).to.be('--Mixed--'); + }); + }); +}); diff --git a/public/app/features/styleguide/styleguide.ts b/public/app/features/styleguide/styleguide.ts index 7bcb60aee3c..a2bdb498c4e 100644 --- a/public/app/features/styleguide/styleguide.ts +++ b/public/app/features/styleguide/styleguide.ts @@ -36,7 +36,7 @@ class StyleGuideCtrl { } loadColors() { - this.$http.get('public/sass/styleguide.json').then(res => { + this.$http.get('public/build/styleguide.json').then(res => { this.colors = _.map(res.data[this.theme], (value, key) => { return {name: key, value: value}; }); diff --git a/public/app/features/templating/editor_ctrl.ts b/public/app/features/templating/editor_ctrl.ts index 44a73b75f72..c10bc5bb5ec 100644 --- a/public/app/features/templating/editor_ctrl.ts +++ b/public/app/features/templating/editor_ctrl.ts @@ -54,7 +54,7 @@ export class VariableEditorCtrl { $scope.isValid = function() { if (!$scope.ctrl.form.$valid) { - return; + return false; } if (!$scope.current.name.match(/^\w+$/)) { diff --git a/public/app/features/templating/specs/template_srv_specs.ts b/public/app/features/templating/specs/template_srv_specs.ts index f8a006fc83c..5a48fc8e173 100644 --- a/public/app/features/templating/specs/template_srv_specs.ts +++ b/public/app/features/templating/specs/template_srv_specs.ts @@ -10,6 +10,7 @@ describe('templateSrv', function() { beforeEach(angularMocks.module('grafana.services')); beforeEach(angularMocks.module($provide => { $provide.value('timeSrv', {}); + $provide.value('datasourceSrv', {}); })); beforeEach(angularMocks.inject(function(variableSrv, templateSrv) { diff --git a/public/app/headers/common.d.ts b/public/app/headers/common.d.ts index c643c0af452..8b9dfa256ef 100644 --- a/public/app/headers/common.d.ts +++ b/public/app/headers/common.d.ts @@ -1,73 +1,6 @@ - declare var System: any; -// dummy modules -declare module 'app/core/config' { - var config: any; - export default config; -} - -declare module 'lodash' { - var lodash: any; - export default lodash; -} - -declare module 'moment' { - var moment: any; - export default moment; -} - -declare module 'angular' { - var angular: any; - export default angular; -} - -declare module 'jquery' { - var jquery: any; - export default jquery; -} - -declare module 'app/core/utils/kbn' { - var kbn: any; - export default kbn; -} - -declare module 'app/core/store' { - var store: any; - export default store; -} - -declare module 'tether' { - var config: any; - export default config; -} - -declare module 'tether-drop' { - var config: any; - export default config; -} - declare module 'eventemitter3' { var config: any; export default config; } - -declare module 'mousetrap' { - var config: any; - export default config; -} - -declare module 'remarkable' { - var config: any; - export default config; -} - -declare module 'd3' { - var d3: any; - export default d3; -} - -declare module 'ace' { - var ace: any; - export default ace; -} diff --git a/public/app/index.ts b/public/app/index.ts new file mode 100644 index 00000000000..696b18112a6 --- /dev/null +++ b/public/app/index.ts @@ -0,0 +1,3 @@ +import app from './app'; +app.init(); + diff --git a/public/app/partials/error.html b/public/app/partials/error.html index b1ae05e7e93..0750e860dd5 100644 --- a/public/app/partials/error.html +++ b/public/app/partials/error.html @@ -37,13 +37,13 @@
      -

      Chance your are one the page your'e looking for.

      +

      Chances you are on the page you are looking for.

      0%

      Sorry for the inconvenience

      -

      Please go back to your home dashboard and try again.

      -

      If the error persists, seek help att the community site.

      +

      Please go back to your home dashboard and try again.

      +

      If the error persists, seek help on the community site.

      diff --git a/public/app/partials/login.html b/public/app/partials/login.html index fe19d34f8d5..48c94640fc5 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -20,7 +20,7 @@
      User - +
      Password diff --git a/public/app/partials/roweditor.html b/public/app/partials/roweditor.html deleted file mode 100644 index d414cb151f3..00000000000 --- a/public/app/partials/roweditor.html +++ /dev/null @@ -1,47 +0,0 @@ -
      -

      - Row settings -

      - - -
      - -
      -
      -
      -
      -
      Row details
      -
      -
      -
      - -
      - Title - -
      -
      - Height - - -
      -
      -
      -
      -
      -
      -
      Templating options
      -
      -
      -
      - Repeat Row -
      - -
      - -
      - - +
      + +
      + Action + +
      +
      + Alarm Name + +
      diff --git a/public/app/plugins/datasource/cloudwatch/plugin.json b/public/app/plugins/datasource/cloudwatch/plugin.json index bac2ddca8b9..3af7d8ccb6e 100644 --- a/public/app/plugins/datasource/cloudwatch/plugin.json +++ b/public/app/plugins/datasource/cloudwatch/plugin.json @@ -4,6 +4,7 @@ "id": "cloudwatch", "metrics": true, + "alerting": true, "annotations": true, "info": { diff --git a/public/app/plugins/datasource/cloudwatch/specs/annotation_query_specs.ts b/public/app/plugins/datasource/cloudwatch/specs/annotation_query_specs.ts deleted file mode 100644 index d469ca8685b..00000000000 --- a/public/app/plugins/datasource/cloudwatch/specs/annotation_query_specs.ts +++ /dev/null @@ -1,81 +0,0 @@ -import "../datasource"; -import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; -import moment from 'moment'; -import helpers from 'test/specs/helpers'; -import {CloudWatchDatasource} from "../datasource"; -import CloudWatchAnnotationQuery from '../annotation_query'; - -describe('CloudWatchAnnotationQuery', function() { - var ctx = new helpers.ServiceTestContext(); - var instanceSettings = { - jsonData: {defaultRegion: 'us-east-1', access: 'proxy'}, - }; - - beforeEach(angularMocks.module('grafana.core')); - beforeEach(angularMocks.module('grafana.services')); - beforeEach(angularMocks.module('grafana.controllers')); - beforeEach(ctx.providePhase(['templateSrv', 'backendSrv'])); - - beforeEach(angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) { - ctx.$q = $q; - ctx.$httpBackend = $httpBackend; - ctx.$rootScope = $rootScope; - ctx.ds = $injector.instantiate(CloudWatchDatasource, {instanceSettings: instanceSettings}); - })); - - describe('When performing annotationQuery', function() { - var parameter = { - annotation: { - region: 'us-east-1', - namespace: 'AWS/EC2', - metricName: 'CPUUtilization', - dimensions: { - InstanceId: 'i-12345678' - }, - statistics: ['Average'], - period: 300 - }, - range: { - from: moment(1443438674760), - to: moment(1443460274760) - } - }; - var alarmResponse = { - MetricAlarms: [ - { - AlarmName: 'test_alarm_name' - } - ] - }; - var historyResponse = { - AlarmHistoryItems: [ - { - Timestamp: '2015-01-01T00:00:00.000Z', - HistoryItemType: 'StateUpdate', - AlarmName: 'test_alarm_name', - HistoryData: '{}', - HistorySummary: 'test_history_summary' - } - ] - }; - beforeEach(function() { - ctx.backendSrv.datasourceRequest = function(params) { - switch (params.data.action) { - case 'DescribeAlarmsForMetric': - return ctx.$q.when({data: alarmResponse}); - case 'DescribeAlarmHistory': - return ctx.$q.when({data: historyResponse}); - } - }; - }); - it('should return annotation list', function(done) { - var annotationQuery = new CloudWatchAnnotationQuery(ctx.ds, parameter.annotation, ctx.$q, ctx.templateSrv); - annotationQuery.process(parameter.range.from, parameter.range.to).then(function(result) { - expect(result[0].title).to.be('test_alarm_name'); - expect(result[0].text).to.be('test_history_summary'); - done(); - }); - ctx.$rootScope.$apply(); - }); - }); -}); diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts index 0130aee60d3..02312148b6a 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts @@ -1,8 +1,7 @@ - import "../datasource"; import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; import helpers from 'test/specs/helpers'; -import {CloudWatchDatasource} from "../datasource"; +import CloudWatchDatasource from "../datasource"; describe('CloudWatchDatasource', function() { var ctx = new helpers.ServiceTestContext(); @@ -28,6 +27,7 @@ describe('CloudWatchDatasource', function() { var query = { range: { from: 'now-1h', to: 'now' }, + rangeRaw: { from: 1483228800, to: 1483232400 }, targets: [ { region: 'us-east-1', @@ -37,43 +37,47 @@ describe('CloudWatchDatasource', function() { InstanceId: 'i-12345678' }, statistics: ['Average'], - period: 300 + period: '300' } ] }; var response = { - Datapoints: [ - { - Average: 1, - Timestamp: 'Wed Dec 31 1969 16:00:00 GMT-0800 (PST)' - }, - { - Average: 2, - Timestamp: 'Wed Dec 31 1969 16:05:00 GMT-0800 (PST)' - }, - { - Average: 5, - Timestamp: 'Wed Dec 31 1969 16:15:00 GMT-0800 (PST)' + timings: [null], + results: { + A: { + error: '', + refId: 'A', + series: [ + { + name: 'CPUUtilization_Average', + points: [ + [1, 1483228800000], + [2, 1483229100000], + [5, 1483229700000], + ], + tags: { + InstanceId: 'i-12345678' + } + } + ] } - ], - Label: 'CPUUtilization' + } }; beforeEach(function() { - ctx.backendSrv.datasourceRequest = function(params) { + ctx.backendSrv.post = function(path, params) { requestParams = params; - return ctx.$q.when({data: response}); + return ctx.$q.when(response); }; }); it('should generate the correct query', function(done) { ctx.ds.query(query).then(function() { - var params = requestParams.data.parameters; + var params = requestParams.queries[0]; expect(params.namespace).to.be(query.targets[0].namespace); expect(params.metricName).to.be(query.targets[0].metricName); - expect(params.dimensions[0].Name).to.be(Object.keys(query.targets[0].dimensions)[0]); - expect(params.dimensions[0].Value).to.be(query.targets[0].dimensions[Object.keys(query.targets[0].dimensions)[0]]); + expect(params.dimensions['InstanceId']).to.be('i-12345678'); expect(params.statistics).to.eql(query.targets[0].statistics); expect(params.period).to.be(query.targets[0].period); done(); @@ -88,6 +92,7 @@ describe('CloudWatchDatasource', function() { var query = { range: { from: 'now-1h', to: 'now' }, + rangeRaw: { from: 1483228800, to: 1483232400 }, targets: [ { region: 'us-east-1', @@ -103,8 +108,8 @@ describe('CloudWatchDatasource', function() { }; ctx.ds.query(query).then(function() { - var params = requestParams.data.parameters; - expect(params.period).to.be(600); + var params = requestParams.queries[0]; + expect(params.period).to.be('600'); done(); }); ctx.$rootScope.$apply(); @@ -112,16 +117,8 @@ describe('CloudWatchDatasource', function() { it('should return series list', function(done) { ctx.ds.query(query).then(function(result) { - expect(result.data[0].target).to.be('CPUUtilization_Average'); - expect(result.data[0].datapoints[0][0]).to.be(response.Datapoints[0]['Average']); - done(); - }); - ctx.$rootScope.$apply(); - }); - - it('should return null for missing data point', function(done) { - ctx.ds.query(query).then(function(result) { - expect(result.data[0].datapoints[2][0]).to.be(null); + expect(result.data[0].target).to.be(response.results.A.series[0].name); + expect(result.data[0].datapoints[0][0]).to.be(response.results.A.series[0].points[0][0]); done(); }); ctx.$rootScope.$apply(); @@ -173,6 +170,7 @@ describe('CloudWatchDatasource', function() { var query = { range: { from: 'now-1h', to: 'now' }, + rangeRaw: { from: 1483228800, to: 1483232400 }, targets: [ { region: 'us-east-1', @@ -189,40 +187,40 @@ describe('CloudWatchDatasource', function() { }; var response = { - Datapoints: [ - { - ExtendedStatistics: { - 'p90.00': 1 - }, - Timestamp: 'Wed Dec 31 1969 16:00:00 GMT-0800 (PST)' - }, - { - ExtendedStatistics: { - 'p90.00': 2 - }, - Timestamp: 'Wed Dec 31 1969 16:05:00 GMT-0800 (PST)' - }, - { - ExtendedStatistics: { - 'p90.00': 5 - }, - Timestamp: 'Wed Dec 31 1969 16:15:00 GMT-0800 (PST)' + timings: [null], + results: { + A: { + error: '', + refId: 'A', + series: [ + { + name: 'TargetResponseTime_p90.00', + points: [ + [1, 1483228800000], + [2, 1483229100000], + [5, 1483229700000], + ], + tags: { + LoadBalancer: 'lb', + TargetGroup: 'tg' + } + } + ] } - ], - Label: 'TargetResponseTime' + } }; beforeEach(function() { - ctx.backendSrv.datasourceRequest = function(params) { + ctx.backendSrv.post = function(path, params) { requestParams = params; - return ctx.$q.when({data: response}); + return ctx.$q.when(response); }; }); it('should return series list', function(done) { ctx.ds.query(query).then(function(result) { - expect(result.data[0].target).to.be('TargetResponseTime_p90.00'); - expect(result.data[0].datapoints[0][0]).to.be(response.Datapoints[0].ExtendedStatistics['p90.00']); + expect(result.data[0].target).to.be(response.results.A.series[0].name); + expect(result.data[0].datapoints[0][0]).to.be(response.results.A.series[0].points[0][0]); done(); }); ctx.$rootScope.$apply(); @@ -237,7 +235,11 @@ describe('CloudWatchDatasource', function() { setupCallback(); ctx.backendSrv.datasourceRequest = args => { scenario.request = args; - return ctx.$q.when({data: scenario.requestResponse }); + return ctx.$q.when({ data: scenario.requestResponse }); + }; + ctx.backendSrv.post = (path, args) => { + scenario.request = args; + return ctx.$q.when(scenario.requestResponse); }; ctx.ds.metricFindQuery(query).then(args => { scenario.result = args; @@ -252,135 +254,178 @@ describe('CloudWatchDatasource', function() { describeMetricFindQuery('regions()', scenario => { scenario.setup(() => { - scenario.requestResponse = [{text: 'us-east-1'}]; + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [ + { rows: [['us-east-1', 'us-east-1']] } + ] + } + } + }; }); it('should call __GetRegions and return result', () => { expect(scenario.result[0].text).to.contain('us-east-1'); - expect(scenario.request.data.action).to.be('__GetRegions'); + expect(scenario.request.queries[0].type).to.be('metricFindQuery'); + expect(scenario.request.queries[0].subtype).to.be('regions'); }); }); describeMetricFindQuery('namespaces()', scenario => { scenario.setup(() => { - scenario.requestResponse = [{text: 'AWS/EC2'}]; + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [ + { rows: [['AWS/EC2', 'AWS/EC2']] } + ] + } + } + }; }); it('should call __GetNamespaces and return result', () => { expect(scenario.result[0].text).to.contain('AWS/EC2'); - expect(scenario.request.data.action).to.be('__GetNamespaces'); + expect(scenario.request.queries[0].type).to.be('metricFindQuery'); + expect(scenario.request.queries[0].subtype).to.be('namespaces'); }); }); describeMetricFindQuery('metrics(AWS/EC2)', scenario => { scenario.setup(() => { - scenario.requestResponse = [{text: 'CPUUtilization'}]; + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [ + { rows: [['CPUUtilization', 'CPUUtilization']] } + ] + } + } + }; }); it('should call __GetMetrics and return result', () => { expect(scenario.result[0].text).to.be('CPUUtilization'); - expect(scenario.request.data.action).to.be('__GetMetrics'); + expect(scenario.request.queries[0].type).to.be('metricFindQuery'); + expect(scenario.request.queries[0].subtype).to.be('metrics'); }); }); describeMetricFindQuery('dimension_keys(AWS/EC2)', scenario => { scenario.setup(() => { - scenario.requestResponse = [{text: 'InstanceId'}]; + scenario.requestResponse = { + results: { + metricFindQuery: { + tables: [ + { rows: [['InstanceId', 'InstanceId']] } + ] + } + } + }; }); it('should call __GetDimensions and return result', () => { expect(scenario.result[0].text).to.be('InstanceId'); - expect(scenario.request.data.action).to.be('__GetDimensions'); + expect(scenario.request.queries[0].type).to.be('metricFindQuery'); + expect(scenario.request.queries[0].subtype).to.be('dimension_keys'); }); }); describeMetricFindQuery('dimension_values(us-east-1,AWS/EC2,CPUUtilization,InstanceId)', scenario => { scenario.setup(() => { scenario.requestResponse = { - Metrics: [ - { - Namespace: 'AWS/EC2', - MetricName: 'CPUUtilization', - Dimensions: [ - { - Name: 'InstanceId', - Value: 'i-12345678' - } + results: { + metricFindQuery: { + tables: [ + { rows: [['i-12345678', 'i-12345678']] } ] } - ] + } }; }); it('should call __ListMetrics and return result', () => { - expect(scenario.result[0].text).to.be('i-12345678'); - expect(scenario.request.data.action).to.be('ListMetrics'); + expect(scenario.result[0].text).to.contain('i-12345678'); + expect(scenario.request.queries[0].type).to.be('metricFindQuery'); + expect(scenario.request.queries[0].subtype).to.be('dimension_values'); }); }); it('should caclculate the correct period', function () { var hourSec = 60 * 60; var daySec = hourSec * 24; - var start = 1483196400; + var start = 1483196400 * 1000; var testData: any[] = [ - [{ period: 60 }, { namespace: 'AWS/EC2' }, {}, start, start + 3600, (hourSec * 3), 60], - [{ period: null }, { namespace: 'AWS/EC2' }, {}, start, start + 3600, (hourSec * 3), 300], - [{ period: 60 }, { namespace: 'AWS/ELB' }, {}, start, start + 3600, (hourSec * 3), 60], - [{ period: null }, { namespace: 'AWS/ELB' }, {}, start, start + 3600, (hourSec * 3), 60], - [{ period: 1 }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 1440 - 1, (hourSec * 3 - 1), 1], - [{ period: 1 }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 3600, (hourSec * 3 - 1), 60], - [{ period: 60 }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 3600, (hourSec * 3), 60], - [{ period: null }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 3600, (hourSec * 3 - 1), 60], - [{ period: null }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 3600, (hourSec * 3), 60], - [{ period: null }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 3600, (daySec * 15), 60], - [{ period: null }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 3600, (daySec * 63), 300], - [{ period: null }, { namespace: 'CustomMetricsNamespace' }, {}, start, start + 3600, (daySec * 455), 3600] + [ + { period: 60, namespace: 'AWS/EC2' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3), 60 + ], + [ + { period: null, namespace: 'AWS/EC2' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3), 300 + ], + [ + { period: 60, namespace: 'AWS/ELB' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3), 60 + ], + [ + { period: null, namespace: 'AWS/ELB' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3), 60 + ], + [ + { period: 1, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + (1440 - 1) * 1000) } }, + (hourSec * 3 - 1), 1 + ], + [ + { period: 1, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3 - 1), 60 + ], + [ + { period: 60, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3), 60 + ], + [ + { period: null, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3 - 1), 60 + ], + [ + { period: null, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (hourSec * 3), 60 + ], + [ + { period: null, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (daySec * 15), 60 + ], + [ + { period: null, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (daySec * 63), 300 + ], + [ + { period: null, namespace: 'CustomMetricsNamespace' }, + { range: { from: new Date(start), to: new Date(start + 3600 * 1000) } }, + (daySec * 455), 3600 + ] ]; for (let t of testData) { let target = t[0]; - let query = t[1]; - let options = t[2]; - let start = t[3]; - let end = t[4]; - let now = start + t[5]; - let expected = t[6]; - let actual = ctx.ds.getPeriod(target, query, options, start, end, now); + let options = t[1]; + let now = new Date(options.range.from.valueOf() + t[2] * 1000); + let expected = t[3]; + let actual = ctx.ds.getPeriod(target, options, now); expect(actual).to.be(expected); } }); - describeMetricFindQuery('ec2_instance_attribute(us-east-1, Tags.Name, { "tag:team": [ "sysops" ] })', scenario => { - scenario.setup(() => { - scenario.requestResponse = { - Reservations: [ - { - Instances: [ - { - Tags: [ - { Key: 'InstanceId', Value: 'i-123456' }, - { Key: 'Name', Value: 'Sysops Dev Server' }, - { Key: 'env', Value: 'dev' }, - { Key: 'team', Value: 'sysops' } - ] - }, - { - Tags: [ - { Key: 'InstanceId', Value: 'i-789012' }, - { Key: 'Name', Value: 'Sysops Staging Server' }, - { Key: 'env', Value: 'staging' }, - { Key: 'team', Value: 'sysops' } - ] - } - ] - } - ] - }; - }); - - it('should return the "Name" tag for each instance', function() { - expect(scenario.result[0].text).to.be('Sysops Dev Server'); - expect(scenario.result[1].text).to.be('Sysops Staging Server'); - }); - }); - }); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.d.ts b/public/app/plugins/datasource/elasticsearch/datasource.d.ts deleted file mode 100644 index 3682abfb614..00000000000 --- a/public/app/plugins/datasource/elasticsearch/datasource.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare var ElasticDatasource: any; -export {ElasticDatasource}; - diff --git a/public/app/plugins/datasource/elasticsearch/datasource.js b/public/app/plugins/datasource/elasticsearch/datasource.js deleted file mode 100644 index d6c2568ae47..00000000000 --- a/public/app/plugins/datasource/elasticsearch/datasource.js +++ /dev/null @@ -1,372 +0,0 @@ -define([ - 'angular', - 'lodash', - 'moment', - 'app/core/utils/kbn', - './query_builder', - './index_pattern', - './elastic_response', - './query_ctrl', -], -function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticResponse) { - 'use strict'; - - ElasticResponse = ElasticResponse.ElasticResponse; - - /** @ngInject */ - function ElasticDatasource(instanceSettings, $q, backendSrv, templateSrv, timeSrv) { - this.basicAuth = instanceSettings.basicAuth; - this.withCredentials = instanceSettings.withCredentials; - this.url = instanceSettings.url; - this.name = instanceSettings.name; - this.index = instanceSettings.index; - this.timeField = instanceSettings.jsonData.timeField; - this.esVersion = instanceSettings.jsonData.esVersion; - this.indexPattern = new IndexPattern(instanceSettings.index, instanceSettings.jsonData.interval); - this.interval = instanceSettings.jsonData.timeInterval; - this.queryBuilder = new ElasticQueryBuilder({ - timeField: this.timeField, - esVersion: this.esVersion, - }); - - this._request = function(method, url, data) { - var options = { - url: this.url + "/" + url, - method: method, - data: data - }; - - if (this.basicAuth || this.withCredentials) { - options.withCredentials = true; - } - if (this.basicAuth) { - options.headers = { - "Authorization": this.basicAuth - }; - } - - return backendSrv.datasourceRequest(options); - }; - - this._get = function(url) { - var range = timeSrv.timeRange(); - var index_list = this.indexPattern.getIndexList(range.from.valueOf(), range.to.valueOf()); - if (_.isArray(index_list) && index_list.length) { - return this._request('GET', index_list[0] + url).then(function(results) { - results.data.$$config = results.config; - return results.data; - }); - } else { - return this._request('GET', this.indexPattern.getIndexForToday() + url).then(function(results) { - results.data.$$config = results.config; - return results.data; - }); - } - }; - - this._post = function(url, data) { - return this._request('POST', url, data).then(function(results) { - results.data.$$config = results.config; - return results.data; - }); - }; - - this.annotationQuery = function(options) { - var annotation = options.annotation; - var timeField = annotation.timeField || '@timestamp'; - var queryString = annotation.query || '*'; - var tagsField = annotation.tagsField || 'tags'; - var titleField = annotation.titleField || 'desc'; - var textField = annotation.textField || null; - - var range = {}; - range[timeField]= { - from: options.range.from.valueOf(), - to: options.range.to.valueOf(), - format: "epoch_millis", - }; - - var queryInterpolated = templateSrv.replace(queryString, {}, 'lucene'); - var query = { - "bool": { - "filter": [ - { "range": range }, - { - "query_string": { - "query": queryInterpolated - } - } - ] - } - }; - - var data = { - "query" : query, - "size": 10000 - }; - - // fields field not supported on ES 5.x - if (this.esVersion < 5) { - data["fields"] = [timeField, "_source"]; - } - - var header = {search_type: "query_then_fetch", "ignore_unavailable": true}; - - // old elastic annotations had index specified on them - if (annotation.index) { - header.index = annotation.index; - } else { - header.index = this.indexPattern.getIndexList(options.range.from, options.range.to); - } - - var payload = angular.toJson(header) + '\n' + angular.toJson(data) + '\n'; - - return this._post('_msearch', payload).then(function(res) { - var list = []; - var hits = res.responses[0].hits.hits; - - var getFieldFromSource = function(source, fieldName) { - if (!fieldName) { return; } - - var fieldNames = fieldName.split('.'); - var fieldValue = source; - - for (var i = 0; i < fieldNames.length; i++) { - fieldValue = fieldValue[fieldNames[i]]; - if (!fieldValue) { - console.log('could not find field in annotation: ', fieldName); - return ''; - } - } - - if (_.isArray(fieldValue)) { - fieldValue = fieldValue.join(', '); - } - return fieldValue; - }; - - for (var i = 0; i < hits.length; i++) { - var source = hits[i]._source; - var time = source[timeField]; - if (typeof hits[i].fields !== 'undefined') { - var fields = hits[i].fields; - if (_.isString(fields[timeField]) || _.isNumber(fields[timeField])) { - time = fields[timeField]; - } - } - - var event = { - annotation: annotation, - time: moment.utc(time).valueOf(), - title: getFieldFromSource(source, titleField), - tags: getFieldFromSource(source, tagsField), - text: getFieldFromSource(source, textField) - }; - - list.push(event); - } - return list; - }); - }; - - this.testDatasource = function() { - timeSrv.setTime({ from: 'now-1m', to: 'now' }, true); - // validate that the index exist and has date field - return this.getFields({type: 'date'}).then(function(dateFields) { - var timeField = _.find(dateFields, {text: this.timeField}); - if (!timeField) { - return { status: "error", message: "No date field named " + this.timeField + ' found' }; - } - return { status: "success", message: "Index OK. Time field name OK." }; - }.bind(this), function(err) { - console.log(err); - if (err.data && err.data.error) { - var message = angular.toJson(err.data.error); - if (err.data.error.reason) { - message = err.data.error.reason; - } - return { status: "error", message: message }; - } else { - return { status: "error", message: err.status }; - } - }); - }; - - this.getQueryHeader = function(searchType, timeFrom, timeTo) { - var header = {search_type: searchType, "ignore_unavailable": true}; - header.index = this.indexPattern.getIndexList(timeFrom, timeTo); - return angular.toJson(header); - }; - - this.query = function(options) { - var payload = ""; - var target; - var sentTargets = []; - - // add global adhoc filters to timeFilter - var adhocFilters = templateSrv.getAdhocFilters(this.name); - - for (var i = 0; i < options.targets.length; i++) { - target = options.targets[i]; - if (target.hide) {continue;} - - var queryString = templateSrv.replace(target.query || '*', options.scopedVars, 'lucene'); - var queryObj = this.queryBuilder.build(target, adhocFilters, queryString); - var esQuery = angular.toJson(queryObj); - - var searchType = (queryObj.size === 0 && this.esVersion < 5) ? 'count' : 'query_then_fetch'; - var header = this.getQueryHeader(searchType, options.range.from, options.range.to); - payload += header + '\n'; - - payload += esQuery + '\n'; - sentTargets.push(target); - } - - if (sentTargets.length === 0) { - return $q.when([]); - } - - payload = payload.replace(/\$timeFrom/g, options.range.from.valueOf()); - payload = payload.replace(/\$timeTo/g, options.range.to.valueOf()); - payload = templateSrv.replace(payload, options.scopedVars); - - return this._post('_msearch', payload).then(function(res) { - return new ElasticResponse(sentTargets, res).getTimeSeries(); - }); - }; - - this.getFields = function(query) { - return this._get('/_mapping').then(function(result) { - - var typeMap = { - 'float': 'number', - 'double': 'number', - 'integer': 'number', - 'long': 'number', - 'date': 'date', - 'string': 'string', - 'text': 'string', - 'scaled_float': 'number', - 'nested': 'nested' - }; - - function shouldAddField(obj, key, query) { - if (key[0] === '_') { - return false; - } - - if (!query.type) { - return true; - } - - // equal query type filter, or via typemap translation - return query.type === obj.type || query.type === typeMap[obj.type]; - } - - // Store subfield names: [system, process, cpu, total] -> system.process.cpu.total - var fieldNameParts = []; - var fields = {}; - - function getFieldsRecursively(obj) { - for (var key in obj) { - var subObj = obj[key]; - - // Check mapping field for nested fields - if (_.isObject(subObj.properties)) { - fieldNameParts.push(key); - getFieldsRecursively(subObj.properties); - } - - if (_.isObject(subObj.fields)) { - fieldNameParts.push(key); - getFieldsRecursively(subObj.fields); - } - - if (_.isString(subObj.type)) { - var fieldName = fieldNameParts.concat(key).join('.'); - - // Hide meta-fields and check field type - if (shouldAddField(subObj, key, query)) { - fields[fieldName] = { - text: fieldName, - type: subObj.type - }; - } - } - } - fieldNameParts.pop(); - } - - for (var indexName in result) { - var index = result[indexName]; - if (index && index.mappings) { - var mappings = index.mappings; - for (var typeName in mappings) { - var properties = mappings[typeName].properties; - getFieldsRecursively(properties); - } - } - } - - // transform to array - return _.map(fields, function(value) { - return value; - }); - }); - }; - - this.getTerms = function(queryDef) { - var range = timeSrv.timeRange(); - var searchType = this.esVersion >= 5 ? 'query_then_fetch' : 'count' ; - var header = this.getQueryHeader(searchType, range.from, range.to); - var esQuery = angular.toJson(this.queryBuilder.getTermsQuery(queryDef)); - - esQuery = esQuery.replace(/\$timeFrom/g, range.from.valueOf()); - esQuery = esQuery.replace(/\$timeTo/g, range.to.valueOf()); - esQuery = header + '\n' + esQuery + '\n'; - - return this._post('_msearch?search_type=' + searchType, esQuery).then(function(res) { - if (!res.responses[0].aggregations) { - return []; - } - - var buckets = res.responses[0].aggregations["1"].buckets; - return _.map(buckets, function(bucket) { - return { - text: bucket.key_as_string || bucket.key, - value: bucket.key - }; - }); - }); - }; - - this.metricFindQuery = function(query) { - query = angular.fromJson(query); - if (!query) { - return $q.when([]); - } - - if (query.find === 'fields') { - query.field = templateSrv.replace(query.field, {}, 'lucene'); - return this.getFields(query); - } - - if (query.find === 'terms') { - query.query = templateSrv.replace(query.query || '*', {}, 'lucene'); - return this.getTerms(query); - } - }; - - this.getTagKeys = function() { - return this.getFields({}); - }; - - this.getTagValues = function(options) { - return this.getTerms({field: options.key, query: '*'}); - }; - } - - return { - ElasticDatasource: ElasticDatasource - }; -}); diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts new file mode 100644 index 00000000000..814b6c72da6 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -0,0 +1,389 @@ +/// + +import angular from 'angular'; +import _ from 'lodash'; +import moment from 'moment'; +import {ElasticQueryBuilder} from './query_builder'; +import {IndexPattern} from './index_pattern'; +import {ElasticResponse} from './elastic_response'; + +export class ElasticDatasource { + basicAuth: string; + withCredentials: boolean; + url: string; + name: string; + index: string; + timeField: string; + esVersion: number; + interval: string; + queryBuilder: ElasticQueryBuilder; + indexPattern: IndexPattern; + + /** @ngInject */ + constructor(instanceSettings, private $q, private backendSrv, private templateSrv, private timeSrv) { + this.basicAuth = instanceSettings.basicAuth; + this.withCredentials = instanceSettings.withCredentials; + this.url = instanceSettings.url; + this.name = instanceSettings.name; + this.index = instanceSettings.index; + this.timeField = instanceSettings.jsonData.timeField; + this.esVersion = instanceSettings.jsonData.esVersion; + this.indexPattern = new IndexPattern(instanceSettings.index, instanceSettings.jsonData.interval); + this.interval = instanceSettings.jsonData.timeInterval; + this.queryBuilder = new ElasticQueryBuilder({ + timeField: this.timeField, + esVersion: this.esVersion, + }); + } + + private request(method, url, data?) { + var options: any = { + url: this.url + "/" + url, + method: method, + data: data + }; + + if (this.basicAuth || this.withCredentials) { + options.withCredentials = true; + } + if (this.basicAuth) { + options.headers = { + "Authorization": this.basicAuth + }; + } + + return this.backendSrv.datasourceRequest(options); + } + + private get(url) { + var range = this.timeSrv.timeRange(); + var index_list = this.indexPattern.getIndexList(range.from.valueOf(), range.to.valueOf()); + if (_.isArray(index_list) && index_list.length) { + return this.request('GET', index_list[0] + url).then(function(results) { + results.data.$$config = results.config; + return results.data; + }); + } else { + return this.request('GET', this.indexPattern.getIndexForToday() + url).then(function(results) { + results.data.$$config = results.config; + return results.data; + }); + } + } + + private post(url, data) { + return this.request('POST', url, data).then(function(results) { + results.data.$$config = results.config; + return results.data; + }).catch(err => { + if (err.data && err.data.error) { + throw {message: 'Elasticsearch error: ' + err.data.error.reason, error: err.data.error}; + } + + throw err; + }); + } + + annotationQuery(options) { + var annotation = options.annotation; + var timeField = annotation.timeField || '@timestamp'; + var queryString = annotation.query || '*'; + var tagsField = annotation.tagsField || 'tags'; + var textField = annotation.textField || null; + + var range = {}; + range[timeField]= { + from: options.range.from.valueOf(), + to: options.range.to.valueOf(), + format: "epoch_millis", + }; + + var queryInterpolated = this.templateSrv.replace(queryString, {}, 'lucene'); + var query = { + "bool": { + "filter": [ + { "range": range }, + { + "query_string": { + "query": queryInterpolated + } + } + ] + } + }; + + var data = { + "query" : query, + "size": 10000 + }; + + // fields field not supported on ES 5.x + if (this.esVersion < 5) { + data["fields"] = [timeField, "_source"]; + } + + var header: any = {search_type: "query_then_fetch", "ignore_unavailable": true}; + + // old elastic annotations had index specified on them + if (annotation.index) { + header.index = annotation.index; + } else { + header.index = this.indexPattern.getIndexList(options.range.from, options.range.to); + } + + var payload = angular.toJson(header) + '\n' + angular.toJson(data) + '\n'; + + return this.post('_msearch', payload).then(res => { + var list = []; + var hits = res.responses[0].hits.hits; + + var getFieldFromSource = function(source, fieldName) { + if (!fieldName) { return; } + + var fieldNames = fieldName.split('.'); + var fieldValue = source; + + for (var i = 0; i < fieldNames.length; i++) { + fieldValue = fieldValue[fieldNames[i]]; + if (!fieldValue) { + console.log('could not find field in annotation: ', fieldName); + return ''; + } + } + + return fieldValue; + }; + + for (var i = 0; i < hits.length; i++) { + var source = hits[i]._source; + var time = source[timeField]; + if (typeof hits[i].fields !== 'undefined') { + var fields = hits[i].fields; + if (_.isString(fields[timeField]) || _.isNumber(fields[timeField])) { + time = fields[timeField]; + } + } + + var event = { + annotation: annotation, + time: moment.utc(time).valueOf(), + text: getFieldFromSource(source, textField), + tags: getFieldFromSource(source, tagsField), + }; + + // legacy support for title tield + if (annotation.titleField) { + const title = getFieldFromSource(source, annotation.titleField); + if (title) { + event.text = title + '\n' + event.text; + } + } + + if (typeof event.tags === 'string') { + event.tags = event.tags.split(','); + } + + list.push(event); + } + return list; + }); + } + + testDatasource() { + this.timeSrv.setTime({ from: 'now-1m', to: 'now' }, true); + // validate that the index exist and has date field + return this.getFields({type: 'date'}).then(function(dateFields) { + var timeField = _.find(dateFields, {text: this.timeField}); + if (!timeField) { + return { status: "error", message: "No date field named " + this.timeField + ' found' }; + } + return { status: "success", message: "Index OK. Time field name OK." }; + }.bind(this), function(err) { + console.log(err); + if (err.data && err.data.error) { + var message = angular.toJson(err.data.error); + if (err.data.error.reason) { + message = err.data.error.reason; + } + return { status: "error", message: message }; + } else { + return { status: "error", message: err.status }; + } + }); + } + + getQueryHeader(searchType, timeFrom, timeTo) { + return angular.toJson({ + search_type: searchType, + "ignore_unavailable": true, + index: this.indexPattern.getIndexList(timeFrom, timeTo), + }); + } + + query(options) { + var payload = ""; + var target; + var sentTargets = []; + + // add global adhoc filters to timeFilter + var adhocFilters = this.templateSrv.getAdhocFilters(this.name); + + for (var i = 0; i < options.targets.length; i++) { + target = options.targets[i]; + if (target.hide) {continue;} + + var queryString = this.templateSrv.replace(target.query || '*', options.scopedVars, 'lucene'); + var queryObj = this.queryBuilder.build(target, adhocFilters, queryString); + var esQuery = angular.toJson(queryObj); + + var searchType = (queryObj.size === 0 && this.esVersion < 5) ? 'count' : 'query_then_fetch'; + var header = this.getQueryHeader(searchType, options.range.from, options.range.to); + payload += header + '\n'; + + payload += esQuery + '\n'; + sentTargets.push(target); + } + + if (sentTargets.length === 0) { + return this.$q.when([]); + } + + payload = payload.replace(/\$timeFrom/g, options.range.from.valueOf()); + payload = payload.replace(/\$timeTo/g, options.range.to.valueOf()); + payload = this.templateSrv.replace(payload, options.scopedVars); + + return this.post('_msearch', payload).then(function(res) { + return new ElasticResponse(sentTargets, res).getTimeSeries(); + }); + } + + getFields(query) { + return this.get('/_mapping').then(function(result) { + + var typeMap = { + 'float': 'number', + 'double': 'number', + 'integer': 'number', + 'long': 'number', + 'date': 'date', + 'string': 'string', + 'text': 'string', + 'scaled_float': 'number', + 'nested': 'nested' + }; + + function shouldAddField(obj, key, query) { + if (key[0] === '_') { + return false; + } + + if (!query.type) { + return true; + } + + // equal query type filter, or via typemap translation + return query.type === obj.type || query.type === typeMap[obj.type]; + } + + // Store subfield names: [system, process, cpu, total] -> system.process.cpu.total + var fieldNameParts = []; + var fields = {}; + + function getFieldsRecursively(obj) { + for (var key in obj) { + var subObj = obj[key]; + + // Check mapping field for nested fields + if (_.isObject(subObj.properties)) { + fieldNameParts.push(key); + getFieldsRecursively(subObj.properties); + } + + if (_.isObject(subObj.fields)) { + fieldNameParts.push(key); + getFieldsRecursively(subObj.fields); + } + + if (_.isString(subObj.type)) { + var fieldName = fieldNameParts.concat(key).join('.'); + + // Hide meta-fields and check field type + if (shouldAddField(subObj, key, query)) { + fields[fieldName] = { + text: fieldName, + type: subObj.type + }; + } + } + } + fieldNameParts.pop(); + } + + for (var indexName in result) { + var index = result[indexName]; + if (index && index.mappings) { + var mappings = index.mappings; + for (var typeName in mappings) { + var properties = mappings[typeName].properties; + getFieldsRecursively(properties); + } + } + } + + // transform to array + return _.map(fields, function(value) { + return value; + }); + }); + } + + getTerms(queryDef) { + var range = this.timeSrv.timeRange(); + var searchType = this.esVersion >= 5 ? 'query_then_fetch' : 'count' ; + var header = this.getQueryHeader(searchType, range.from, range.to); + var esQuery = angular.toJson(this.queryBuilder.getTermsQuery(queryDef)); + + esQuery = esQuery.replace(/\$timeFrom/g, range.from.valueOf()); + esQuery = esQuery.replace(/\$timeTo/g, range.to.valueOf()); + esQuery = header + '\n' + esQuery + '\n'; + + return this.post('_msearch?search_type=' + searchType, esQuery).then(function(res) { + if (!res.responses[0].aggregations) { + return []; + } + + var buckets = res.responses[0].aggregations["1"].buckets; + return _.map(buckets, function(bucket) { + return { + text: bucket.key_as_string || bucket.key, + value: bucket.key + }; + }); + }); + } + + metricFindQuery(query) { + query = angular.fromJson(query); + if (!query) { + return this.$q.when([]); + } + + if (query.find === 'fields') { + query.field = this.templateSrv.replace(query.field, {}, 'lucene'); + return this.getFields(query); + } + + if (query.find === 'terms') { + query.query = this.templateSrv.replace(query.query || '*', {}, 'lucene'); + return this.getTerms(query); + } + } + + getTagKeys() { + return this.getFields({}); + } + + getTagValues(options) { + return this.getTerms({field: options.key, query: '*'}); + } +} diff --git a/public/app/plugins/datasource/elasticsearch/elastic_response.ts b/public/app/plugins/datasource/elasticsearch/elastic_response.ts index 47fa14a99a6..84aa397cb1f 100644 --- a/public/app/plugins/datasource/elasticsearch/elastic_response.ts +++ b/public/app/plugins/datasource/elasticsearch/elastic_response.ts @@ -1,130 +1,55 @@ /// import _ from 'lodash'; -import queryDef from "./query_def"; +import * as queryDef from "./query_def"; import TableModel from 'app/core/table_model'; -export function ElasticResponse(targets, response) { - this.targets = targets; - this.response = response; -} +export class ElasticResponse { -ElasticResponse.prototype.processMetrics = function(esAgg, target, seriesList, props) { - var metric, y, i, newSeries, bucket, value; + constructor(private targets, private response) { + this.targets = targets; + this.response = response; + } - for (y = 0; y < target.metrics.length; y++) { - metric = target.metrics[y]; - if (metric.hide) { - continue; - } + processMetrics(esAgg, target, seriesList, props) { + var metric, y, i, newSeries, bucket, value; - switch (metric.type) { - case 'count': { - newSeries = { datapoints: [], metric: 'count', props: props}; - for (i = 0; i < esAgg.buckets.length; i++) { - bucket = esAgg.buckets[i]; - value = bucket.doc_count; - newSeries.datapoints.push([value, bucket.key]); - } - seriesList.push(newSeries); - break; + for (y = 0; y < target.metrics.length; y++) { + metric = target.metrics[y]; + if (metric.hide) { + continue; } - case 'percentiles': { - if (esAgg.buckets.length === 0) { + + switch (metric.type) { + case 'count': { + newSeries = { datapoints: [], metric: 'count', props: props}; + for (i = 0; i < esAgg.buckets.length; i++) { + bucket = esAgg.buckets[i]; + value = bucket.doc_count; + newSeries.datapoints.push([value, bucket.key]); + } + seriesList.push(newSeries); break; } - - var firstBucket = esAgg.buckets[0]; - var percentiles = firstBucket[metric.id].values; - - for (var percentileName in percentiles) { - newSeries = {datapoints: [], metric: 'p' + percentileName, props: props, field: metric.field}; - - for (i = 0; i < esAgg.buckets.length; i++) { - bucket = esAgg.buckets[i]; - var values = bucket[metric.id].values; - newSeries.datapoints.push([values[percentileName], bucket.key]); - } - seriesList.push(newSeries); - } - - break; - } - case 'extended_stats': { - for (var statName in metric.meta) { - if (!metric.meta[statName]) { - continue; + case 'percentiles': { + if (esAgg.buckets.length === 0) { + break; } - newSeries = {datapoints: [], metric: statName, props: props, field: metric.field}; + var firstBucket = esAgg.buckets[0]; + var percentiles = firstBucket[metric.id].values; - for (i = 0; i < esAgg.buckets.length; i++) { - bucket = esAgg.buckets[i]; - var stats = bucket[metric.id]; + for (var percentileName in percentiles) { + newSeries = {datapoints: [], metric: 'p' + percentileName, props: props, field: metric.field}; - // add stats that are in nested obj to top level obj - stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper; - stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower; - - newSeries.datapoints.push([stats[statName], bucket.key]); - } - - seriesList.push(newSeries); - } - - break; - } - default: { - newSeries = { datapoints: [], metric: metric.type, field: metric.field, props: props}; - for (i = 0; i < esAgg.buckets.length; i++) { - bucket = esAgg.buckets[i]; - - value = bucket[metric.id]; - if (value !== undefined) { - if (value.normalized_value) { - newSeries.datapoints.push([value.normalized_value, bucket.key]); - } else { - newSeries.datapoints.push([value.value, bucket.key]); + for (i = 0; i < esAgg.buckets.length; i++) { + bucket = esAgg.buckets[i]; + var values = bucket[metric.id].values; + newSeries.datapoints.push([values[percentileName], bucket.key]); } + seriesList.push(newSeries); } - } - seriesList.push(newSeries); - break; - } - } - } -}; - -ElasticResponse.prototype.processAggregationDocs = function(esAgg, aggDef, target, table, props) { - // add columns - if (table.columns.length === 0) { - for (let propKey of _.keys(props)) { - table.addColumn({text: propKey, filterable: true}); - } - table.addColumn({text: aggDef.field, filterable: true}); - } - - // helper func to add values to value array - let addMetricValue = (values, metricName, value) => { - table.addColumn({text: metricName}); - values.push(value); - }; - - for (let bucket of esAgg.buckets) { - let values = []; - - for (let propValues of _.values(props)) { - values.push(propValues); - } - - // add bucket key (value) - values.push(bucket.key); - - for (let metric of target.metrics) { - switch (metric.type) { - case "count": { - addMetricValue(values, this._getMetricName(metric.type), bucket.doc_count); break; } case 'extended_stats': { @@ -133,228 +58,304 @@ ElasticResponse.prototype.processAggregationDocs = function(esAgg, aggDef, targe continue; } - var stats = bucket[metric.id]; - // add stats that are in nested obj to top level obj - stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper; - stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower; + newSeries = {datapoints: [], metric: statName, props: props, field: metric.field}; - addMetricValue(values, this._getMetricName(statName), stats[statName]); + for (i = 0; i < esAgg.buckets.length; i++) { + bucket = esAgg.buckets[i]; + var stats = bucket[metric.id]; + + // add stats that are in nested obj to top level obj + stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper; + stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower; + + newSeries.datapoints.push([stats[statName], bucket.key]); + } + + seriesList.push(newSeries); } + break; } - default: { - let metricName = this._getMetricName(metric.type); - let otherMetrics = _.filter(target.metrics, {type: metric.type}); + default: { + newSeries = { datapoints: [], metric: metric.type, field: metric.field, props: props}; + for (i = 0; i < esAgg.buckets.length; i++) { + bucket = esAgg.buckets[i]; + + value = bucket[metric.id]; + if (value !== undefined) { + if (value.normalized_value) { + newSeries.datapoints.push([value.normalized_value, bucket.key]); + } else { + newSeries.datapoints.push([value.value, bucket.key]); + } + } - // if more of the same metric type include field field name in property - if (otherMetrics.length > 1) { - metricName += ' ' + metric.field; } - - addMetricValue(values, metricName, bucket[metric.id].value); + seriesList.push(newSeries); break; } } } - - table.rows.push(values); } -}; -// This is quite complex -// neeed to recurise down the nested buckets to build series -ElasticResponse.prototype.processBuckets = function(aggs, target, seriesList, table, props, depth) { - var bucket, aggDef, esAgg, aggId; - var maxDepth = target.bucketAggs.length-1; - - for (aggId in aggs) { - aggDef = _.find(target.bucketAggs, {id: aggId}); - esAgg = aggs[aggId]; - - if (!aggDef) { - continue; - } - - if (depth === maxDepth) { - if (aggDef.type === 'date_histogram') { - this.processMetrics(esAgg, target, seriesList, props); - } else { - this.processAggregationDocs(esAgg, aggDef, target, table, props); - } - } else { - for (var nameIndex in esAgg.buckets) { - bucket = esAgg.buckets[nameIndex]; - props = _.clone(props); - if (bucket.key !== void 0) { - props[aggDef.field] = bucket.key; - } else { - props["filter"] = nameIndex; - } - if (bucket.key_as_string) { - props[aggDef.field] = bucket.key_as_string; - } - this.processBuckets(bucket, target, seriesList, table, props, depth+1); + processAggregationDocs(esAgg, aggDef, target, table, props) { + // add columns + if (table.columns.length === 0) { + for (let propKey of _.keys(props)) { + table.addColumn({text: propKey, filterable: true}); } + table.addColumn({text: aggDef.field, filterable: true}); } - } -}; -ElasticResponse.prototype._getMetricName = function(metric) { - var metricDef = _.find(queryDef.metricAggTypes, {value: metric}); - if (!metricDef) { - metricDef = _.find(queryDef.extendedStats, {value: metric}); - } - - return metricDef ? metricDef.text : metric; -}; - -ElasticResponse.prototype._getSeriesName = function(series, target, metricTypeCount) { - var metricName = this._getMetricName(series.metric); - - if (target.alias) { - var regex = /\{\{([\s\S]+?)\}\}/g; - - return target.alias.replace(regex, function(match, g1, g2) { - var group = g1 || g2; - - if (group.indexOf('term ') === 0) { return series.props[group.substring(5)]; } - if (series.props[group] !== void 0) { return series.props[group]; } - if (group === 'metric') { return metricName; } - if (group === 'field') { return series.field; } - - return match; - }); - } - - if (series.field && queryDef.isPipelineAgg(series.metric)) { - var appliedAgg = _.find(target.metrics, { id: series.field }); - if (appliedAgg) { - metricName += ' ' + queryDef.describeMetric(appliedAgg); - } else { - metricName = 'Unset'; - } - } else if (series.field) { - metricName += ' ' + series.field; - } - - var propKeys = _.keys(series.props); - if (propKeys.length === 0) { - return metricName; - } - - var name = ''; - for (var propName in series.props) { - name += series.props[propName] + ' '; - } - - if (metricTypeCount === 1) { - return name.trim(); - } - - return name.trim() + ' ' + metricName; -}; - -ElasticResponse.prototype.nameSeries = function(seriesList, target) { - var metricTypeCount = _.uniq(_.map(seriesList, 'metric')).length; - var fieldNameCount = _.uniq(_.map(seriesList, 'field')).length; - - for (var i = 0; i < seriesList.length; i++) { - var series = seriesList[i]; - series.target = this._getSeriesName(series, target, metricTypeCount, fieldNameCount); - } -}; - -ElasticResponse.prototype.processHits = function(hits, seriesList) { - var series = {target: 'docs', type: 'docs', datapoints: [], total: hits.total, filterable: true}; - var propName, hit, doc, i; - - for (i = 0; i < hits.hits.length; i++) { - hit = hits.hits[i]; - doc = { - _id: hit._id, - _type: hit._type, - _index: hit._index + // helper func to add values to value array + let addMetricValue = (values, metricName, value) => { + table.addColumn({text: metricName}); + values.push(value); }; - if (hit._source) { - for (propName in hit._source) { - doc[propName] = hit._source[propName]; - } - } + for (let bucket of esAgg.buckets) { + let values = []; - for (propName in hit.fields) { - doc[propName] = hit.fields[propName]; - } - series.datapoints.push(doc); - } - - seriesList.push(series); -}; - -ElasticResponse.prototype.trimDatapoints = function(aggregations, target) { - var histogram = _.find(target.bucketAggs, { type: 'date_histogram'}); - - var shouldDropFirstAndLast = histogram && histogram.settings && histogram.settings.trimEdges; - if (shouldDropFirstAndLast) { - var trim = histogram.settings.trimEdges; - for (var prop in aggregations) { - var points = aggregations[prop]; - if (points.datapoints.length > trim * 2) { - points.datapoints = points.datapoints.slice(trim, points.datapoints.length - trim); - } - } - } -}; - -ElasticResponse.prototype.getErrorFromElasticResponse = function(response, err) { - var result: any = {}; - result.data = JSON.stringify(err, null, 4); - if (err.root_cause && err.root_cause.length > 0 && err.root_cause[0].reason) { - result.message = err.root_cause[0].reason; - } else { - result.message = err.reason || 'Unkown elatic error response'; - } - - if (response.$$config) { - result.config = response.$$config; - } - - return result; -}; - -ElasticResponse.prototype.getTimeSeries = function() { - var seriesList = []; - - for (var i = 0; i < this.response.responses.length; i++) { - var response = this.response.responses[i]; - if (response.error) { - throw this.getErrorFromElasticResponse(this.response, response.error); - } - - if (response.hits && response.hits.hits.length > 0) { - this.processHits(response.hits, seriesList); - } - - if (response.aggregations) { - var aggregations = response.aggregations; - var target = this.targets[i]; - var tmpSeriesList = []; - var table = new TableModel(); - - this.processBuckets(aggregations, target, tmpSeriesList, table, {}, 0); - this.trimDatapoints(tmpSeriesList, target); - this.nameSeries(tmpSeriesList, target); - - for (var y = 0; y < tmpSeriesList.length; y++) { - seriesList.push(tmpSeriesList[y]); + for (let propValues of _.values(props)) { + values.push(propValues); } - if (table.rows.length > 0) { - seriesList.push(table); + // add bucket key (value) + values.push(bucket.key); + + for (let metric of target.metrics) { + switch (metric.type) { + case "count": { + addMetricValue(values, this.getMetricName(metric.type), bucket.doc_count); + break; + } + case 'extended_stats': { + for (var statName in metric.meta) { + if (!metric.meta[statName]) { + continue; + } + + var stats = bucket[metric.id]; + // add stats that are in nested obj to top level obj + stats.std_deviation_bounds_upper = stats.std_deviation_bounds.upper; + stats.std_deviation_bounds_lower = stats.std_deviation_bounds.lower; + + addMetricValue(values, this.getMetricName(statName), stats[statName]); + } + break; + } + default: { + let metricName = this.getMetricName(metric.type); + let otherMetrics = _.filter(target.metrics, {type: metric.type}); + + // if more of the same metric type include field field name in property + if (otherMetrics.length > 1) { + metricName += ' ' + metric.field; + } + + addMetricValue(values, metricName, bucket[metric.id].value); + break; + } + } + } + + table.rows.push(values); + } + } + + // This is quite complex + // neeed to recurise down the nested buckets to build series + processBuckets(aggs, target, seriesList, table, props, depth) { + var bucket, aggDef, esAgg, aggId; + var maxDepth = target.bucketAggs.length-1; + + for (aggId in aggs) { + aggDef = _.find(target.bucketAggs, {id: aggId}); + esAgg = aggs[aggId]; + + if (!aggDef) { + continue; + } + + if (depth === maxDepth) { + if (aggDef.type === 'date_histogram') { + this.processMetrics(esAgg, target, seriesList, props); + } else { + this.processAggregationDocs(esAgg, aggDef, target, table, props); + } + } else { + for (var nameIndex in esAgg.buckets) { + bucket = esAgg.buckets[nameIndex]; + props = _.clone(props); + if (bucket.key !== void 0) { + props[aggDef.field] = bucket.key; + } else { + props["filter"] = nameIndex; + } + if (bucket.key_as_string) { + props[aggDef.field] = bucket.key_as_string; + } + this.processBuckets(bucket, target, seriesList, table, props, depth+1); + } } } } - return { data: seriesList }; -}; + private getMetricName(metric) { + var metricDef = _.find(queryDef.metricAggTypes, {value: metric}); + if (!metricDef) { + metricDef = _.find(queryDef.extendedStats, {value: metric}); + } + return metricDef ? metricDef.text : metric; + } + + private getSeriesName(series, target, metricTypeCount) { + var metricName = this.getMetricName(series.metric); + + if (target.alias) { + var regex = /\{\{([\s\S]+?)\}\}/g; + + return target.alias.replace(regex, function(match, g1, g2) { + var group = g1 || g2; + + if (group.indexOf('term ') === 0) { return series.props[group.substring(5)]; } + if (series.props[group] !== void 0) { return series.props[group]; } + if (group === 'metric') { return metricName; } + if (group === 'field') { return series.field; } + + return match; + }); + } + + if (series.field && queryDef.isPipelineAgg(series.metric)) { + var appliedAgg = _.find(target.metrics, { id: series.field }); + if (appliedAgg) { + metricName += ' ' + queryDef.describeMetric(appliedAgg); + } else { + metricName = 'Unset'; + } + } else if (series.field) { + metricName += ' ' + series.field; + } + + var propKeys = _.keys(series.props); + if (propKeys.length === 0) { + return metricName; + } + + var name = ''; + for (var propName in series.props) { + name += series.props[propName] + ' '; + } + + if (metricTypeCount === 1) { + return name.trim(); + } + + return name.trim() + ' ' + metricName; + } + + nameSeries(seriesList, target) { + var metricTypeCount = _.uniq(_.map(seriesList, 'metric')).length; + + for (var i = 0; i < seriesList.length; i++) { + var series = seriesList[i]; + series.target = this.getSeriesName(series, target, metricTypeCount); + } + } + + processHits(hits, seriesList) { + var series = {target: 'docs', type: 'docs', datapoints: [], total: hits.total, filterable: true}; + var propName, hit, doc, i; + + for (i = 0; i < hits.hits.length; i++) { + hit = hits.hits[i]; + doc = { + _id: hit._id, + _type: hit._type, + _index: hit._index + }; + + if (hit._source) { + for (propName in hit._source) { + doc[propName] = hit._source[propName]; + } + } + + for (propName in hit.fields) { + doc[propName] = hit.fields[propName]; + } + series.datapoints.push(doc); + } + + seriesList.push(series); + } + + trimDatapoints(aggregations, target) { + var histogram = _.find(target.bucketAggs, { type: 'date_histogram'}); + + var shouldDropFirstAndLast = histogram && histogram.settings && histogram.settings.trimEdges; + if (shouldDropFirstAndLast) { + var trim = histogram.settings.trimEdges; + for (var prop in aggregations) { + var points = aggregations[prop]; + if (points.datapoints.length > trim * 2) { + points.datapoints = points.datapoints.slice(trim, points.datapoints.length - trim); + } + } + } + } + + getErrorFromElasticResponse(response, err) { + var result: any = {}; + result.data = JSON.stringify(err, null, 4); + if (err.root_cause && err.root_cause.length > 0 && err.root_cause[0].reason) { + result.message = err.root_cause[0].reason; + } else { + result.message = err.reason || 'Unkown elatic error response'; + } + + if (response.$$config) { + result.config = response.$$config; + } + + return result; + } + + getTimeSeries() { + var seriesList = []; + + for (var i = 0; i < this.response.responses.length; i++) { + var response = this.response.responses[i]; + if (response.error) { + throw this.getErrorFromElasticResponse(this.response, response.error); + } + + if (response.hits && response.hits.hits.length > 0) { + this.processHits(response.hits, seriesList); + } + + if (response.aggregations) { + var aggregations = response.aggregations; + var target = this.targets[i]; + var tmpSeriesList = []; + var table = new TableModel(); + + this.processBuckets(aggregations, target, tmpSeriesList, table, {}, 0); + this.trimDatapoints(tmpSeriesList, target); + this.nameSeries(tmpSeriesList, target); + + for (var y = 0; y < tmpSeriesList.length; y++) { + seriesList.push(tmpSeriesList[y]); + } + + if (table.rows.length > 0) { + seriesList.push(table); + } + } + } + + return { data: seriesList }; + } +} diff --git a/public/app/plugins/datasource/elasticsearch/index_pattern.d.ts b/public/app/plugins/datasource/elasticsearch/index_pattern.d.ts deleted file mode 100644 index c3318b8e133..00000000000 --- a/public/app/plugins/datasource/elasticsearch/index_pattern.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare var test: any; -export default test; diff --git a/public/app/plugins/datasource/elasticsearch/index_pattern.js b/public/app/plugins/datasource/elasticsearch/index_pattern.js deleted file mode 100644 index 6b26e41e1c5..00000000000 --- a/public/app/plugins/datasource/elasticsearch/index_pattern.js +++ /dev/null @@ -1,48 +0,0 @@ -define([ - 'lodash', - 'moment', -], -function (_, moment) { - 'use strict'; - - function IndexPattern(pattern, interval) { - this.pattern = pattern; - this.interval = interval; - } - - IndexPattern.intervalMap = { - "Hourly": { startOf: 'hour', amount: 'hours'}, - "Daily": { startOf: 'day', amount: 'days'}, - "Weekly": { startOf: 'isoWeek', amount: 'weeks'}, - "Monthly": { startOf: 'month', amount: 'months'}, - "Yearly": { startOf: 'year', amount: 'years'}, - }; - - IndexPattern.prototype.getIndexForToday = function() { - if (this.interval) { - return moment.utc().format(this.pattern); - } else { - return this.pattern; - } - }; - - IndexPattern.prototype.getIndexList = function(from, to) { - if (!this.interval) { - return this.pattern; - } - - var intervalInfo = IndexPattern.intervalMap[this.interval]; - var start = moment(from).utc().startOf(intervalInfo.startOf); - var end = moment(to).utc().startOf(intervalInfo.startOf).valueOf(); - var indexList = []; - - while (start <= end) { - indexList.push(start.format(this.pattern)); - start.add(1, intervalInfo.amount); - } - - return indexList; - }; - - return IndexPattern; -}); diff --git a/public/app/plugins/datasource/elasticsearch/index_pattern.ts b/public/app/plugins/datasource/elasticsearch/index_pattern.ts new file mode 100644 index 00000000000..22f121d02de --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/index_pattern.ts @@ -0,0 +1,41 @@ +import moment from 'moment'; + +const intervalMap = { + "Hourly": { startOf: 'hour', amount: 'hours'}, + "Daily": { startOf: 'day', amount: 'days'}, + "Weekly": { startOf: 'isoWeek', amount: 'weeks'}, + "Monthly": { startOf: 'month', amount: 'months'}, + "Yearly": { startOf: 'year', amount: 'years'}, +}; + +export class IndexPattern { + + constructor(private pattern, private interval: string | null) { } + + getIndexForToday() { + if (this.interval) { + return moment.utc().format(this.pattern); + } else { + return this.pattern; + } + } + + getIndexList(from, to) { + if (!this.interval) { + return this.pattern; + } + + var intervalInfo = intervalMap[this.interval]; + var start = moment(from).utc().startOf(intervalInfo.startOf); + var endEpoch = moment(to).utc().startOf(intervalInfo.startOf).valueOf(); + var indexList = []; + + while (start.valueOf() <= endEpoch) { + indexList.push(start.format(this.pattern)); + start.add(1, intervalInfo.amount); + } + + return indexList; + } +} + diff --git a/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html index ad68312b727..d4e1e7d1b1c 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html +++ b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html @@ -15,24 +15,20 @@
      Field mappings
      - Time - + Time +
      -
      - Title + Text + +
      +
      + Tags + +
      +
      + Title (depricated)
      -
      -
      - Tags - -
      - -
      - Text - -
      -
      diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.d.ts b/public/app/plugins/datasource/elasticsearch/query_builder.d.ts deleted file mode 100644 index c3318b8e133..00000000000 --- a/public/app/plugins/datasource/elasticsearch/query_builder.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare var test: any; -export default test; diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.js b/public/app/plugins/datasource/elasticsearch/query_builder.ts similarity index 88% rename from public/app/plugins/datasource/elasticsearch/query_builder.js rename to public/app/plugins/datasource/elasticsearch/query_builder.ts index 274a0e8f72f..754c541b2a8 100644 --- a/public/app/plugins/datasource/elasticsearch/query_builder.js +++ b/public/app/plugins/datasource/elasticsearch/query_builder.ts @@ -1,15 +1,15 @@ -define([ - './query_def', -], -function (queryDef) { - 'use strict'; +import * as queryDef from './query_def'; - function ElasticQueryBuilder(options) { +export class ElasticQueryBuilder { + timeField: string; + esVersion: number; + + constructor(options) { this.timeField = options.timeField; this.esVersion = options.esVersion; } - ElasticQueryBuilder.prototype.getRangeFilter = function() { + getRangeFilter() { var filter = {}; filter[this.timeField] = { gte: "$timeFrom", @@ -18,9 +18,9 @@ function (queryDef) { }; return filter; - }; + } - ElasticQueryBuilder.prototype.buildTermsAgg = function(aggDef, queryNode, target) { + buildTermsAgg(aggDef, queryNode, target) { var metricRef, metric, y; queryNode.terms = { "field": aggDef.field }; @@ -57,10 +57,10 @@ function (queryDef) { } return queryNode; - }; + } - ElasticQueryBuilder.prototype.getDateHistogramAgg = function(aggDef) { - var esAgg = {}; + getDateHistogramAgg(aggDef) { + var esAgg: any = {}; var settings = aggDef.settings || {}; esAgg.interval = settings.interval; esAgg.field = this.timeField; @@ -77,10 +77,10 @@ function (queryDef) { } return esAgg; - }; + } - ElasticQueryBuilder.prototype.getHistogramAgg = function(aggDef) { - var esAgg = {}; + getHistogramAgg(aggDef) { + var esAgg: any = {}; var settings = aggDef.settings || {}; esAgg.interval = settings.interval; esAgg.field = aggDef.field; @@ -90,9 +90,9 @@ function (queryDef) { esAgg.missing = settings.missing; } return esAgg; - }; + } - ElasticQueryBuilder.prototype.getFiltersAgg = function(aggDef) { + getFiltersAgg(aggDef) { var filterObj = {}; for (var i = 0; i < aggDef.settings.filters.length; i++) { var query = aggDef.settings.filters[i].query; @@ -107,9 +107,9 @@ function (queryDef) { } return filterObj; - }; + } - ElasticQueryBuilder.prototype.documentQuery = function(query, size) { + documentQuery(query, size) { query.size = size; query.sort = {}; query.sort[this.timeField] = {order: 'desc', unmapped_type: 'boolean'}; @@ -126,9 +126,9 @@ function (queryDef) { query.docvalue_fields = [this.timeField]; } return query; - }; + } - ElasticQueryBuilder.prototype.addAdhocFilters = function(query, adhocFilters) { + addAdhocFilters(query, adhocFilters) { if (!adhocFilters) { return; } @@ -142,7 +142,7 @@ function (queryDef) { queryCondition = {}; queryCondition[filter.key] = {query: filter.value}; - switch(filter.operator){ + switch (filter.operator){ case "=": if (!query.query.bool.must) { query.query.bool.must = []; } query.query.bool.must.push({match_phrase: queryCondition}); @@ -167,9 +167,9 @@ function (queryDef) { break; } } - }; + } - ElasticQueryBuilder.prototype.build = function(target, adhocFilters, queryString) { + build(target, adhocFilters?, queryString?) { // make sure query has defaults; target.metrics = target.metrics || [{ type: 'count', id: '1' }]; target.dsType = 'elasticsearch'; @@ -213,7 +213,7 @@ function (queryDef) { var aggDef = target.bucketAggs[i]; var esAgg = {}; - switch(aggDef.type) { + switch (aggDef.type) { case 'date_histogram': { esAgg["date_histogram"] = this.getDateHistogramAgg(aggDef); break; @@ -273,10 +273,10 @@ function (queryDef) { } return query; - }; + } - ElasticQueryBuilder.prototype.getTermsQuery = function(queryDef) { - var query = { + getTermsQuery(queryDef) { + var query: any = { "size": 0, "query": { "bool": { @@ -311,7 +311,5 @@ function (queryDef) { } }; return query; - }; - - return ElasticQueryBuilder; -}); + } +} diff --git a/public/app/plugins/datasource/elasticsearch/query_ctrl.ts b/public/app/plugins/datasource/elasticsearch/query_ctrl.ts index 025410c5205..b1195fe637d 100644 --- a/public/app/plugins/datasource/elasticsearch/query_ctrl.ts +++ b/public/app/plugins/datasource/elasticsearch/query_ctrl.ts @@ -5,7 +5,7 @@ import './metric_agg'; import angular from 'angular'; import _ from 'lodash'; -import queryDef from './query_def'; +import * as queryDef from './query_def'; import {QueryCtrl} from 'app/plugins/sdk'; export class ElasticQueryCtrl extends QueryCtrl { diff --git a/public/app/plugins/datasource/elasticsearch/query_def.d.ts b/public/app/plugins/datasource/elasticsearch/query_def.d.ts deleted file mode 100644 index c3318b8e133..00000000000 --- a/public/app/plugins/datasource/elasticsearch/query_def.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare var test: any; -export default test; diff --git a/public/app/plugins/datasource/elasticsearch/query_def.js b/public/app/plugins/datasource/elasticsearch/query_def.js deleted file mode 100644 index a55e2dfd9e5..00000000000 --- a/public/app/plugins/datasource/elasticsearch/query_def.js +++ /dev/null @@ -1,199 +0,0 @@ -define([ - 'lodash' -], -function (_) { - 'use strict'; - - return { - metricAggTypes: [ - {text: "Count", value: 'count', requiresField: false}, - {text: "Average", value: 'avg', requiresField: true, supportsInlineScript: true, supportsMissing: true}, - {text: "Sum", value: 'sum', requiresField: true, supportsInlineScript: true, supportsMissing: true}, - {text: "Max", value: 'max', requiresField: true, supportsInlineScript: true, supportsMissing: true}, - {text: "Min", value: 'min', requiresField: true, supportsInlineScript: true, supportsMissing: true}, - {text: "Extended Stats", value: 'extended_stats', requiresField: true, supportsMissing: true, supportsInlineScript: true}, - {text: "Percentiles", value: 'percentiles', requiresField: true, supportsMissing: true, supportsInlineScript: true}, - {text: "Unique Count", value: "cardinality", requiresField: true, supportsMissing: true}, - {text: "Moving Average", value: 'moving_avg', requiresField: false, isPipelineAgg: true, minVersion: 2}, - {text: "Derivative", value: 'derivative', requiresField: false, isPipelineAgg: true, minVersion: 2 }, - {text: "Raw Document", value: "raw_document", requiresField: false} - ], - - bucketAggTypes: [ - {text: "Terms", value: 'terms', requiresField: true}, - {text: "Filters", value: 'filters' }, - {text: "Geo Hash Grid", value: 'geohash_grid', requiresField: true}, - {text: "Date Histogram", value: 'date_histogram', requiresField: true}, - {text: "Histogram", value: 'histogram', requiresField: true}, - ], - - orderByOptions: [ - {text: "Doc Count", value: '_count' }, - {text: "Term value", value: '_term' }, - ], - - orderOptions: [ - {text: "Top", value: 'desc' }, - {text: "Bottom", value: 'asc' }, - ], - - sizeOptions: [ - {text: "No limit", value: '0' }, - {text: "1", value: '1' }, - {text: "2", value: '2' }, - {text: "3", value: '3' }, - {text: "5", value: '5' }, - {text: "10", value: '10' }, - {text: "15", value: '15' }, - {text: "20", value: '20' }, - ], - - extendedStats: [ - {text: 'Avg', value: 'avg'}, - {text: 'Min', value: 'min'}, - {text: 'Max', value: 'max'}, - {text: 'Sum', value: 'sum'}, - {text: 'Count', value: 'count'}, - {text: 'Std Dev', value: 'std_deviation'}, - {text: 'Std Dev Upper', value: 'std_deviation_bounds_upper'}, - {text: 'Std Dev Lower', value: 'std_deviation_bounds_lower'}, - ], - - intervalOptions: [ - {text: 'auto', value: 'auto'}, - {text: '10s', value: '10s'}, - {text: '1m', value: '1m'}, - {text: '5m', value: '5m'}, - {text: '10m', value: '10m'}, - {text: '20m', value: '20m'}, - {text: '1h', value: '1h'}, - {text: '1d', value: '1d'}, - ], - - movingAvgModelOptions: [ - {text: 'Simple', value: 'simple'}, - {text: 'Linear', value: 'linear'}, - {text: 'Exponentially Weighted', value: 'ewma'}, - {text: 'Holt Linear', value: 'holt'}, - {text: 'Holt Winters', value: 'holt_winters'}, - ], - - pipelineOptions: { - 'moving_avg' : [ - {text: 'window', default: 5}, - {text: 'model', default: 'simple'}, - {text: 'predict', default: undefined}, - {text: 'minimize', default: false}, - ], - 'derivative': [ - {text: 'unit', default: undefined}, - ] - }, - - movingAvgModelSettings: { - 'simple' : [], - 'linear' : [], - 'ewma' : [ - {text: "Alpha", value: "alpha", default: undefined}], - 'holt' : [ - {text: "Alpha", value: "alpha", default: undefined}, - {text: "Beta", value: "beta", default: undefined}, - ], - 'holt_winters' : [ - {text: "Alpha", value: "alpha", default: undefined}, - {text: "Beta", value: "beta", default: undefined}, - {text: "Gamma", value: "gamma", default: undefined}, - {text: "Period", value: "period", default: undefined}, - {text: "Pad", value: "pad", default: undefined, isCheckbox: true}, - ], - }, - - getMetricAggTypes: function(esVersion) { - return _.filter(this.metricAggTypes, function(f) { - if (f.minVersion) { - return f.minVersion <= esVersion; - } else { - return true; - } - }); - }, - - getPipelineOptions: function(metric) { - if (!this.isPipelineAgg(metric.type)) { - return []; - } - - return this.pipelineOptions[metric.type]; - }, - - isPipelineAgg: function(metricType) { - if (metricType) { - var po = this.pipelineOptions[metricType]; - return po !== null && po !== undefined; - } - - return false; - }, - - getPipelineAggOptions: function(targets) { - var self = this; - var result = []; - _.each(targets.metrics, function(metric) { - if (!self.isPipelineAgg(metric.type)) { - result.push({text: self.describeMetric(metric), value: metric.id }); - } - }); - - return result; - }, - - getMovingAvgSettings: function(model, filtered) { - var filteredResult = []; - if (filtered) { - _.each(this.movingAvgModelSettings[model], function(setting) { - if (!(setting.isCheckbox)) { - filteredResult.push(setting); - } - }); - return filteredResult; - } - return this.movingAvgModelSettings[model]; - }, - - getOrderByOptions: function(target) { - var self = this; - var metricRefs = []; - _.each(target.metrics, function(metric) { - if (metric.type !== 'count') { - metricRefs.push({text: self.describeMetric(metric), value: metric.id}); - } - }); - - return this.orderByOptions.concat(metricRefs); - }, - - describeOrder: function(order) { - var def = _.find(this.orderOptions, {value: order}); - return def.text; - }, - - describeMetric: function(metric) { - var def = _.find(this.metricAggTypes, {value: metric.type}); - return def.text + ' ' + metric.field; - }, - - describeOrderBy: function(orderBy, target) { - var def = _.find(this.orderByOptions, {value: orderBy}); - if (def) { - return def.text; - } - var metric = _.find(target.metrics, {id: orderBy}); - if (metric) { - return this.describeMetric(metric); - } else { - return "metric not found"; - } - }, - }; - -}); diff --git a/public/app/plugins/datasource/elasticsearch/query_def.ts b/public/app/plugins/datasource/elasticsearch/query_def.ts new file mode 100644 index 00000000000..3cb63340b84 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/query_def.ts @@ -0,0 +1,191 @@ +/// + +import _ from 'lodash'; + +export const metricAggTypes = [ + {text: "Count", value: 'count', requiresField: false}, + {text: "Average", value: 'avg', requiresField: true, supportsInlineScript: true, supportsMissing: true}, + {text: "Sum", value: 'sum', requiresField: true, supportsInlineScript: true, supportsMissing: true}, + {text: "Max", value: 'max', requiresField: true, supportsInlineScript: true, supportsMissing: true}, + {text: "Min", value: 'min', requiresField: true, supportsInlineScript: true, supportsMissing: true}, + {text: "Extended Stats", value: 'extended_stats', requiresField: true, supportsMissing: true, supportsInlineScript: true}, + {text: "Percentiles", value: 'percentiles', requiresField: true, supportsMissing: true, supportsInlineScript: true}, + {text: "Unique Count", value: "cardinality", requiresField: true, supportsMissing: true}, + {text: "Moving Average", value: 'moving_avg', requiresField: false, isPipelineAgg: true, minVersion: 2}, + {text: "Derivative", value: 'derivative', requiresField: false, isPipelineAgg: true, minVersion: 2 }, + {text: "Raw Document", value: "raw_document", requiresField: false} +]; + +export const bucketAggTypes = [ + {text: "Terms", value: 'terms', requiresField: true}, + {text: "Filters", value: 'filters' }, + {text: "Geo Hash Grid", value: 'geohash_grid', requiresField: true}, + {text: "Date Histogram", value: 'date_histogram', requiresField: true}, + {text: "Histogram", value: 'histogram', requiresField: true}, +]; + +export const orderByOptions = [ + {text: "Doc Count", value: '_count' }, + {text: "Term value", value: '_term' }, +]; + +export const orderOptions = [ + {text: "Top", value: 'desc' }, + {text: "Bottom", value: 'asc' }, +]; + +export const sizeOptions = [ + {text: "No limit", value: '0' }, + {text: "1", value: '1' }, + {text: "2", value: '2' }, + {text: "3", value: '3' }, + {text: "5", value: '5' }, + {text: "10", value: '10' }, + {text: "15", value: '15' }, + {text: "20", value: '20' }, +]; + +export const extendedStats = [ + {text: 'Avg', value: 'avg'}, + {text: 'Min', value: 'min'}, + {text: 'Max', value: 'max'}, + {text: 'Sum', value: 'sum'}, + {text: 'Count', value: 'count'}, + {text: 'Std Dev', value: 'std_deviation'}, + {text: 'Std Dev Upper', value: 'std_deviation_bounds_upper'}, + {text: 'Std Dev Lower', value: 'std_deviation_bounds_lower'}, +]; + +export const intervalOptions = [ + {text: 'auto', value: 'auto'}, + {text: '10s', value: '10s'}, + {text: '1m', value: '1m'}, + {text: '5m', value: '5m'}, + {text: '10m', value: '10m'}, + {text: '20m', value: '20m'}, + {text: '1h', value: '1h'}, + {text: '1d', value: '1d'}, +]; + +export const movingAvgModelOptions = [ + {text: 'Simple', value: 'simple'}, + {text: 'Linear', value: 'linear'}, + {text: 'Exponentially Weighted', value: 'ewma'}, + {text: 'Holt Linear', value: 'holt'}, + {text: 'Holt Winters', value: 'holt_winters'}, +]; + +export const pipelineOptions = { + 'moving_avg' : [ + {text: 'window', default: 5}, + {text: 'model', default: 'simple'}, + {text: 'predict', default: undefined}, + {text: 'minimize', default: false}, + ], + 'derivative': [ + {text: 'unit', default: undefined}, + ] +}; + +export const movingAvgModelSettings = { + 'simple' : [], + 'linear' : [], + 'ewma' : [ + {text: "Alpha", value: "alpha", default: undefined}], + 'holt' : [ + {text: "Alpha", value: "alpha", default: undefined}, + {text: "Beta", value: "beta", default: undefined}, + ], + 'holt_winters' : [ + {text: "Alpha", value: "alpha", default: undefined}, + {text: "Beta", value: "beta", default: undefined}, + {text: "Gamma", value: "gamma", default: undefined}, + {text: "Period", value: "period", default: undefined}, + {text: "Pad", value: "pad", default: undefined, isCheckbox: true}, + ], +}; + +export function getMetricAggTypes(esVersion) { + return _.filter(metricAggTypes, function(f) { + if (f.minVersion) { + return f.minVersion <= esVersion; + } else { + return true; + } + }); +} + +export function getPipelineOptions(metric) { + if (!isPipelineAgg(metric.type)) { + return []; + } + + return pipelineOptions[metric.type]; +} + +export function isPipelineAgg(metricType) { + if (metricType) { + var po = pipelineOptions[metricType]; + return po !== null && po !== undefined; + } + + return false; +} + +export function getPipelineAggOptions(targets) { + var result = []; + _.each(targets.metrics, function(metric) { + if (!isPipelineAgg(metric.type)) { + result.push({text: describeMetric(metric), value: metric.id }); + } + }); + + return result; +} + +export function getMovingAvgSettings(model, filtered) { + var filteredResult = []; + if (filtered) { + _.each(movingAvgModelSettings[model], function(setting) { + if (!(setting.isCheckbox)) { + filteredResult.push(setting); + } + }); + return filteredResult; + } + return movingAvgModelSettings[model]; +} + +export function getOrderByOptions(target) { + var metricRefs = []; + _.each(target.metrics, function(metric) { + if (metric.type !== 'count') { + metricRefs.push({text: describeMetric(metric), value: metric.id}); + } + }); + + return orderByOptions.concat(metricRefs); +} + +export function describeOrder(order) { + var def = _.find(orderOptions, {value: order}); + return def.text; +} + +export function describeMetric(metric) { + var def = _.find(metricAggTypes, {value: metric.type}); + return def.text + ' ' + metric.field; +} + +export function describeOrderBy(orderBy, target) { + var def = _.find(orderByOptions, {value: orderBy}); + if (def) { + return def.text; + } + var metric = _.find(target.metrics, {id: orderBy}); + if (metric) { + return describeMetric(metric); + } else { + return "metric not found"; + } +} diff --git a/public/app/plugins/datasource/elasticsearch/specs/index_pattern_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/index_pattern_specs.ts index 3fee81083b7..16ec678f1c0 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/index_pattern_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/index_pattern_specs.ts @@ -2,7 +2,7 @@ import {describe, it, expect} from 'test/lib/common'; import moment from 'moment'; -import IndexPattern from '../index_pattern'; +import {IndexPattern} from '../index_pattern'; describe('IndexPattern', function() { @@ -19,7 +19,7 @@ describe('IndexPattern', function() { describe('no interval', function() { it('should return correct index', function() { - var pattern = new IndexPattern('my-metrics'); + var pattern = new IndexPattern('my-metrics', null); var from = new Date(2015, 4, 30, 1, 2, 3); var to = new Date(2015, 5, 1, 12, 5 , 6); expect(pattern.getIndexList(from, to)).to.eql('my-metrics'); diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts index 3440309897b..29876d1ea42 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts @@ -1,6 +1,6 @@ import {describe, beforeEach, it, expect} from 'test/lib/common'; -import ElasticQueryBuilder from '../query_builder'; +import {ElasticQueryBuilder} from '../query_builder'; describe('ElasticQueryBuilder', function() { var builder; diff --git a/public/app/plugins/datasource/elasticsearch/specs/query_def_specs.ts b/public/app/plugins/datasource/elasticsearch/specs/query_def_specs.ts index e89c9774769..d46fc1aa933 100644 --- a/public/app/plugins/datasource/elasticsearch/specs/query_def_specs.ts +++ b/public/app/plugins/datasource/elasticsearch/specs/query_def_specs.ts @@ -1,7 +1,7 @@ import {describe, it, expect} from 'test/lib/common'; -import queryDef from '../query_def'; +import * as queryDef from '../query_def'; describe('ElasticQueryDef', function() { diff --git a/public/app/plugins/datasource/grafana/datasource.ts b/public/app/plugins/datasource/grafana/datasource.ts index 2960af6b062..5ca3c433476 100644 --- a/public/app/plugins/datasource/grafana/datasource.ts +++ b/public/app/plugins/datasource/grafana/datasource.ts @@ -1,5 +1,3 @@ -/// - import _ from 'lodash'; class GrafanaDatasource { @@ -8,42 +6,62 @@ class GrafanaDatasource { constructor(private backendSrv, private $q) {} query(options) { - return this.backendSrv.get('/api/tsdb/testdata/random-walk', { - from: options.range.from.valueOf(), - to: options.range.to.valueOf(), - intervalMs: options.intervalMs, - maxDataPoints: options.maxDataPoints, - }).then(res => { - var data = []; + return this.backendSrv + .get('/api/tsdb/testdata/random-walk', { + from: options.range.from.valueOf(), + to: options.range.to.valueOf(), + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + }) + .then(res => { + var data = []; - if (res.results) { - _.forEach(res.results, queryRes => { - for (let series of queryRes.series) { - data.push({ - target: series.name, - datapoints: series.points - }); - } - }); - } + if (res.results) { + _.forEach(res.results, queryRes => { + for (let series of queryRes.series) { + data.push({ + target: series.name, + datapoints: series.points, + }); + } + }); + } - return {data: data}; - }); + return {data: data}; + }); } metricFindQuery(options) { return this.$q.when({data: []}); } + annotationQuery(options) { - return this.backendSrv.get('/api/annotations', { + const params: any = { from: options.range.from.valueOf(), to: options.range.to.valueOf(), - limit: options.limit, - type: options.type, - }); - } + limit: options.annotation.limit, + tags: options.annotation.tags, + }; + if (options.annotation.type === 'dashboard') { + // if no dashboard id yet return + if (!options.dashboard.id) { + return this.$q.when([]); + } + // filter by dashboard id + params.dashboardId = options.dashboard.id; + // remove tags filter if any + delete params.tags; + } else { + // require at least one tag + if (!_.isArray(options.annotation.tags) || options.annotation.tags.length === 0) { + return this.$q.when([]); + } + } + + return this.backendSrv.get('/api/annotations', params); + } } export {GrafanaDatasource}; diff --git a/public/app/plugins/datasource/grafana/module.ts b/public/app/plugins/datasource/grafana/module.ts index d03914bda19..eb0b582bfb1 100644 --- a/public/app/plugins/datasource/grafana/module.ts +++ b/public/app/plugins/datasource/grafana/module.ts @@ -1,5 +1,3 @@ -/// - import {GrafanaDatasource} from './datasource'; import {QueryCtrl} from 'app/plugins/sdk'; @@ -10,19 +8,22 @@ class GrafanaQueryCtrl extends QueryCtrl { class GrafanaAnnotationsQueryCtrl { annotation: any; + types = [ + {text: 'Dashboard', value: 'dashboard'}, + {text: 'Tags', value: 'tags'} + ]; + constructor() { - this.annotation.type = this.annotation.type || 'alert'; + this.annotation.type = this.annotation.type || 'tags'; this.annotation.limit = this.annotation.limit || 100; } static templateUrl = 'partials/annotations.editor.html'; } - export { GrafanaDatasource, GrafanaDatasource as Datasource, GrafanaQueryCtrl as QueryCtrl, GrafanaAnnotationsQueryCtrl as AnnotationsQueryCtrl, }; - diff --git a/public/app/plugins/datasource/grafana/partials/annotations.editor.html b/public/app/plugins/datasource/grafana/partials/annotations.editor.html index 24a06a2abd6..9803f082a23 100644 --- a/public/app/plugins/datasource/grafana/partials/annotations.editor.html +++ b/public/app/plugins/datasource/grafana/partials/annotations.editor.html @@ -2,14 +2,29 @@
      - Type -
      -
      + +
      + Tags + + +
      +
      - Max limit + Max limit
      @@ -17,3 +32,5 @@
      + + diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index 2c846ee6be8..5114922f1f7 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -68,6 +68,18 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv return result; }; + this.parseTags = function(tagString) { + let tags = []; + tags = tagString.split(','); + if (tags.length === 1) { + tags = tagString.split(' '); + if (tags[0] === '') { + tags = []; + } + } + return tags; + }; + this.annotationQuery = function(options) { // Graphite metric as annotation if (options.annotation.target) { @@ -102,19 +114,25 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv } else { // Graphite event as annotation var tags = templateSrv.replace(options.annotation.tags); - return this.events({range: options.rangeRaw, tags: tags}).then(function(results) { + return this.events({range: options.rangeRaw, tags: tags}).then(results => { var list = []; for (var i = 0; i < results.data.length; i++) { var e = results.data[i]; + var tags = e.tags; + if (_.isString(e.tags)) { + tags = this.parseTags(e.tags); + } + list.push({ annotation: options.annotation, time: e.when * 1000, title: e.what, - tags: e.tags, + tags: tags, text: e.data }); } + return list; }); } @@ -126,7 +144,6 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv if (options.tags) { tags = '&tags=' + options.tags; } - return this.doGraphiteRequest({ method: 'GET', url: '/events/get_data?from=' + this.translateTime(options.range.from, false) + diff --git a/public/app/plugins/datasource/graphite/query_ctrl.ts b/public/app/plugins/datasource/graphite/query_ctrl.ts index 7127636d47f..fd753fb77e1 100644 --- a/public/app/plugins/datasource/graphite/query_ctrl.ts +++ b/public/app/plugins/datasource/graphite/query_ctrl.ts @@ -1,5 +1,3 @@ -/// - import './add_graphite_func'; import './func_editor'; diff --git a/public/app/plugins/datasource/graphite/specs/datasource_specs.ts b/public/app/plugins/datasource/graphite/specs/datasource_specs.ts index 47c2fa070fe..182ba475331 100644 --- a/public/app/plugins/datasource/graphite/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/graphite/specs/datasource_specs.ts @@ -2,14 +2,16 @@ import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; import helpers from 'test/specs/helpers'; import {GraphiteDatasource} from "../datasource"; +import moment from 'moment'; +import _ from 'lodash'; describe('graphiteDatasource', function() { - var ctx = new helpers.ServiceTestContext(); - var instanceSettings: any = {url: [''], name: 'graphiteProd', jsonData: {}}; + let ctx = new helpers.ServiceTestContext(); + let instanceSettings: any = {url: [''], name: 'graphiteProd', jsonData: {}}; beforeEach(angularMocks.module('grafana.core')); beforeEach(angularMocks.module('grafana.services')); - beforeEach(ctx.providePhase(['backendSrv'])); + beforeEach(ctx.providePhase(['backendSrv', 'templateSrv'])); beforeEach(angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) { ctx.$q = $q; ctx.$httpBackend = $httpBackend; @@ -22,16 +24,16 @@ describe('graphiteDatasource', function() { ctx.ds = ctx.$injector.instantiate(GraphiteDatasource, {instanceSettings: instanceSettings}); }); - describe('When querying influxdb with one target using query editor target spec', function() { - var query = { + describe('When querying graphite with one target using query editor target spec', function() { + let query = { panelId: 3, rangeRaw: { from: 'now-1h', to: 'now' }, targets: [{ target: 'prod1.count' }, {target: 'prod2.count'}], maxDataPoints: 500, }; - var results; - var requestOptions; + let results; + let requestOptions; beforeEach(function() { ctx.backendSrv.datasourceRequest = function(options) { @@ -52,7 +54,7 @@ describe('graphiteDatasource', function() { }); it('should query correctly', function() { - var params = requestOptions.data.split('&'); + let params = requestOptions.data.split('&'); expect(params).to.contain('target=prod1.count'); expect(params).to.contain('target=prod2.count'); expect(params).to.contain('from=-1h'); @@ -60,7 +62,7 @@ describe('graphiteDatasource', function() { }); it('should exclude undefined params', function() { - var params = requestOptions.data.split('&'); + let params = requestOptions.data.split('&'); expect(params).to.not.contain('cacheTimeout=undefined'); }); @@ -75,58 +77,130 @@ describe('graphiteDatasource', function() { }); + describe('when fetching Graphite Events as annotations', () => { + let results; + + const options = { + annotation: { + tags: 'tag1' + }, + range: { + from: moment(1432288354), + to: moment(1432288401) + }, + rangeRaw: {from: "now-24h", to: "now"} + }; + + describe('and tags are returned as string', () => { + const response = { + data: [ + { + when: 1507222850, + tags: 'tag1 tag2', + data: 'some text', + id: 2, + what: 'Event - deploy' + } + ]}; + + beforeEach(() => { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when(response); + }; + + ctx.ds.annotationQuery(options).then(function(data) { results = data; }); + ctx.$rootScope.$apply(); + }); + + it('should parse the tags string into an array', () => { + expect(_.isArray(results[0].tags)).to.eql(true); + expect(results[0].tags.length).to.eql(2); + expect(results[0].tags[0]).to.eql('tag1'); + expect(results[0].tags[1]).to.eql('tag2'); + }); + }); + + describe('and tags are returned as an array', () => { + const response = { + data: [ + { + when: 1507222850, + tags: ['tag1', 'tag2'], + data: 'some text', + id: 2, + what: 'Event - deploy' + } + ]}; + beforeEach(() => { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when(response); + }; + + ctx.ds.annotationQuery(options).then(function(data) { results = data; }); + ctx.$rootScope.$apply(); + }); + + it('should parse the tags string into an array', () => { + expect(_.isArray(results[0].tags)).to.eql(true); + expect(results[0].tags.length).to.eql(2); + expect(results[0].tags[0]).to.eql('tag1'); + expect(results[0].tags[1]).to.eql('tag2'); + }); + }); + }); + describe('building graphite params', function() { it('should return empty array if no targets', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{}] }); expect(results.length).to.be(0); }); it('should uri escape targets', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{target: 'prod1.{test,test2}'}, {target: 'prod2.count'}] }); expect(results).to.contain('target=prod1.%7Btest%2Ctest2%7D'); }); it('should replace target placeholder', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{target: 'series1'}, {target: 'series2'}, {target: 'asPercent(#A,#B)'}] }); expect(results[2]).to.be('target=asPercent(series1%2Cseries2)'); }); it('should replace target placeholder for hidden series', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{target: 'series1', hide: true}, {target: 'sumSeries(#A)', hide: true}, {target: 'asPercent(#A,#B)'}] }); expect(results[0]).to.be('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))')); }); it('should replace target placeholder when nesting query references', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{target: 'series1'}, {target: 'sumSeries(#A)'}, {target: 'asPercent(#A,#B)'}] }); expect(results[2]).to.be('target=' + encodeURIComponent("asPercent(series1,sumSeries(series1))")); }); it('should fix wrong minute interval parameters', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{target: "summarize(prod.25m.count, '25m', 'sum')" }] }); expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')")); }); it('should fix wrong month interval parameters', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{target: "summarize(prod.5M.count, '5M', 'sum')" }] }); expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')")); }); it('should ignore empty targets', function() { - var results = ctx.ds.buildGraphiteParams({ + let results = ctx.ds.buildGraphiteParams({ targets: [{target: 'series1'}, {target: ''}] }); expect(results.length).to.be(2); diff --git a/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts b/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts index ff1ea9449ee..bbcd90bd40f 100644 --- a/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts +++ b/public/app/plugins/datasource/graphite/specs/query_ctrl_specs.ts @@ -1,5 +1,4 @@ -import '../query_ctrl'; import 'app/core/services/segment_srv'; import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index bab2d597a67..7b53d0e1d97 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -1,5 +1,3 @@ -/// - import _ from 'lodash'; import * as dateMath from 'app/core/utils/datemath'; diff --git a/public/app/plugins/datasource/influxdb/partials/annotations.editor.html b/public/app/plugins/datasource/influxdb/partials/annotations.editor.html index da8f4edf881..475eaa2d4e0 100644 --- a/public/app/plugins/datasource/influxdb/partials/annotations.editor.html +++ b/public/app/plugins/datasource/influxdb/partials/annotations.editor.html @@ -9,18 +9,16 @@
      - Title - + Text +
      -
      Tags
      - -
      - Text - +
      + Title (depricated) +
      diff --git a/public/app/plugins/datasource/influxdb/query_ctrl.ts b/public/app/plugins/datasource/influxdb/query_ctrl.ts index 2240183fa48..5c5bece75a2 100644 --- a/public/app/plugins/datasource/influxdb/query_ctrl.ts +++ b/public/app/plugins/datasource/influxdb/query_ctrl.ts @@ -352,12 +352,13 @@ export class InfluxQueryCtrl extends QueryCtrl { this.panelCtrl.refresh(); } - getTagValueOperator(tagValue, tagOperator) { + getTagValueOperator(tagValue, tagOperator): string { if (tagOperator !== '=~' && tagOperator !== '!~' && /^\/.*\/$/.test(tagValue)) { return '=~'; } else if ((tagOperator === '=~' || tagOperator === '!~') && /^(?!\/.*\/$)/.test(tagValue)) { return '='; } + return null; } getCollapsedText() { diff --git a/public/app/plugins/datasource/mixed/plugin.json b/public/app/plugins/datasource/mixed/plugin.json index 658e24c5e03..b28c58710f0 100644 --- a/public/app/plugins/datasource/mixed/plugin.json +++ b/public/app/plugins/datasource/mixed/plugin.json @@ -5,5 +5,9 @@ "builtIn": true, "mixed": true, - "metrics": true + "metrics": true, + + "queryOptions": { + "minInterval": true + } } diff --git a/public/app/plugins/datasource/mysql/datasource.ts b/public/app/plugins/datasource/mysql/datasource.ts index 652c21c1013..6a255d63d52 100644 --- a/public/app/plugins/datasource/mysql/datasource.ts +++ b/public/app/plugins/datasource/mysql/datasource.ts @@ -20,7 +20,15 @@ export class MysqlDatasource { return '\'' + value + '\''; } + if (typeof value === 'number') { + return value; + } + var quotedValues = _.map(value, function(val) { + if (typeof value === 'number') { + return value; + } + return '\'' + val + '\''; }); return quotedValues.join(','); diff --git a/public/app/plugins/datasource/mysql/module.ts b/public/app/plugins/datasource/mysql/module.ts index c32c832d6a0..2d953d941b5 100644 --- a/public/app/plugins/datasource/mysql/module.ts +++ b/public/app/plugins/datasource/mysql/module.ts @@ -9,7 +9,6 @@ class MysqlConfigCtrl { const defaultQuery = `SELECT UNIX_TIMESTAMP() as time_sec, - as title, as text, as tags FROM
      -   + +   {{annotation.name}} +   + {{annotation.name}} (Built-in) + @@ -67,7 +71,7 @@ - +
      Name Start url
      diff --git a/public/app/plugins/datasource/mysql/partials/config.html b/public/app/plugins/datasource/mysql/partials/config.html index 870d3443908..8cbeece71dd 100644 --- a/public/app/plugins/datasource/mysql/partials/config.html +++ b/public/app/plugins/datasource/mysql/partials/config.html @@ -9,17 +9,17 @@
      Database - +
      User - +
      Password - +
      diff --git a/public/app/plugins/datasource/mysql/partials/query.editor.html b/public/app/plugins/datasource/mysql/partials/query.editor.html index cd8b4eee7c4..a7e993afd7f 100644 --- a/public/app/plugins/datasource/mysql/partials/query.editor.html +++ b/public/app/plugins/datasource/mysql/partials/query.editor.html @@ -49,6 +49,7 @@ Macros: - $__time(column) -> UNIX_TIMESTAMP(column) as time_sec - $__timeFilter(column) -> UNIX_TIMESTAMP(time_date_time) ≥ 1492750877 AND UNIX_TIMESTAMP(time_date_time) ≤ 1492750877 - $__unixEpochFilter(column) -> time_unix_epoch > 1492750877 AND time_unix_epoch < 1492750877 +- $__timeGroup(column,'5m') -> (extract(epoch from "dateColumn")/extract(epoch from '5m'::interval))::int Or build your own conditionals using these macros which just return the values: - $__timeFrom() -> FROM_UNIXTIME(1492750877) diff --git a/public/app/plugins/datasource/mysql/response_parser.ts b/public/app/plugins/datasource/mysql/response_parser.ts index 5501e4fc17a..70b7ddd2695 100644 --- a/public/app/plugins/datasource/mysql/response_parser.ts +++ b/public/app/plugins/datasource/mysql/response_parser.ts @@ -106,7 +106,6 @@ export default class ResponseParser { const table = data.data.results[options.annotation.name].tables[0]; let timeColumnIndex = -1; - let titleColumnIndex = -1; let textColumnIndex = -1; let tagsColumnIndex = -1; @@ -114,7 +113,7 @@ export default class ResponseParser { if (table.columns[i].text === 'time_sec') { timeColumnIndex = i; } else if (table.columns[i].text === 'title') { - titleColumnIndex = i; + return this.$q.reject({message: 'The title column for annotations is deprecated, now only a column named text is returned'}); } else if (table.columns[i].text === 'text') { textColumnIndex = i; } else if (table.columns[i].text === 'tags') { @@ -132,7 +131,6 @@ export default class ResponseParser { list.push({ annotation: options.annotation, time: Math.floor(row[timeColumnIndex]) * 1000, - title: row[titleColumnIndex], text: row[textColumnIndex], tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : [] }); diff --git a/public/app/plugins/datasource/mysql/specs/datasource_specs.ts b/public/app/plugins/datasource/mysql/specs/datasource_specs.ts index 08d2f8922a5..6ff1f9d47ac 100644 --- a/public/app/plugins/datasource/mysql/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/mysql/specs/datasource_specs.ts @@ -27,7 +27,7 @@ describe('MySQLDatasource', function() { const options = { annotation: { name: annotationName, - rawQuery: 'select time_sec, title, text, tags from table;' + rawQuery: 'select time_sec, text, tags from table;' }, range: { from: moment(1432288354), @@ -41,11 +41,11 @@ describe('MySQLDatasource', function() { refId: annotationName, tables: [ { - columns: [{text: 'time_sec'}, {text: 'title'}, {text: 'text'}, {text: 'tags'}], + columns: [{text: 'time_sec'}, {text: 'text'}, {text: 'tags'}], rows: [ - [1432288355, 'aTitle', 'some text', 'TagA,TagB'], - [1432288390, 'aTitle2', 'some text2', ' TagB , TagC'], - [1432288400, 'aTitle3', 'some text3'] + [1432288355, 'some text', 'TagA,TagB'], + [1432288390, 'some text2', ' TagB , TagC'], + [1432288400, 'some text3'] ] } ] @@ -64,7 +64,6 @@ describe('MySQLDatasource', function() { it('should return annotation list', function() { expect(results.length).to.be(3); - expect(results[0].title).to.be('aTitle'); expect(results[0].text).to.be('some text'); expect(results[0].tags[0]).to.be('TagA'); expect(results[0].tags[1]).to.be('TagB'); @@ -194,4 +193,24 @@ describe('MySQLDatasource', function() { expect(results[0].value).to.be('same'); }); }); + + describe('When interpolating variables', () => { + describe('and value is a string', () => { + it('should return a quoted value', () => { + expect(ctx.ds.interpolateVariable('abc')).to.eql('\'abc\''); + }); + }); + + describe('and value is a number', () => { + it('should return an unquoted value', () => { + expect(ctx.ds.interpolateVariable(1000)).to.eql(1000); + }); + }); + + describe('and value is an array of strings', () => { + it('should return comma separated quoted values', () => { + expect(ctx.ds.interpolateVariable(['a', 'b', 'c'])).to.eql('\'a\',\'b\',\'c\''); + }); + }); + }); }); diff --git a/public/app/plugins/datasource/opentsdb/datasource.d.ts b/public/app/plugins/datasource/opentsdb/datasource.d.ts index bbbf1680f27..2e722bc1976 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.d.ts +++ b/public/app/plugins/datasource/opentsdb/datasource.d.ts @@ -1,3 +1,3 @@ declare var OpenTsDatasource: any; -export {OpenTsDatasource}; +export default OpenTsDatasource; diff --git a/public/app/plugins/datasource/opentsdb/datasource.js b/public/app/plugins/datasource/opentsdb/datasource.js index 921805a58ca..4d51b117ed4 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.js +++ b/public/app/plugins/datasource/opentsdb/datasource.js @@ -91,9 +91,8 @@ function (angular, _, dateMath) { if(annotationObject) { _.each(annotationObject, function(annotation) { var event = { - title: annotation.description, + text: annotation.description, time: Math.floor(annotation.startTime) * 1000, - text: annotation.notes, annotation: options.annotation }; @@ -472,7 +471,5 @@ function (angular, _, dateMath) { } } - return { - OpenTsDatasource: OpenTsDatasource - }; + return OpenTsDatasource; }); diff --git a/public/app/plugins/datasource/opentsdb/module.ts b/public/app/plugins/datasource/opentsdb/module.ts index 895cbe2ad8c..a34ee2219fe 100644 --- a/public/app/plugins/datasource/opentsdb/module.ts +++ b/public/app/plugins/datasource/opentsdb/module.ts @@ -1,4 +1,4 @@ -import {OpenTsDatasource} from './datasource'; +import OpenTsDatasource from './datasource'; import {OpenTsQueryCtrl} from './query_ctrl'; import {OpenTsConfigCtrl} from './config_ctrl'; diff --git a/public/app/plugins/datasource/opentsdb/partials/annotations.editor.html b/public/app/plugins/datasource/opentsdb/partials/annotations.editor.html index 6c5cc5450bf..914672053ae 100644 --- a/public/app/plugins/datasource/opentsdb/partials/annotations.editor.html +++ b/public/app/plugins/datasource/opentsdb/partials/annotations.editor.html @@ -3,8 +3,5 @@ OpenTSDB metrics query -
      - Show Global Annotations? - -
      + diff --git a/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts b/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts index cddcd621c01..13379824485 100644 --- a/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts +++ b/public/app/plugins/datasource/opentsdb/specs/datasource-specs.ts @@ -1,6 +1,6 @@ import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; import helpers from 'test/specs/helpers'; -import {OpenTsDatasource} from "../datasource"; +import OpenTsDatasource from "../datasource"; describe('opentsdb', function() { var ctx = new helpers.ServiceTestContext(); diff --git a/public/app/plugins/datasource/postgres/README.md b/public/app/plugins/datasource/postgres/README.md new file mode 100644 index 00000000000..7b343ba78ff --- /dev/null +++ b/public/app/plugins/datasource/postgres/README.md @@ -0,0 +1,3 @@ +# Grafana PostgreSQL Datasource - Native Plugin + +This is the built in PostgreSQL Datasource that is used to connect to PostgreSQL databases. diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts new file mode 100644 index 00000000000..54e3bdc41cd --- /dev/null +++ b/public/app/plugins/datasource/postgres/datasource.ts @@ -0,0 +1,132 @@ +/// + +import _ from 'lodash'; +import ResponseParser from './response_parser'; + +export class PostgresDatasource { + id: any; + name: any; + responseParser: ResponseParser; + + /** @ngInject **/ + constructor(instanceSettings, private backendSrv, private $q, private templateSrv) { + this.name = instanceSettings.name; + this.id = instanceSettings.id; + this.responseParser = new ResponseParser(this.$q); + } + + interpolateVariable(value) { + if (typeof value === 'string') { + return '\'' + value + '\''; + } + + var quotedValues = _.map(value, function(val) { + return '\'' + val + '\''; + }); + return quotedValues.join(','); + } + + query(options) { + var queries = _.filter(options.targets, item => { + return item.hide !== true; + }).map(item => { + return { + refId: item.refId, + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + datasourceId: this.id, + rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable), + format: item.format, + }; + }); + + if (queries.length === 0) { + return this.$q.when({data: []}); + } + + return this.backendSrv.datasourceRequest({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: queries, + } + }).then(this.responseParser.processQueryResult); + } + + annotationQuery(options) { + if (!options.annotation.rawQuery) { + return this.$q.reject({message: 'Query missing in annotation definition'}); + } + + const query = { + refId: options.annotation.name, + datasourceId: this.id, + rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable), + format: 'table', + }; + + return this.backendSrv.datasourceRequest({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries: [query], + } + }).then(data => this.responseParser.transformAnnotationResponse(options, data)); + } + + metricFindQuery(query, optionalOptions) { + let refId = 'tempvar'; + if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { + refId = optionalOptions.variable.name; + } + + const interpolatedQuery = { + refId: refId, + datasourceId: this.id, + rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable), + format: 'table', + }; + + return this.backendSrv.datasourceRequest({ + url: '/api/tsdb/query', + method: 'POST', + data: { + queries: [interpolatedQuery], + } + }) + .then(data => this.responseParser.parseMetricFindQueryResult(refId, data)); + } + + testDatasource() { + return this.backendSrv.datasourceRequest({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: '5m', + to: 'now', + queries: [{ + refId: 'A', + intervalMs: 1, + maxDataPoints: 1, + datasourceId: this.id, + rawSql: "SELECT 1", + format: 'table', + }], + } + }).then(res => { + return { status: "success", message: "Database Connection OK"}; + }).catch(err => { + console.log(err); + if (err.data && err.data.message) { + return { status: "error", message: err.data.message }; + } else { + return { status: "error", message: err.status }; + } + }); + } +} + diff --git a/public/app/plugins/datasource/postgres/img/postgresql_logo.svg b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg new file mode 100644 index 00000000000..d98e3659c39 --- /dev/null +++ b/public/app/plugins/datasource/postgres/img/postgresql_logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/app/plugins/datasource/mysql/mode-sql.js b/public/app/plugins/datasource/postgres/mode-sql.js similarity index 88% rename from public/app/plugins/datasource/mysql/mode-sql.js rename to public/app/plugins/datasource/postgres/mode-sql.js index 110cfb24649..20e3d493458 100644 --- a/public/app/plugins/datasource/mysql/mode-sql.js +++ b/public/app/plugins/datasource/postgres/mode-sql.js @@ -20,13 +20,13 @@ var SqlHighlightRules = function() { ); var builtinFunctions = ( - "avg|count|first|last|max|min|sum|ucase|lcase|mid|len|round|rank|now|format|" + - "coalesce|ifnull|isnull|nvl" + "avg|count|first|last|max|min|sum|upper|lower|substring|char_length|round|rank|now|" + + "coalesce" ); var dataTypes = ( - "int|numeric|decimal|date|varchar|char|bigint|float|double|bit|binary|text|set|timestamp|" + - "money|real|number|integer" + "int|int2|int4|int8|numeric|decimal|date|varchar|char|bigint|float|bool|bytea|text|timestamp|" + + "time|money|real|integer" ); var keywordMapper = this.createKeywordMapper({ @@ -50,9 +50,6 @@ var SqlHighlightRules = function() { }, { token : "string", // ' string regex : "'.*?'" - }, { - token : "string", // ` string (apache drill) - regex : "`.*?`" }, { token : "constant.numeric", // float regex : "[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b" diff --git a/public/app/plugins/datasource/postgres/module.ts b/public/app/plugins/datasource/postgres/module.ts new file mode 100644 index 00000000000..09a3f43c9ea --- /dev/null +++ b/public/app/plugins/datasource/postgres/module.ts @@ -0,0 +1,45 @@ +/// + +import {PostgresDatasource} from './datasource'; +import {PostgresQueryCtrl} from './query_ctrl'; + +class PostgresConfigCtrl { + static templateUrl = 'partials/config.html'; + + current: any; + + /** @ngInject **/ + constructor($scope) { + this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'require'; + } +} + +const defaultQuery = `SELECT + extract(epoch from time_column) AS time, + title_column as title, + description_column as text +FROM + metric_table +WHERE + $__timeFilter(time_column) +`; + +class PostgresAnnotationsQueryCtrl { + static templateUrl = 'partials/annotations.editor.html'; + + annotation: any; + + /** @ngInject **/ + constructor() { + this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery; + } +} + +export { + PostgresDatasource, + PostgresDatasource as Datasource, + PostgresQueryCtrl as QueryCtrl, + PostgresConfigCtrl as ConfigCtrl, + PostgresAnnotationsQueryCtrl as AnnotationsQueryCtrl, +}; + diff --git a/public/app/plugins/datasource/postgres/partials/annotations.editor.html b/public/app/plugins/datasource/postgres/partials/annotations.editor.html new file mode 100644 index 00000000000..07b838e739a --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/annotations.editor.html @@ -0,0 +1,41 @@ + +
      +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      Annotation Query Format
      +An annotation is an event that is overlayed on top of graphs. The query can have up to four columns per row, the time column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned. + +- column with alias: time for the annotation event. Format is UTC in seconds, use extract(epoch from column) as "time" +- column with alias title for the annotation title +- column with alias: text for the annotation text +- column with alias: tags for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2' + + +Macros: +- $__time(column) -> column as "time" +- $__timeFilter(column) -> column ≥ to_timestamp(1492750877) AND column ≤ to_timestamp(1492750877) +- $__unixEpochFilter(column) -> column > 1492750877 AND column < 1492750877 + +Or build your own conditionals using these macros which just return the values: +- $__timeFrom() -> to_timestamp(1492750877) +- $__timeTo() -> to_timestamp(1492750877) +- $__unixEpochFrom() -> 1492750877 +- $__unixEpochTo() -> 1492750877 +
      +
      +
      diff --git a/public/app/plugins/datasource/postgres/partials/config.html b/public/app/plugins/datasource/postgres/partials/config.html new file mode 100644 index 00000000000..77f0dcfa4a5 --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/config.html @@ -0,0 +1,52 @@ + +

      PostgreSQL Connection

      + +
      +
      + Host + +
      + +
      + Database + +
      + +
      +
      + User + +
      +
      + Password + +
      +
      + Password + + reset +
      +
      +
      + +
      + + + This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. + +
      +
      +
      + +
      +
      +
      User Permission
      +

      + The database user should only be granted SELECT permissions on the specified database & tables you want to query. + Grafana does not validate that queries are safe so queries can contain 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. +

      +
      +
      + diff --git a/public/app/plugins/datasource/postgres/partials/query.editor.html b/public/app/plugins/datasource/postgres/partials/query.editor.html new file mode 100644 index 00000000000..1939fc47ecb --- /dev/null +++ b/public/app/plugins/datasource/postgres/partials/query.editor.html @@ -0,0 +1,79 @@ + +
      +
      + + +
      +
      + +
      +
      + +
      + +
      +
      +
      + +
      +
      + +
      +
      +
      +
      +
      + +
      +
      {{ctrl.lastQueryMeta.sql}}
      +
      + +
      +
      Time series:
      +- return column named time (UTC in seconds or timestamp)
      +- return column(s) with numeric datatype as values
      +- (Optional: return column named metric to represent the series name. If no column named metric is found the column name of the value column is used as series name)
      +
      +Table:
      +- return any set of columns
      +
      +Macros:
      +- $__time(column) -> column as "time"
      +- $__timeEpoch -> extract(epoch from column) as "time"
      +- $__timeFilter(column) ->  column ≥ to_timestamp(1492750877) AND column ≤ to_timestamp(1492750877)
      +- $__unixEpochFilter(column) ->  column > 1492750877 AND column < 1492750877
      +
      +To group by time use $__timeGroup:
      +-> (extract(epoch from column)/extract(epoch from column::interval))::int
      +
      +Example of group by and order by with $__timeGroup:
      +SELECT
      +  min(date_time_col) AS time_sec,
      +  sum(value_double) as value
      +FROM yourtable
      +group by $__timeGroup(date_time_col, '1h')
      +order by $__timeGroup(date_time_col, '1h') ASC
      +
      +Or build your own conditionals using these macros which just return the values:
      +- $__timeFrom() ->  to_timestamp(1492750877)
      +- $__timeTo() ->  to_timestamp(1492750877)
      +- $__unixEpochFrom() ->  1492750877
      +- $__unixEpochTo() ->  1492750877
      +		
      +
      + + + +
      +
      {{ctrl.lastQueryError}}
      +
      + +
      diff --git a/public/app/plugins/datasource/postgres/plugin.json b/public/app/plugins/datasource/postgres/plugin.json new file mode 100644 index 00000000000..26d050ba8ed --- /dev/null +++ b/public/app/plugins/datasource/postgres/plugin.json @@ -0,0 +1,20 @@ +{ + "type": "datasource", + "name": "PostgreSQL", + "id": "postgres", + + "info": { + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/postgresql_logo.svg", + "large": "img/postgresql_logo.svg" + } + }, + + "alerting": true, + "annotations": true, + "metrics": true +} diff --git a/public/app/plugins/datasource/postgres/query_ctrl.ts b/public/app/plugins/datasource/postgres/query_ctrl.ts new file mode 100644 index 00000000000..c5ebdd0ad85 --- /dev/null +++ b/public/app/plugins/datasource/postgres/query_ctrl.ts @@ -0,0 +1,84 @@ +/// + +import _ from 'lodash'; +import {QueryCtrl} from 'app/plugins/sdk'; + +export interface PostgresQuery { + refId: string; + format: string; + alias: string; + rawSql: string; +} + +export interface QueryMeta { + sql: string; +} + + +const defaultQuery = `SELECT + $__time(time_column), + value1 +FROM + metric_table +WHERE + $__timeFilter(time_column) +`; + +export class PostgresQueryCtrl extends QueryCtrl { + static templateUrl = 'partials/query.editor.html'; + + showLastQuerySQL: boolean; + formats: any[]; + target: PostgresQuery; + lastQueryMeta: QueryMeta; + lastQueryError: string; + showHelp: boolean; + + /** @ngInject **/ + constructor($scope, $injector) { + super($scope, $injector); + + this.target.format = this.target.format || 'time_series'; + this.target.alias = ""; + this.formats = [ + {text: 'Time series', value: 'time_series'}, + {text: 'Table', value: 'table'}, + ]; + + if (!this.target.rawSql) { + + // special handling when in table panel + if (this.panelCtrl.panel.type === 'table') { + this.target.format = 'table'; + this.target.rawSql = "SELECT 1"; + } else { + this.target.rawSql = defaultQuery; + } + } + + this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope); + this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope); + } + + onDataReceived(dataList) { + this.lastQueryMeta = null; + this.lastQueryError = null; + + let anySeriesFromQuery = _.find(dataList, {refId: this.target.refId}); + if (anySeriesFromQuery) { + this.lastQueryMeta = anySeriesFromQuery.meta; + } + } + + onDataError(err) { + if (err.data && err.data.results) { + let queryRes = err.data.results[this.target.refId]; + if (queryRes) { + this.lastQueryMeta = queryRes.meta; + this.lastQueryError = queryRes.error; + } + } + } +} + + diff --git a/public/app/plugins/datasource/postgres/response_parser.ts b/public/app/plugins/datasource/postgres/response_parser.ts new file mode 100644 index 00000000000..9bc0cf7c626 --- /dev/null +++ b/public/app/plugins/datasource/postgres/response_parser.ts @@ -0,0 +1,141 @@ +/// + +import _ from 'lodash'; + +export default class ResponseParser { + constructor(private $q) {} + + processQueryResult(res) { + var data = []; + + if (!res.data.results) { + return {data: data}; + } + + for (let key in res.data.results) { + let queryRes = res.data.results[key]; + + if (queryRes.series) { + for (let series of queryRes.series) { + data.push({ + target: series.name, + datapoints: series.points, + refId: queryRes.refId, + meta: queryRes.meta, + }); + } + } + + if (queryRes.tables) { + for (let table of queryRes.tables) { + table.type = 'table'; + table.refId = queryRes.refId; + table.meta = queryRes.meta; + data.push(table); + } + } + } + + return {data: data}; + } + + parseMetricFindQueryResult(refId, results) { + if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) { return []; } + + const columns = results.data.results[refId].tables[0].columns; + const rows = results.data.results[refId].tables[0].rows; + const textColIndex = this.findColIndex(columns, '__text'); + const valueColIndex = this.findColIndex(columns, '__value'); + + if (columns.length === 2 && textColIndex !== -1 && valueColIndex !== -1) { + return this.transformToKeyValueList(rows, textColIndex, valueColIndex); + } + + return this.transformToSimpleList(rows); + } + + transformToKeyValueList(rows, textColIndex, valueColIndex) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + if (!this.containsKey(res, rows[i][textColIndex])) { + res.push({text: rows[i][textColIndex], value: rows[i][valueColIndex]}); + } + } + + return res; + } + + transformToSimpleList(rows) { + const res = []; + + for (let i = 0; i < rows.length; i++) { + for (let j = 0; j < rows[i].length; j++) { + const value = rows[i][j]; + if ( res.indexOf( value ) === -1 ) { + res.push(value); + } + } + } + + return _.map(res, value => { + return { text: value}; + }); + } + + findColIndex(columns, colName) { + for (let i = 0; i < columns.length; i++) { + if (columns[i].text === colName) { + return i; + } + } + + return -1; + } + + containsKey(res, key) { + for (let i = 0; i < res.length; i++) { + if (res[i].text === key) { + return true; + } + } + return false; + } + + transformAnnotationResponse(options, data) { + const table = data.data.results[options.annotation.name].tables[0]; + + let timeColumnIndex = -1; + let titleColumnIndex = -1; + let textColumnIndex = -1; + let tagsColumnIndex = -1; + + for (let i = 0; i < table.columns.length; i++) { + if (table.columns[i].text === 'time') { + timeColumnIndex = i; + } else if (table.columns[i].text === 'text') { + textColumnIndex = i; + } else if (table.columns[i].text === 'tags') { + tagsColumnIndex = i; + } + } + + if (timeColumnIndex === -1) { + return this.$q.reject({message: 'Missing mandatory time column in annotation query.'}); + } + + const list = []; + for (let i = 0; i < table.rows.length; i++) { + const row = table.rows[i]; + list.push({ + annotation: options.annotation, + time: Math.floor(row[timeColumnIndex]) * 1000, + title: row[titleColumnIndex], + text: row[textColumnIndex], + tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : [] + }); + } + + return list; + } +} diff --git a/public/app/plugins/datasource/postgres/specs/datasource_specs.ts b/public/app/plugins/datasource/postgres/specs/datasource_specs.ts new file mode 100644 index 00000000000..5510e138f63 --- /dev/null +++ b/public/app/plugins/datasource/postgres/specs/datasource_specs.ts @@ -0,0 +1,196 @@ +import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; +import moment from 'moment'; +import helpers from 'test/specs/helpers'; +import {PostgresDatasource} from '../datasource'; + +describe('PostgreSQLDatasource', function() { + var ctx = new helpers.ServiceTestContext(); + var instanceSettings = {name: 'postgresql'}; + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(ctx.providePhase(['backendSrv'])); + + beforeEach(angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) { + ctx.$q = $q; + ctx.$httpBackend = $httpBackend; + ctx.$rootScope = $rootScope; + ctx.ds = $injector.instantiate(PostgresDatasource, {instanceSettings: instanceSettings}); + $httpBackend.when('GET', /\.html$/).respond(''); + })); + + describe('When performing annotationQuery', function() { + let results; + + const annotationName = 'MyAnno'; + + const options = { + annotation: { + name: annotationName, + rawQuery: 'select time, title, text, tags from table;' + }, + range: { + from: moment(1432288354), + to: moment(1432288401) + } + }; + + const response = { + results: { + MyAnno: { + refId: annotationName, + tables: [ + { + columns: [{text: 'time'}, {text: 'text'}, {text: 'tags'}], + rows: [ + [1432288355, 'some text', 'TagA,TagB'], + [1432288390, 'some text2', ' TagB , TagC'], + [1432288400, 'some text3'] + ] + } + ] + } + } + }; + + beforeEach(function() { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when({data: response, status: 200}); + }; + ctx.ds.annotationQuery(options).then(function(data) { results = data; }); + ctx.$rootScope.$apply(); + }); + + it('should return annotation list', function() { + expect(results.length).to.be(3); + + expect(results[0].text).to.be('some text'); + expect(results[0].tags[0]).to.be('TagA'); + expect(results[0].tags[1]).to.be('TagB'); + + expect(results[1].tags[0]).to.be('TagB'); + expect(results[1].tags[1]).to.be('TagC'); + + expect(results[2].tags.length).to.be(0); + }); + }); + + describe('When performing metricFindQuery', function() { + let results; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3 + }, + refId: 'tempvar', + tables: [ + { + columns: [{text: 'title'}, {text: 'text'}], + rows: [ + ['aTitle', 'some text'], + ['aTitle2', 'some text2'], + ['aTitle3', 'some text3'] + ] + } + ] + } + } + }; + + beforeEach(function() { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when({data: response, status: 200}); + }; + ctx.ds.metricFindQuery(query).then(function(data) { results = data; }); + ctx.$rootScope.$apply(); + }); + + it('should return list of all column values', function() { + expect(results.length).to.be(6); + expect(results[0].text).to.be('aTitle'); + expect(results[5].text).to.be('some text3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns', function() { + let results; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3 + }, + refId: 'tempvar', + tables: [ + { + columns: [{text: '__value'}, {text: '__text'}], + rows: [ + ['value1', 'aTitle'], + ['value2', 'aTitle2'], + ['value3', 'aTitle3'] + ] + } + ] + } + } + }; + + beforeEach(function() { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when({data: response, status: 200}); + }; + ctx.ds.metricFindQuery(query).then(function(data) { results = data; }); + ctx.$rootScope.$apply(); + }); + + it('should return list of as text, value', function() { + expect(results.length).to.be(3); + expect(results[0].text).to.be('aTitle'); + expect(results[0].value).to.be('value1'); + expect(results[2].text).to.be('aTitle3'); + expect(results[2].value).to.be('value3'); + }); + }); + + describe('When performing metricFindQuery with key, value columns and with duplicate keys', function() { + let results; + const query = 'select * from atable'; + const response = { + results: { + tempvar: { + meta: { + rowCount: 3 + }, + refId: 'tempvar', + tables: [ + { + columns: [{text: '__text'}, {text: '__value'}], + rows: [ + ['aTitle', 'same'], + ['aTitle', 'same'], + ['aTitle', 'diff'] + ] + } + ] + } + } + }; + + beforeEach(function() { + ctx.backendSrv.datasourceRequest = function(options) { + return ctx.$q.when({data: response, status: 200}); + }; + ctx.ds.metricFindQuery(query).then(function(data) { results = data; }); + ctx.$rootScope.$apply(); + }); + + it('should return list of unique keys', function() { + expect(results.length).to.be(1); + expect(results[0].text).to.be('aTitle'); + expect(results[0].value).to.be('same'); + }); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/completer.ts b/public/app/plugins/datasource/prometheus/completer.ts index 2651f75df98..dc2def62a91 100644 --- a/public/app/plugins/datasource/prometheus/completer.ts +++ b/public/app/plugins/datasource/prometheus/completer.ts @@ -1,39 +1,87 @@ /// import {PrometheusDatasource} from "./datasource"; +import _ from 'lodash'; export class PromCompleter { - identifierRegexps = [/[\[\]a-zA-Z_0-9=]/]; + labelQueryCache: any; + labelNameCache: any; + labelValueCache: any; + + identifierRegexps = [/\[/, /[a-zA-Z0-9_:]/]; constructor(private datasource: PrometheusDatasource) { + this.labelQueryCache = {}; + this.labelNameCache = {}; + this.labelValueCache = {}; } getCompletions(editor, session, pos, prefix, callback) { let token = session.getTokenAt(pos.row, pos.column); + var metricName; switch (token.type) { - case 'label.name': - callback(null, ['instance', 'job'].map(function (key) { - return { - caption: key, - value: key, - meta: "label name", - score: Number.MAX_VALUE - }; - })); - return; - case 'label.value': - callback(null, []); - return; + case 'entity.name.tag': + metricName = this.findMetricName(session, pos.row, pos.column); + if (!metricName) { + callback(null, this.transformToCompletions(['__name__', 'instance', 'job'], 'label name')); + return; + } + + if (this.labelNameCache[metricName]) { + callback(null, this.labelNameCache[metricName]); + return; + } + + return this.getLabelNameAndValueForMetric(metricName).then(result => { + var labelNames = this.transformToCompletions( + _.uniq(_.flatten(result.map(r => { + return Object.keys(r.metric); + }))) + , 'label name'); + this.labelNameCache[metricName] = labelNames; + callback(null, labelNames); + }); + case 'string.quoted': + metricName = this.findMetricName(session, pos.row, pos.column); + if (!metricName) { + callback(null, []); + return; + } + + var labelNameToken = this.findToken(session, pos.row, pos.column, 'entity.name.tag', null, 'paren.lparen'); + if (!labelNameToken) { + callback(null, []); + return; + } + var labelName = labelNameToken.value; + + if (this.labelValueCache[metricName] && this.labelValueCache[metricName][labelName]) { + callback(null, this.labelValueCache[metricName][labelName]); + return; + } + + return this.getLabelNameAndValueForMetric(metricName).then(result => { + var labelValues = this.transformToCompletions( + _.uniq(result.map(r => { + return r.metric[labelName]; + })) + , 'label value'); + this.labelValueCache[metricName] = this.labelValueCache[metricName] || {}; + this.labelValueCache[metricName][labelName] = labelValues; + callback(null, labelValues); + }); } - if (prefix === '[') { + if (token.type === 'paren.lparen' && token.value === '[') { var vectors = []; for (let unit of ['s', 'm', 'h']) { for (let value of [1,5,10,30]) { vectors.push({caption: value+unit, value: '['+value+unit, meta: 'range vector'}); } } + vectors.push({caption: '$__interval', value: '[$__interval', meta: 'range vector'}); + vectors.push({caption: '$__interval_ms', value: '[$__interval_ms', meta: 'range vector'}); callback(null, vectors); return; } @@ -56,4 +104,87 @@ export class PromCompleter { }); } + getLabelNameAndValueForMetric(metricName) { + if (this.labelQueryCache[metricName]) { + return Promise.resolve(this.labelQueryCache[metricName]); + } + var op = '=~'; + if (/[a-zA-Z_:][a-zA-Z0-9_:]*/.test(metricName)) { + op = '='; + } + var expr = '{__name__' + op + '"' + metricName + '"}'; + return this.datasource.performInstantQuery({ expr: expr }, new Date().getTime() / 1000).then(response => { + this.labelQueryCache[metricName] = response.data.data.result; + return response.data.data.result; + }); + } + + transformToCompletions(words, meta) { + return words.map(name => { + return { + caption: name, + value: name, + meta: meta, + score: Number.MAX_VALUE + }; + }); + } + + findMetricName(session, row, column) { + var metricName = ''; + + var tokens; + var nameLabelNameToken = this.findToken(session, row, column, 'entity.name.tag', '__name__', 'paren.lparen'); + if (nameLabelNameToken) { + tokens = session.getTokens(nameLabelNameToken.row); + var nameLabelValueToken = tokens[nameLabelNameToken.index + 2]; + if (nameLabelValueToken && nameLabelValueToken.type === 'string.quoted') { + metricName = nameLabelValueToken.value.slice(1, -1); // cut begin/end quotation + } + } else { + var metricNameToken = this.findToken(session, row, column, 'identifier', null, null); + if (metricNameToken) { + tokens = session.getTokens(metricNameToken.row); + if (tokens[metricNameToken.index + 1].type === 'paren.lparen') { + metricName = metricNameToken.value; + } + } + } + + return metricName; + } + + findToken(session, row, column, target, value, guard) { + var tokens, idx; + for (var r = row; r >= 0; r--) { + tokens = session.getTokens(r); + if (r === row) { // current row + var c = 0; + for (idx = 0; idx < tokens.length; idx++) { + c += tokens[idx].value.length; + if (c >= column) { + break; + } + } + } else { + idx = tokens.length - 1; + } + + for (; idx >= 0; idx--) { + if (tokens[idx].type === guard) { + return null; + } + + if (tokens[idx].type === target + && (!value || tokens[idx].value === value)) { + tokens[idx].row = r; + tokens[idx].index = idx; + return tokens[idx]; + } + } + } + + return null; + } + } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 235198b21ba..98776f37962 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -1,15 +1,12 @@ /// import _ from 'lodash'; -import moment from 'moment'; import kbn from 'app/core/utils/kbn'; import * as dateMath from 'app/core/utils/datemath'; import PrometheusMetricFindQuery from './metric_find_query'; import TableModel from 'app/core/table_model'; -var durationSplitRegexp = /(\d+)(ms|s|m|h|d|w|M|y)/; - function prometheusSpecialRegexEscape(value) { return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&'); } @@ -83,6 +80,7 @@ export class PrometheusDatasource { var self = this; var start = this.getPrometheusTime(options.range.from, false); var end = this.getPrometheusTime(options.range.to, true); + var range = Math.ceil(end - start); var queries = []; var activeTargets = []; @@ -95,17 +93,7 @@ export class PrometheusDatasource { } activeTargets.push(target); - - var query: any = {}; - query.expr = this.templateSrv.replace(target.expr, options.scopedVars, self.interpolateQueryExpr); - query.requestId = options.panelId + target.refId; - - var interval = this.templateSrv.replace(target.interval, options.scopedVars) || options.interval; - var intervalFactor = target.intervalFactor || 1; - target.step = query.step = this.calculateInterval(interval, intervalFactor); - var range = Math.ceil(end - start); - target.step = query.step = this.adjustStep(query.step, this.intervalSeconds(options.interval), range); - queries.push(query); + queries.push(this.createQuery(target, options, range)); } // No valid targets, return the empty result to save a round trip. @@ -114,7 +102,11 @@ export class PrometheusDatasource { } var allQueryPromise = _.map(queries, query => { - return this.performTimeSeriesQuery(query, start, end); + if (!query.instant) { + return this.performTimeSeriesQuery(query, start, end); + } else { + return this.performInstantQuery(query, end); + } }); return this.$q.all(allQueryPromise).then(responseList => { @@ -129,7 +121,11 @@ export class PrometheusDatasource { result.push(self.transformMetricDataToTable(response.data.data.result)); } else { for (let metricData of response.data.data.result) { - result.push(self.transformMetricData(metricData, activeTargets[index], start, end)); + if (response.data.data.resultType === 'matrix') { + result.push(self.transformMetricData(metricData, activeTargets[index], start, end)); + } else if (response.data.data.resultType === 'vector') { + result.push(self.transformInstantMetricData(metricData, activeTargets[index])); + } } } }); @@ -138,13 +134,41 @@ export class PrometheusDatasource { }); } - adjustStep(step, autoStep, range) { - // Prometheus drop query if range/step > 11000 - // calibrate step if it is too big - if (step !== 0 && range / step > 11000) { - step = Math.ceil(range / 11000); + createQuery(target, options, range) { + var query: any = {}; + query.instant = target.instant; + + var interval = kbn.interval_to_seconds(options.interval); + // Minimum interval ("Min step"), if specified for the query. or same as interval otherwise + var minInterval = kbn.interval_to_seconds(this.templateSrv.replace(target.interval, options.scopedVars) || options.interval); + var intervalFactor = target.intervalFactor || 1; + // Adjust the interval to take into account any specified minimum and interval factor plus Prometheus limits + var adjustedInterval = this.adjustInterval(interval, minInterval, range, intervalFactor); + + var scopedVars = options.scopedVars; + // If the interval was adjusted, make a shallow copy of scopedVars with updated interval vars + if (interval !== adjustedInterval) { + interval = adjustedInterval; + scopedVars = Object.assign({}, options.scopedVars, { + "__interval": {text: interval + "s", value: interval + "s"}, + "__interval_ms": {text: interval * 1000, value: interval * 1000}, + }); } - return Math.max(step, autoStep); + target.step = query.step = interval; + + // Only replace vars in expression after having (possibly) updated interval vars + query.expr = this.templateSrv.replace(target.expr, scopedVars, this.interpolateQueryExpr); + query.requestId = options.panelId + target.refId; + return query; + } + + adjustInterval(interval, minInterval, range, intervalFactor) { + // Prometheus will drop queries that might return more than 11000 data points. + // Calibrate interval if it is too small. + if (interval !== 0 && range / intervalFactor / interval > 11000) { + interval = Math.ceil(range / intervalFactor / 11000); + } + return Math.max(interval * intervalFactor, minInterval, 1); } performTimeSeriesQuery(query, start, end) { @@ -156,6 +180,11 @@ export class PrometheusDatasource { return this._request('GET', url, query.requestId); } + performInstantQuery(query, time) { + var url = '/api/v1/query?query=' + encodeURIComponent(query.expr) + '&time=' + time; + return this._request('GET', url, query.requestId); + } + performSuggestQuery(query, cache = false) { var url = '/api/v1/label/__name__/values'; @@ -204,7 +233,7 @@ export class PrometheusDatasource { var end = this.getPrometheusTime(options.range.to, true); var query = { expr: interpolated, - step: this.adjustStep(kbn.interval_to_seconds(step), 0, Math.ceil(end - start)) + 's' + step: this.adjustInterval(kbn.interval_to_seconds(step), 0, Math.ceil(end - start), 1) + 's' }; var self = this; @@ -243,21 +272,6 @@ export class PrometheusDatasource { }); } - calculateInterval(interval, intervalFactor) { - return Math.ceil(this.intervalSeconds(interval) * intervalFactor); - } - - intervalSeconds(interval) { - var m = interval.match(durationSplitRegexp); - var dur = moment.duration(parseInt(m[1]), m[2]); - var sec = dur.asSeconds(); - if (sec < 1) { - sec = 1; - } - - return sec; - } - transformMetricData(md, options, start, end) { var dps = [], metricLabel = null; @@ -317,6 +331,9 @@ export class PrometheusDatasource { // Populate rows, set value to empty string when label not present. _.each(md, function(series) { + if (series.value) { + series.values = [series.value]; + } if (series.values) { for (i = 0; i < series.values.length; i++) { var values = series.values[i]; @@ -340,6 +357,13 @@ export class PrometheusDatasource { return table; } + transformInstantMetricData(md, options) { + var dps = [], metricLabel = null; + metricLabel = this.createMetricLabel(md.metric, options); + dps.push([parseFloat(md.value[1]), md.value[0] * 1000]); + return { target: metricLabel, datapoints: dps }; + } + createMetricLabel(labelData, options) { if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) { return this.getOriginalMetricName(labelData); diff --git a/public/app/plugins/datasource/prometheus/metric_find_query.js b/public/app/plugins/datasource/prometheus/metric_find_query.js index a33044fddf6..6bfd324cd6e 100644 --- a/public/app/plugins/datasource/prometheus/metric_find_query.js +++ b/public/app/plugins/datasource/prometheus/metric_find_query.js @@ -95,9 +95,7 @@ function (_) { PrometheusMetricFindQuery.prototype.queryResultQuery = function(query) { var end = this.datasource.getPrometheusTime(this.range.to, true); - var url = '/api/v1/query?query=' + encodeURIComponent(query) + '&time=' + end; - - return this.datasource._request('GET', url) + return this.datasource.performInstantQuery({ expr: query }, end) .then(function(result) { return _.map(result.data.data.result, function(metricData) { var text = metricData.metric.__name__ || ''; diff --git a/public/app/plugins/datasource/prometheus/mode-prometheus.js b/public/app/plugins/datasource/prometheus/mode-prometheus.js index 78edbbd30d1..352bcb8dfb5 100644 --- a/public/app/plugins/datasource/prometheus/mode-prometheus.js +++ b/public/app/plugins/datasource/prometheus/mode-prometheus.js @@ -43,7 +43,7 @@ var PrometheusHighlightRules = function() { regex : "\\d+[smhdwy]" }, { token : keywordMapper, - regex : "[a-zA-Z_$][a-zA-Z0-9_$]*\\b" + regex : "[a-zA-Z_:][a-zA-Z0-9_:]*" }, { token : "keyword.operator", regex : "\\+|\\-|\\*|\\/|%|\\^|=|==|!=|<=|>=|<|>|=\\~|!\\~" @@ -65,13 +65,13 @@ var PrometheusHighlightRules = function() { regex : "\\s+" } ], "start-label-matcher" : [ { - token : "keyword", + token : "entity.name.tag", regex : '[a-zA-Z_][a-zA-Z0-9_]*' }, { token : "keyword.operator", regex : '=~|=|!~|!=' }, { - token : "string", + token : "string.quoted", regex : '"[^"]*"|\'[^\']*\'' }, { token : "punctuation.operator", @@ -401,7 +401,7 @@ var PrometheusCompletions = function() {}; (function() { this.getCompletions = function(state, session, pos, prefix, callback) { var token = session.getTokenAt(pos.row, pos.column); - if (token.type === 'label.name' || token.type === 'label.value') { + if (token.type === 'entity.name.tag' || token.type === 'string.quoted') { return callback(null, []); } diff --git a/public/app/plugins/datasource/prometheus/partials/query.editor.html b/public/app/plugins/datasource/prometheus/partials/query.editor.html index 9b06d4116f4..98fd01eee15 100644 --- a/public/app/plugins/datasource/prometheus/partials/query.editor.html +++ b/public/app/plugins/datasource/prometheus/partials/query.editor.html @@ -45,6 +45,8 @@
      + +