diff --git a/.circleci/config.yml b/.circleci/config.yml index a5497e6c7e8..28400fc6bd6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -206,6 +206,10 @@ jobs: - run: docker info - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}" + - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cd packaging/docker && ./build-enterprise.sh "master" + grafana-docker-pr: docker: @@ -230,6 +234,9 @@ jobs: - run: docker info - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}" + - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz + - run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}" build-enterprise: docker: @@ -238,8 +245,17 @@ jobs: steps: - checkout - run: - name: build, test and package grafana enterprise - command: './scripts/build/build_enterprise.sh' + name: prepare build tools + command: '/tmp/bootstrap.sh' + - run: + name: checkout enterprise + command: './scripts/build/prepare-enterprise.sh' + - run: + name: test enterprise + command: 'go test ./pkg/extensions/...' + - run: + name: build and package enterprise + command: './scripts/build/build.sh -enterprise' - run: name: sign packages command: './scripts/build/sign_packages.sh' @@ -254,6 +270,53 @@ jobs: paths: - enterprise-dist/grafana-enterprise* + build-all-enterprise: + docker: + - image: grafana/build-container:1.2.0 + working_directory: /go/src/github.com/grafana/grafana + steps: + - checkout + - run: + name: prepare build tools + command: '/tmp/bootstrap.sh' + - run: + name: checkout enterprise + command: './scripts/build/prepare-enterprise.sh' + - restore_cache: + key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }} + - run: + name: download phantomjs binaries + command: './scripts/build/download-phantomjs.sh' + - save_cache: + key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }} + paths: + - /tmp/phantomjs + - run: + name: test enterprise + command: 'go test ./pkg/extensions/...' + - run: + name: build and package grafana + command: './scripts/build/build-all.sh -enterprise' + - run: + name: sign packages + command: './scripts/build/sign_packages.sh' + - run: + name: verify signed packages + command: | + mkdir -p ~/.rpmdb/pubkeys + curl -s https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana > ~/.rpmdb/pubkeys/grafana.key + ./scripts/build/verify_signed_packages.sh dist/*.rpm + - run: + name: sha-sum packages + command: 'go run build.go sha-dist' + - run: + name: move enterprise packages into their own folder + command: 'mv dist enterprise-dist' + - persist_to_workspace: + root: . + paths: + - enterprise-dist/grafana-enterprise* + deploy-enterprise-master: docker: - image: circleci/python:2.7-stretch @@ -267,6 +330,19 @@ jobs: name: deploy to s3 command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master' + deploy-enterprise-release: + docker: + - image: circleci/python:2.7-stretch + steps: + - attach_workspace: + at: . + - run: + name: install awscli + command: 'sudo pip install awscli' + - run: + name: deploy to s3 + command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release' + deploy-master: docker: - image: circleci/python:2.7-stretch @@ -313,7 +389,7 @@ workflows: jobs: - build-all: filters: *filter-only-master - - build-enterprise: + - build-all-enterprise: filters: *filter-only-master - codespell: filters: *filter-only-master @@ -340,6 +416,7 @@ workflows: - grafana-docker-master: requires: - build-all + - build-all-enterprise - test-backend - test-frontend - codespell @@ -356,13 +433,15 @@ workflows: - gometalinter - mysql-integration-test - postgres-integration-test - - build-enterprise + - build-all-enterprise filters: *filter-only-master release: jobs: - build-all: filters: *filter-only-release + - build-all-enterprise: + filters: *filter-only-release - codespell: filters: *filter-only-release - gometalinter: @@ -385,6 +464,17 @@ workflows: - mysql-integration-test - postgres-integration-test filters: *filter-only-release + - deploy-enterprise-release: + requires: + - build-all + - build-all-enterprise + - test-backend + - test-frontend + - codespell + - gometalinter + - mysql-integration-test + - postgres-integration-test + filters: *filter-only-release - grafana-docker-release: requires: - build-all diff --git a/CHANGELOG.md b/CHANGELOG.md index 1189b5fe112..0b14126e5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,45 @@ ### New Features +* **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat) * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset) * **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro) +* **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) +* **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669) ### Minor -* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda) * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg) +* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu) +* **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino) ### Breaking changes * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited) +# 5.3.3 (unreleased) + +* **MySQL**: Fix `$__timeFilter()` should respect local time zone [#13769](https://github.com/grafana/grafana/issues/13769) + +# 5.3.2 (2018-10-24) + +* **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm) +* **Postgres**: Fix template variables error [#13692](https://github.com/grafana/grafana/issues/13692), thx [@svenklemm](https://github.com/svenklemm) +* **Cloudwatch**: Fix service panic because of race conditions [#13674](https://github.com/grafana/grafana/issues/13674), thx [@mtanda](https://github.com/mtanda) +* **Cloudwatch**: Fix check for invalid percentile statistics [#13633](https://github.com/grafana/grafana/issues/13633), thx [@apalaniuk](https://github.com/apalaniuk) +* **Stackdriver/Cloudwatch**: Allow user to change unit in graph panel if cloudwatch/stackdriver datasource response doesn't include unit [#13718](https://github.com/grafana/grafana/issues/13718), thx [@mtanda](https://github.com/mtanda) +* **Stackdriver**: stackdriver user-metrics duplicated response when multiple resource types [#13691](https://github.com/grafana/grafana/issues/13691) +* **Variables**: Fix text box template variable doesn't work properly without a default value [#13666](https://github.com/grafana/grafana/issues/13666) +* **Variables**: Fix variable dependency check when using `${var}` format [#13600](https://github.com/grafana/grafana/issues/13600) +* **Dashboard**: Fix kiosk=1 url parameter should put dashboard in kiosk mode [#13764](https://github.com/grafana/grafana/pull/13764) +* **LDAP**: Fix super admins can also be admins of orgs [#13710](https://github.com/grafana/grafana/issues/13710), thx [@adrien-f](https://github.com/adrien-f) +* **Provisioning**: Fix deleting provisioned dashboard folder should cleanup provisioning meta data [#13280](https://github.com/grafana/grafana/issues/13280) + +### Minor + +* **Docker**: adds curl back into the docker image for utility. [#13794](https://github.com/grafana/grafana/pull/13794) + # 5.3.1 (2018-10-16) * **Render**: Fix PhantomJS render of graph panel when legend displayed as table to the right [#13616](https://github.com/grafana/grafana/issues/13616) diff --git a/Gruntfile.js b/Gruntfile.js index 2d5990b5f58..de3e68d4a92 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -9,12 +9,17 @@ module.exports = function (grunt) { destDir: 'dist', tempDir: 'tmp', platform: process.platform.replace('win32', 'windows'), + enterprise: false, }; if (grunt.option('platform')) { config.platform = grunt.option('platform'); } + if (grunt.option('enterprise')) { + config.enterprise = true; + } + if (grunt.option('arch')) { config.arch = grunt.option('arch'); } else { diff --git a/Makefile b/Makefile index c9e51d897f3..fcb740d2fac 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,7 @@ all: deps build deps-go: go run build.go setup -deps-js: - yarn install --pure-lockfile --no-progress +deps-js: node_modules deps: deps-js @@ -43,3 +42,10 @@ test: test-go test-js run: ./bin/grafana-server + +clean: + rm -rf node_modules + rm -rf public/build + +node_modules: package.json yarn.lock + yarn install --pure-lockfile --no-progress diff --git a/README.md b/README.md index 1179385d10c..5882ea8a6a3 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ the latest master builds [here](https://grafana.com/grafana/download) ### Dependencies -- Go 1.11 +- Go (Latest Stable) - NodeJS LTS ### Building the backend @@ -69,15 +69,27 @@ bra run Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`). -### Building a docker image (on linux/amd64) +### Building a Docker image -This builds a docker image from your local sources: +There are two different ways to build a Grafana docker image. If you're machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker. + +Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev` + +#### Building on linux/amd64 (fast) 1. Build the frontend `go run build.go build-frontend` 2. Build the docker image `make build-docker-dev` The resulting image will be tagged as `grafana/grafana:dev` +#### Building anywhere (slower) + +Choose this option to build on platforms other than linux/amd64 and/or not have to setup the Grafana development environment. + +1. `make build-docker-full` or `docker build -t grafana/grafana:dev .` + +The resulting image will be tagged as `grafana/grafana:dev` + ### Dev config Create a custom.ini in the conf directory to override default configuration options. @@ -113,18 +125,6 @@ GRAFANA_TEST_DB=mysql go test ./pkg/... GRAFANA_TEST_DB=postgres go test ./pkg/... ``` -## Building custom docker image - -You can build a custom image using Docker, which doesn't require installing any dependencies besides docker itself. -```bash -git clone https://github.com/grafana/grafana -cd grafana -docker build -t grafana:dev . -docker run -d --name=grafana -p 3000:3000 grafana:dev -``` - -Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`). - ## Contribute If you have any idea for an improvement or found a bug, do not hesitate to open an issue. diff --git a/UPGRADING_DEPENDENCIES.md b/UPGRADING_DEPENDENCIES.md new file mode 100644 index 00000000000..f3d2adbd71a --- /dev/null +++ b/UPGRADING_DEPENDENCIES.md @@ -0,0 +1,89 @@ +# Guide to Upgrading Dependencies + +Upgrading Go or Node.js requires making changes in many different files. See below for a list and explanation for each. + +## Go + +- CircleCi +- `grafana/build-container` +- Appveyor +- Dockerfile + +## Node.js + +- CircleCI +- `grafana/build-container` +- Appveyor +- Dockerfile + +## Go Dependencies + +Updated using `dep`. + +- `Gopkg.toml` +- `Gopkg.lock` + +## Node.js Dependencies + +Updated using `yarn`. + +- `package.json` + +## Where to make changes + +### CircleCI + +Our builds run on CircleCI through our build script. + +#### Files + +- `.circleci/config.yml`. + +#### Dependencies + +- nodejs +- golang +- grafana/build-container (our custom docker build container) + +### grafana/build-container + +The main build step (in CircleCI) is built using a custom build container that comes pre-baked with some of the neccesary dependencies. + +Link: [grafana-build-container](https://github.com/grafana/grafana-build-container) + +#### Dependencies + +- fpm +- nodejs +- golang +- crosscompiling (several compilers) + +### Appveyor + +Master and release builds trigger test runs on Appveyors build environment so that tests will run on Windows. + +#### Files: + +- `appveyor.yml` + +#### Dependencies + +- nodejs +- golang + +### Dockerfile + +There is a Docker build for Grafana in the root of the project that allows anyone to build Grafana just using Docker. + +#### Files + +- `Dockerfile` + +#### Dependencies + +- nodejs +- golang + +### Local developer environments + +Please send out a notice in the grafana-dev slack channel when updating Go or Node.js to make it easier for everyone to update their local developer environments. \ No newline at end of file diff --git a/build.go b/build.go index 69fbf3bada8..b136754efbc 100644 --- a/build.go +++ b/build.go @@ -403,6 +403,10 @@ func gruntBuildArg(task string) []string { if phjsToRelease != "" { args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease)) } + if enterprise { + args = append(args, "--enterprise") + } + args = append(args, fmt.Sprintf("--platform=%v", goos)) return args @@ -467,6 +471,7 @@ func ldflags() string { b.WriteString(fmt.Sprintf(" -X main.version=%s", version)) b.WriteString(fmt.Sprintf(" -X main.commit=%s", getGitSha())) b.WriteString(fmt.Sprintf(" -X main.buildstamp=%d", buildStamp())) + b.WriteString(fmt.Sprintf(" -X main.buildBranch=%s", getGitBranch())) return b.String() } @@ -514,6 +519,14 @@ func setBuildEnv() { } } +func getGitBranch() string { + v, err := runError("git", "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "master" + } + return string(v) +} + func getGitSha() string { v, err := runError("git", "rev-parse", "--short", "HEAD") if err != nil { diff --git a/conf/defaults.ini b/conf/defaults.ini index eb8debc0094..750f06f2f6a 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -554,3 +554,6 @@ container_name = # Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer server_url = callback_url = + +[panels] +enable_alpha = false diff --git a/devenv/dev-dashboards/panel_tests_graph.json b/devenv/dev-dashboards/panel_tests_graph.json index 8a1770f0fa6..ba677764a43 100644 --- a/devenv/dev-dashboards/panel_tests_graph.json +++ b/devenv/dev-dashboards/panel_tests_graph.json @@ -927,6 +927,123 @@ "title": "", "type": "text" }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "editable": true, + "error": false, + "fill": 0, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 44 + }, + "id": 21, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "C-series", + "steppedLine": true + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "hide": false, + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,null,40,null,90,null,null,100,null,null,100,null,null,80,null", + "target": "" + }, + { + "alias": "", + "hide": false, + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "20,null40,null,null,50,null,70,null,100,null,10,null,30,null", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Null between points", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points", + "editable": true, + "error": false, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 44 + }, + "id": 22, + "links": [], + "mode": "markdown", + "title": "", + "type": "text" + }, { "aliasColors": {}, "bars": false, @@ -939,7 +1056,7 @@ "h": 7, "w": 24, "x": 0, - "y": 44 + "y": 51 }, "id": 20, "legend": { @@ -1024,7 +1141,7 @@ "h": 7, "w": 12, "x": 0, - "y": 51 + "y": 58 }, "id": 16, "legend": { @@ -1127,7 +1244,7 @@ "h": 7, "w": 12, "x": 12, - "y": 51 + "y": 58 }, "id": 17, "legend": { @@ -1266,7 +1383,7 @@ "h": 7, "w": 12, "x": 0, - "y": 58 + "y": 65 }, "id": 18, "legend": { @@ -1370,7 +1487,7 @@ "h": 7, "w": 12, "x": 12, - "y": 58 + "y": 65 }, "id": 19, "legend": { @@ -1554,5 +1671,5 @@ "timezone": "browser", "title": "Panel Tests - Graph", "uid": "5SdHCadmz", - "version": 3 + "version": 1 } diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index 8916b2bf6e3..9149aa42130 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -158,7 +158,7 @@ Since not all datasources have the same configuration settings we only have the | timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source | | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) | | timeField | string | Elasticsearch | Which field that should be used as timestamp | -| interval | string | Elasticsearch | Index date time format | +| interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | | assumeRoleArn | string | Cloudwatch | ARN of Assume Role | | defaultRegion | string | Cloudwatch | AWS region | diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 307af1ee15e..b232ee78f27 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -140,7 +140,7 @@ In DingTalk PC Client: 6. There will be a Webhook URL in the panel, looks like this: https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx. Copy this URL to the grafana Dingtalk setting page and then click "finish". -Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `text` message type is supported. +Dingtalk supports the following "message type": `text`, `link` and `markdown`. Only the `link` message type is supported. ### Kafka diff --git a/docs/sources/auth/gitlab.md b/docs/sources/auth/gitlab.md index e3a450f9fc7..56fc3b131a5 100644 --- a/docs/sources/auth/gitlab.md +++ b/docs/sources/auth/gitlab.md @@ -100,12 +100,12 @@ display name, especially if the display name contains spaces or special characters. Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. -Here's a complete example with `alloed_sign_up` enabled, and access limited to +Here's a complete example with `allow_sign_up` enabled, and access limited to the `example` and `foo/bar` groups: ```ini [auth.gitlab] -enabled = false +enabled = true allow_sign_up = true client_id = GITLAB_APPLICATION_ID client_secret = GITLAB_SECRET diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index 7adc6ebe4fb..be36d108475 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -46,7 +46,7 @@ Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGu ## IAM Policies Grafana needs permissions granted via IAM to be able to read CloudWatch metrics -and EC2 tags/instances. You can attach these permissions to IAM roles and +and EC2 tags/instances/regions. You can attach these permissions to IAM roles and utilize Grafana's built-in support for assuming roles. Here is a minimal policy example: @@ -65,11 +65,12 @@ Here is a minimal policy example: "Resource": "*" }, { - "Sid": "AllowReadingTagsFromEC2", + "Sid": "AllowReadingTagsInstancesRegionsFromEC2", "Effect": "Allow", "Action": [ "ec2:DescribeTags", - "ec2:DescribeInstances" + "ec2:DescribeInstances", + "ec2:DescribeRegions" ], "Resource": "*" } diff --git a/docs/sources/features/datasources/stackdriver.md b/docs/sources/features/datasources/stackdriver.md index cea86e96faf..d19dbe4ea50 100644 --- a/docs/sources/features/datasources/stackdriver.md +++ b/docs/sources/features/datasources/stackdriver.md @@ -35,7 +35,9 @@ Grafana ships with built-in support for Google Stackdriver. Just add it as a dat ## Authentication -### Service Account Credentials - Private Key File +There are two ways to authenticate the Stackdriver plugin - either by uploading a Google JWT file, or by automatically retrieving credentials from Google metadata server. The latter option is only available when running Grafana on GCE virtual machine. + +### Using a Google Service Account Key File To authenticate with the Stackdriver API, you need to create a Google Cloud Platform (GCP) Service Account for the Project you want to show data for. A Grafana datasource integrates with one GCP Project. If you want to visualize data from multiple GCP Projects then you need to create one datasource per GCP Project. @@ -74,6 +76,16 @@ Click on the links above and click the `Enable` button: {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_key_uploaded.png" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}} +### Using GCE Default Service Account + +If Grafana is running on a Google Compute Engine (GCE) virtual machine, it is possible for Grafana to automatically retrieve default credentials from the metadata server. This has the advantage of not needing to generate a private key file for the service account and also not having to upload the file to Grafana. However for this to work, there are a few preconditions that need to be met. + +1. First of all, you need to create a Service Account that can be used by the GCE virtual machine. See detailed instructions on how to do that [here](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount). +2. Make sure the GCE virtual machine instance is being run as the service account that you just created. See instructions [here](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#using). +3. Allow access to the `Stackdriver Monitoring API` scope. See instructions [here](changeserviceaccountandscopes). + +Read more about creating and enabling service accounts for GCE VM instances [here](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances). + ## Metric Query Editor {{< docs-imagebox img="/img/docs/v53/stackdriver_query_editor.png" max-width= "400px" class="docs-image--right" >}} @@ -144,6 +156,16 @@ Example Alias By: `{{metric.type}} - {{metric.labels.instance_name}}` Example Result: `compute.googleapis.com/instance/cpu/usage_time - server1-prod` +It is also possible to resolve the name of the Monitored Resource Type. + +| Alias Pattern Format | Description | Example Result | +| ------------------------ | ------------------------------------------------| ---------------- | +| `{{resource.type}}` | returns the name of the monitored resource type | `gce_instance` | + +Example Alias By: `{{resource.type}} - {{metric.type}}` + +Example Result: `gce_instance - compute.googleapis.com/instance/cpu/usage_time` + ## Templating Instead of hard-coding things like server, application and sensor name in you metric queries you can use variables in their place. @@ -194,7 +216,7 @@ Example Result: `monitoring.googleapis.com/uptime_check/http_status has this val It's now possible to configure datasources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) -Here is a provisioning example for this datasource. +Here is a provisioning example using the JWT (Service Account key file) authentication type. ```yaml apiVersion: 1 @@ -206,6 +228,8 @@ datasources: jsonData: tokenUri: https://oauth2.googleapis.com/token clientEmail: stackdriver@myproject.iam.gserviceaccount.com + authenticationType: jwt + defaultProject: my-project-name secureJsonData: privateKey: | -----BEGIN PRIVATE KEY----- @@ -214,3 +238,16 @@ datasources: yA+23427282348234= -----END PRIVATE KEY----- ``` + +Here is a provisioning example using GCE Default Service Account authentication. + +```yaml +apiVersion: 1 + +datasources: + - name: Stackdriver + type: stackdriver + access: proxy + jsonData: + authenticationType: gce +``` diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 2ae2e9dc40d..7ed44572533 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -28,7 +28,7 @@ installation. ```bash wget sudo apt-get install -y adduser libfontconfig -sudo dpkg -i grafana_5.1.4_amd64.deb +sudo dpkg -i grafana__amd64.deb ``` Example: diff --git a/docs/sources/installation/docker.md b/docs/sources/installation/docker.md index ba0d6199ba4..52353ede8c2 100644 --- a/docs/sources/installation/docker.md +++ b/docs/sources/installation/docker.md @@ -87,7 +87,7 @@ docker run \ ## Building a custom Grafana image with pre-installed plugins -In the [grafana-docker](https://github.com/grafana/grafana-docker/) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments. +In the [grafana-docker](https://github.com/grafana/grafana/tree/master/packaging/docker) there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image. It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments. Example of how to build and run: ```bash @@ -103,6 +103,21 @@ docker run \ grafana:latest-with-plugins ``` +## Installing Plugins from other sources + +> Only available in Grafana v5.3.1+ + +It's possible to install plugins from custom url:s by specifying the url like this: `GF_INSTALL_PLUGINS=;` + +```bash +docker run \ + -d \ + -p 3000:3000 \ + --name=grafana \ + -e "GF_INSTALL_PLUGINS=http://plugin-domain.com/my-custom-plugin.zip;custom-plugin" \ + grafana/grafana +``` + ## Configuring AWS Credentials for CloudWatch Support ```bash diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index ea75b9797e8..eed05f05fa6 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -13,7 +13,7 @@ dev environment. Grafana ships with its own required backend server; also comple ## Dependencies -- [Go 1.11](https://golang.org/dl/) +- [Go (Latest Stable)](https://golang.org/dl/) - [Git](https://git-scm.com/downloads) - [NodeJS LTS](https://nodejs.org/download/) - node-gyp is the Node.js native addon build tool and it requires extra dependencies: python 2.7, make and GCC. These are already installed for most Linux distros and MacOS. See the Building On Windows section or the [node-gyp installation instructions](https://github.com/nodejs/node-gyp#installation) for more details. diff --git a/docs/sources/tutorials/index.md b/docs/sources/tutorials/index.md index cb11940c6dd..90410e901d3 100644 --- a/docs/sources/tutorials/index.md +++ b/docs/sources/tutorials/index.md @@ -1,5 +1,6 @@ +++ title = "Tutorials" +type = "docs" [menu.docs] identifier = "tutorials" weight = 6 @@ -11,7 +12,11 @@ This section of the docs contains a series for tutorials and stack setup guides. ## Articles -- [How to integrate Hubot with Grafana](hubot_howto.md) +- [Running Grafana behind a reverse proxy]({{< relref "behind_proxy.md" >}}) +- [API Tutorial: How To Create API Tokens And Dashboards For A Specific Organization]({{< relref "api_org_token_howto.md" >}}) +- [How to Use IIS with URL Rewrite as a Reverse Proxy for Grafana on Windows]({{< relref "iis.md" >}}) +- [How to integrate Hubot with Grafana]({{< relref "hubot_howto.md" >}}) +- [How to setup Grafana for high availability]({{< relref "ha_setup.md" >}}) ## External links diff --git a/latest.json b/latest.json index 4355e9a64b7..992af0a8336 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "5.3.1", - "testing": "5.3.1" + "stable": "5.3.2", + "testing": "5.3.2" } diff --git a/package.json b/package.json index 0517c0d5ba5..b4c70c5b3a8 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ "react-redux": "^5.0.7", "react-select": "2.1.0", "react-sizeme": "^2.3.6", + "react-table": "^6.8.6", "react-transition-group": "^2.2.1", "redux": "^4.0.0", "redux-logger": "^3.0.6", diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index 890d6a4fb11..dc8972b0ba0 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -25,7 +25,7 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi WORKDIR $GF_PATHS_HOME -RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates && \ +RUN apt-get update && apt-get install -qq -y libfontconfig ca-certificates curl && \ apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh new file mode 100755 index 00000000000..f716a1f44f1 --- /dev/null +++ b/packaging/docker/build-enterprise.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +_grafana_tag=$1 +_docker_repo=${2:-grafana/grafana-enterprise} + +docker build \ + --tag "${_docker_repo}:${_grafana_tag}"\ + --no-cache=true \ + . diff --git a/packaging/release_process.md b/packaging/release_process.md deleted file mode 100644 index 6037a9c499c..00000000000 --- a/packaging/release_process.md +++ /dev/null @@ -1,29 +0,0 @@ -# New Grafana Release Processes - -## Building release packages - -1) Update package.json so that it has the right version. -2) Create a git tag for the release: `git tag -a v3.0.4 -m "3.0.4 release"` -3) Push branch & tag to github! -2) Packages from master a built automatically by circle CI for this repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer) - -### Non master branch - -When building from non master branch create a new branch in repo [grafana/grafana-packer](https://github.com/grafana/grafana-packer) -and configure circle.yml to deploy that branch as well, https://github.com/grafana/grafana-packer/blob/master/circle.yml#L25, -you also need to update https://github.com/grafana/grafana-packer/blob/v3.1.x/deploy.sh#L7. - -### Windows build - -Sign into ci.appveyor.com and the Grafana project's build history page. Builds for windows take a long time (around 20min) -and fail quite often for random reasons so I usually continue with the release process without a windows build already built. - -1) Click on the green build that has the correct version and tag -2) Click on `DEPLOYMENTS` -3) Click on `NEW DEPLOYMENT` -4) Select GrafanaBuildS3 -4) Select the build you want to deploy. - -The deployment should be quick (just uploads the release zip file to S3) - - diff --git a/pkg/api/api.go b/pkg/api/api.go index 5c5596d5da2..f1fe940e416 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -234,13 +234,13 @@ func (hs *HTTPServer) registerRoutes() { datasourceRoute.Get("/", Wrap(GetDataSources)) datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource)) datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource)) - datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceByID)) + datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById)) datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName)) - datasourceRoute.Get("/:id", Wrap(GetDataSourceByID)) + datasourceRoute.Get("/:id", Wrap(GetDataSourceById)) datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName)) }, reqOrgAdmin) - apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIDByName), reqSignedIn) + apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIdByName), reqSignedIn) apiRoute.Get("/plugins", Wrap(GetPluginList)) apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID)) @@ -251,7 +251,7 @@ func (hs *HTTPServer) registerRoutes() { pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) }, reqOrgAdmin) - apiRoute.Get("/frontend/settings/", GetFrontendSettings) + apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings) apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index eddfb884f8f..3bb2f236129 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "github.com/pkg/errors" "time" "github.com/grafana/grafana/pkg/api/pluginproxy" @@ -14,6 +15,20 @@ import ( const HeaderNameNoBackendCache = "X-Grafana-NoCache" func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) { + userPermissionsQuery := m.GetDataSourcePermissionsForUserQuery{ + User: c.SignedInUser, + } + if err := bus.Dispatch(&userPermissionsQuery); err != nil { + if err != bus.ErrHandlerNotFound { + return nil, err + } + } else { + permissionType, exists := userPermissionsQuery.Result[id] + if exists && permissionType != m.DsPermissionQuery { + return nil, errors.New("User not allowed to access datasource") + } + } + nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true" cacheKey := fmt.Sprintf("ds-%d", id) @@ -38,7 +53,9 @@ func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.Data func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) { c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer) - ds, err := hs.getDatasourceFromCache(c.ParamsInt64(":id"), c) + dsId := c.ParamsInt64(":id") + ds, err := hs.getDatasourceFromCache(dsId, c) + if err != nil { c.JsonApiErr(500, "Unable to load datasource meta data", err) return diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 23dbb221d71..e7614614076 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -20,8 +20,8 @@ func GetDataSources(c *m.ReqContext) Response { result := make(dtos.DataSourceList, 0) for _, ds := range query.Result { dsItem := dtos.DataSourceListItemDTO{ - Id: ds.Id, OrgId: ds.OrgId, + Id: ds.Id, Name: ds.Name, Url: ds.Url, Type: ds.Type, @@ -49,7 +49,7 @@ func GetDataSources(c *m.ReqContext) Response { return JSON(200, &result) } -func GetDataSourceByID(c *m.ReqContext) Response { +func GetDataSourceById(c *m.ReqContext) Response { query := m.GetDataSourceByIdQuery{ Id: c.ParamsInt64(":id"), OrgId: c.OrgId, @@ -68,14 +68,14 @@ func GetDataSourceByID(c *m.ReqContext) Response { return JSON(200, &dtos) } -func DeleteDataSourceByID(c *m.ReqContext) Response { +func DeleteDataSourceById(c *m.ReqContext) Response { id := c.ParamsInt64(":id") if id <= 0 { return Error(400, "Missing valid datasource id", nil) } - ds, err := getRawDataSourceByID(id, c.OrgId) + ds, err := getRawDataSourceById(id, c.OrgId) if err != nil { return Error(400, "Failed to delete datasource", nil) } @@ -186,7 +186,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error { return nil } - ds, err := getRawDataSourceByID(cmd.Id, cmd.OrgId) + ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId) if err != nil { return err } @@ -206,7 +206,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error { return nil } -func getRawDataSourceByID(id int64, orgID int64) (*m.DataSource, error) { +func getRawDataSourceById(id int64, orgID int64) (*m.DataSource, error) { query := m.GetDataSourceByIdQuery{ Id: id, OrgId: orgID, @@ -236,7 +236,7 @@ func GetDataSourceByName(c *m.ReqContext) Response { } // Get /api/datasources/id/:name -func GetDataSourceIDByName(c *m.ReqContext) Response { +func GetDataSourceIdByName(c *m.ReqContext) Response { query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index 697d0a35a08..c037831f341 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -49,28 +49,30 @@ func formatShort(interval time.Duration) string { func NewAlertNotification(notification *models.AlertNotification) *AlertNotification { return &AlertNotification{ - Id: notification.Id, - Name: notification.Name, - Type: notification.Type, - IsDefault: notification.IsDefault, - Created: notification.Created, - Updated: notification.Updated, - Frequency: formatShort(notification.Frequency), - SendReminder: notification.SendReminder, - Settings: notification.Settings, + Id: notification.Id, + Name: notification.Name, + Type: notification.Type, + IsDefault: notification.IsDefault, + Created: notification.Created, + Updated: notification.Updated, + Frequency: formatShort(notification.Frequency), + SendReminder: notification.SendReminder, + DisableResolveMessage: notification.DisableResolveMessage, + Settings: notification.Settings, } } type AlertNotification struct { - Id int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsDefault bool `json:"isDefault"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Settings *simplejson.Json `json:"settings"` + Id int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsDefault bool `json:"isDefault"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Settings *simplejson.Json `json:"settings"` } type AlertTestCommand struct { @@ -100,11 +102,12 @@ type EvalMatch struct { } type NotificationTestCommand struct { - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - Settings *simplejson.Json `json:"settings"` + Name string `json:"name"` + Type string `json:"type"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + Settings *simplejson.Json `json:"settings"` } type PauseAlertCommand struct { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index a58be38781e..1de65c7b2ce 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) { +func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) { orgDataSources := make([]*m.DataSource, 0) if c.OrgId != 0 { @@ -22,7 +22,20 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) { return nil, err } - orgDataSources = query.Result + dsFilterQuery := m.DatasourcesPermissionFilterQuery{ + User: c.SignedInUser, + Datasources: query.Result, + } + + if err := bus.Dispatch(&dsFilterQuery); err != nil { + if err != bus.ErrHandlerNotFound { + return nil, err + } + + orgDataSources = query.Result + } else { + orgDataSources = dsFilterQuery.Result + } } datasources := make(map[string]interface{}) @@ -120,6 +133,10 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) { panels := map[string]interface{}{} for _, panel := range enabledPlugins.Panels { + if panel.State == "alpha" && !hs.Cfg.EnableAlphaPanels { + continue + } + panels[panel.Id] = map[string]interface{}{ "module": panel.Module, "baseUrl": panel.BaseUrl, @@ -183,8 +200,8 @@ func getPanelSort(id string) int { return sort } -func GetFrontendSettings(c *m.ReqContext) { - settings, err := getFrontendSettingsMap(c) +func (hs *HTTPServer) GetFrontendSettings(c *m.ReqContext) { + settings, err := hs.getFrontendSettingsMap(c) if err != nil { c.JsonApiErr(400, "Failed to get frontend settings", err) return diff --git a/pkg/api/index.go b/pkg/api/index.go index 9f867d51cad..e61620f9586 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -18,7 +18,7 @@ const ( ) func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) { - settings, err := getFrontendSettingsMap(c) + settings, err := hs.getFrontendSettingsMap(c) if err != nil { return nil, err } diff --git a/pkg/api/pluginproxy/ds_auth_provider.go b/pkg/api/pluginproxy/ds_auth_provider.go index c68da839d13..edf43085c7c 100644 --- a/pkg/api/pluginproxy/ds_auth_provider.go +++ b/pkg/api/pluginproxy/ds_auth_provider.go @@ -12,6 +12,7 @@ import ( m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/util" + "golang.org/x/oauth2/google" ) //ApplyRoute should use the plugin route data to set auth headers and custom headers @@ -54,15 +55,30 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route } } - if route.JwtTokenAuth != nil { + authenticationType := ds.JsonData.Get("authenticationType").MustString("jwt") + if route.JwtTokenAuth != nil && authenticationType == "jwt" { if token, err := tokenProvider.getJwtAccessToken(ctx, data); err != nil { logger.Error("Failed to get access token", "error", err) } else { req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) } } - logger.Info("Requesting", "url", req.URL.String()) + if authenticationType == "gce" { + tokenSrc, err := google.DefaultTokenSource(ctx, route.JwtTokenAuth.Scopes...) + if err != nil { + logger.Error("Failed to get default token from meta data server", "error", err) + } else { + token, err := tokenSrc.Token() + if err != nil { + logger.Error("Failed to get default access token from meta data server", "error", err) + } else { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + } + } + } + + logger.Info("Requesting", "url", req.URL.String()) } func interpolateString(text string, data templateData) (string, error) { diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index 06c07a2887c..4f10ad7a60a 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -3,6 +3,8 @@ package main import ( "flag" "fmt" + "net/http" + _ "net/http/pprof" "os" "os/signal" "runtime" @@ -11,16 +13,12 @@ import ( "syscall" "time" - "net/http" - _ "net/http/pprof" - + extensions "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" - "github.com/grafana/grafana/pkg/setting" - - extensions "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/services/alerting/conditions" _ "github.com/grafana/grafana/pkg/services/alerting/notifiers" + "github.com/grafana/grafana/pkg/setting" _ "github.com/grafana/grafana/pkg/tsdb/cloudwatch" _ "github.com/grafana/grafana/pkg/tsdb/elasticsearch" _ "github.com/grafana/grafana/pkg/tsdb/graphite" @@ -35,6 +33,7 @@ import ( var version = "5.0.0" var commit = "NA" +var buildBranch = "master" var buildstamp string var configFile = flag.String("config", "", "path to config file") @@ -47,7 +46,7 @@ func main() { profilePort := flag.Int("profile-port", 6060, "Define custom port for profiling") flag.Parse() if *v { - fmt.Printf("Version %s (commit: %s)\n", version, commit) + fmt.Printf("Version %s (commit: %s, branch: %s)\n", version, commit, buildBranch) os.Exit(0) } @@ -78,6 +77,7 @@ func main() { setting.BuildVersion = version setting.BuildCommit = commit setting.BuildStamp = buildstampInt64 + setting.BuildBranch = buildBranch setting.IsEnterprise = extensions.IsEnterprise metrics.M_Grafana_Version.WithLabelValues(version).Set(1) diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 8794d7d8338..765b8ddf993 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -12,24 +12,16 @@ import ( "time" "github.com/facebookgo/inject" + "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/registry" - - "golang.org/x/sync/errgroup" - - "github.com/grafana/grafana/pkg/api" + _ "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/login" - "github.com/grafana/grafana/pkg/setting" - - "github.com/grafana/grafana/pkg/social" - - // self registering services - _ "github.com/grafana/grafana/pkg/extensions" _ "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/middleware" _ "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry" _ "github.com/grafana/grafana/pkg/services/alerting" _ "github.com/grafana/grafana/pkg/services/cleanup" _ "github.com/grafana/grafana/pkg/services/notifications" @@ -37,7 +29,10 @@ import ( _ "github.com/grafana/grafana/pkg/services/rendering" _ "github.com/grafana/grafana/pkg/services/search" _ "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/social" // self registering services _ "github.com/grafana/grafana/pkg/tracing" + "golang.org/x/sync/errgroup" ) func NewGrafanaServer() *GrafanaServerImpl { @@ -159,7 +154,7 @@ func (g *GrafanaServerImpl) loadConfiguration() { os.Exit(1) } - g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "compiled", time.Unix(setting.BuildStamp, 0)) + g.log.Info("Starting "+setting.ApplicationName, "version", version, "commit", commit, "branch", buildBranch, "compiled", time.Unix(setting.BuildStamp, 0)) g.cfg.LogConfigSources() } diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go index 43f45f900d9..d4e81d2bd46 100644 --- a/pkg/login/ldap.go +++ b/pkg/login/ldap.go @@ -185,7 +185,9 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo if ldapUser.isMemberOf(group.GroupDN) { extUser.OrgRoles[group.OrgId] = group.OrgRole - extUser.IsGrafanaAdmin = group.IsGrafanaAdmin + if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin { + extUser.IsGrafanaAdmin = group.IsGrafanaAdmin + } } } diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 3e83a60f94b..7b29901c1a3 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -43,12 +43,13 @@ func GetContextHandler() macaron.Handler { // then init session and look for userId in session // then look for api key in session (special case for render calls via api) // then test if anonymous access is enabled - if initContextWithRenderAuth(ctx) || - initContextWithApiKey(ctx) || - initContextWithBasicAuth(ctx, orgId) || - initContextWithAuthProxy(ctx, orgId) || - initContextWithUserSessionCookie(ctx, orgId) || - initContextWithAnonymousUser(ctx) { + switch { + case initContextWithRenderAuth(ctx): + case initContextWithApiKey(ctx): + case initContextWithBasicAuth(ctx, orgId): + case initContextWithAuthProxy(ctx, orgId): + case initContextWithUserSessionCookie(ctx, orgId): + case initContextWithAnonymousUser(ctx): } ctx.Logger = log.New("context", "userId", ctx.UserId, "orgId", ctx.OrgId, "uname", ctx.Login) diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go index 2128b469fa4..e0fd12937ed 100644 --- a/pkg/models/alert_notifications.go +++ b/pkg/models/alert_notifications.go @@ -23,38 +23,41 @@ var ( ) type AlertNotification struct { - Id int64 `json:"id"` - OrgId int64 `json:"-"` - Name string `json:"name"` - Type string `json:"type"` - SendReminder bool `json:"sendReminder"` - Frequency time.Duration `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + Id int64 `json:"id"` + OrgId int64 `json:"-"` + Name string `json:"name"` + Type string `json:"type"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency time.Duration `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` } type CreateAlertNotificationCommand struct { - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings"` + Name string `json:"name" binding:"Required"` + Type string `json:"type" binding:"Required"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings"` OrgId int64 `json:"-"` Result *AlertNotification } type UpdateAlertNotificationCommand struct { - Id int64 `json:"id" binding:"Required"` - Name string `json:"name" binding:"Required"` - Type string `json:"type" binding:"Required"` - SendReminder bool `json:"sendReminder"` - Frequency string `json:"frequency"` - IsDefault bool `json:"isDefault"` - Settings *simplejson.Json `json:"settings" binding:"Required"` + Id int64 `json:"id" binding:"Required"` + Name string `json:"name" binding:"Required"` + Type string `json:"type" binding:"Required"` + SendReminder bool `json:"sendReminder"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Frequency string `json:"frequency"` + IsDefault bool `json:"isDefault"` + Settings *simplejson.Json `json:"settings" binding:"Required"` OrgId int64 `json:"-"` Result *AlertNotification diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index c730622512f..b71d17ec0d1 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -30,6 +30,7 @@ var ( ErrDataSourceNameExists = errors.New("Data source with same name already exists") ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource") ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration.") + ErrDataSourceAccessDenied = errors.New("Data source access denied") ) type DsAccess string @@ -167,6 +168,7 @@ type DeleteDataSourceByNameCommand struct { type GetDataSourcesQuery struct { OrgId int64 + User *SignedInUser Result []*DataSource } @@ -187,6 +189,31 @@ type GetDataSourceByNameQuery struct { } // --------------------- -// EVENTS -type DataSourceCreatedEvent struct { +// Permissions +// --------------------- + +type DsPermissionType int + +const ( + DsPermissionNoAccess DsPermissionType = iota + DsPermissionQuery +) + +func (p DsPermissionType) String() string { + names := map[int]string{ + int(DsPermissionQuery): "Query", + int(DsPermissionNoAccess): "No Access", + } + return names[int(p)] +} + +type GetDataSourcePermissionsForUserQuery struct { + User *SignedInUser + Result map[int64]DsPermissionType +} + +type DatasourcesPermissionFilterQuery struct { + User *SignedInUser + Datasources []*DataSource + Result []*DataSource } diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 67eb0f51d70..4f15441bb2f 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -121,7 +121,6 @@ func (pm *PluginManager) Run(ctx context.Context) error { pm.checkForUpdates() case <-ctx.Done(): run = false - break } } diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 96294f0624f..040d0991861 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -27,6 +27,7 @@ type Notifier interface { GetNotifierId() int64 GetIsDefault() bool GetSendReminder() bool + GetDisableResolveMessage() bool GetFrequency() time.Duration } diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index fbade2eccac..d141d6cd257 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/alerting" ) @@ -15,13 +14,14 @@ const ( ) type NotifierBase struct { - Name string - Type string - Id int64 - IsDeault bool - UploadImage bool - SendReminder bool - Frequency time.Duration + Name string + Type string + Id int64 + IsDeault bool + UploadImage bool + SendReminder bool + DisableResolveMessage bool + Frequency time.Duration log log.Logger } @@ -34,14 +34,15 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase { } return NotifierBase{ - Id: model.Id, - Name: model.Name, - IsDeault: model.IsDefault, - Type: model.Type, - UploadImage: uploadImage, - SendReminder: model.SendReminder, - Frequency: model.Frequency, - log: log.New("alerting.notifier." + model.Name), + Id: model.Id, + Name: model.Name, + IsDeault: model.IsDefault, + Type: model.Type, + UploadImage: uploadImage, + SendReminder: model.SendReminder, + DisableResolveMessage: model.DisableResolveMessage, + Frequency: model.Frequency, + log: log.New("alerting.notifier." + model.Name), } } @@ -83,6 +84,11 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalC } } + // Do not notify when state is OK if DisableResolveMessage is set to true + if context.Rule.State == models.AlertStateOK && n.DisableResolveMessage { + return false + } + return true } @@ -106,6 +112,10 @@ func (n *NotifierBase) GetSendReminder() bool { return n.SendReminder } +func (n *NotifierBase) GetDisableResolveMessage() bool { + return n.DisableResolveMessage +} + func (n *NotifierBase) GetFrequency() time.Duration { return n.Frequency } diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 5e46d3ad72e..5062828cb4f 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -179,5 +179,10 @@ func TestBaseNotifier(t *testing.T) { base := NewNotifierBase(model) So(base.UploadImage, ShouldBeTrue) }) + + Convey("default value should be false for backwards compatibility", func() { + base := NewNotifierBase(model) + So(base.DisableResolveMessage, ShouldBeFalse) + }) }) } diff --git a/pkg/services/alerting/notifiers/dingding.go b/pkg/services/alerting/notifiers/dingding.go index 738e43af2d2..1ef085c82f1 100644 --- a/pkg/services/alerting/notifiers/dingding.go +++ b/pkg/services/alerting/notifiers/dingding.go @@ -57,6 +57,9 @@ func (this *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error { message := evalContext.Rule.Message picUrl := evalContext.ImagePublicUrl title := evalContext.GetNotificationTitle() + if message == "" { + message = title + } bodyJSON, err := simplejson.NewJson([]byte(`{ "msgtype": "link", diff --git a/pkg/services/alerting/reader.go b/pkg/services/alerting/reader.go index 627159c286b..2cdbc57b41d 100644 --- a/pkg/services/alerting/reader.go +++ b/pkg/services/alerting/reader.go @@ -34,11 +34,8 @@ func NewRuleReader() *DefaultRuleReader { func (arr *DefaultRuleReader) initReader() { heartbeat := time.NewTicker(time.Second * 10) - for { - select { - case <-heartbeat.C: - arr.heartbeat() - } + for range heartbeat.C { + arr.heartbeat() } } diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go index 7506338c5f0..366bc90fc37 100644 --- a/pkg/services/guardian/guardian.go +++ b/pkg/services/guardian/guardian.go @@ -40,7 +40,7 @@ var New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardia user: user, dashId: dashId, orgId: orgId, - log: log.New("guardians.dashboard"), + log: log.New("dashboard.permissions"), } } @@ -66,15 +66,30 @@ func (g *dashboardGuardianImpl) CanAdmin() (bool, error) { func (g *dashboardGuardianImpl) HasPermission(permission m.PermissionType) (bool, error) { if g.user.OrgRole == m.ROLE_ADMIN { - return true, nil + return g.logHasPermissionResult(permission, true, nil) } acl, err := g.GetAcl() if err != nil { - return false, err + return g.logHasPermissionResult(permission, false, err) } - return g.checkAcl(permission, acl) + result, err := g.checkAcl(permission, acl) + return g.logHasPermissionResult(permission, result, err) +} + +func (g *dashboardGuardianImpl) logHasPermissionResult(permission m.PermissionType, hasPermission bool, err error) (bool, error) { + if err != nil { + return hasPermission, err + } + + if hasPermission { + g.log.Debug("User granted access to execute action", "userId", g.user.UserId, "orgId", g.orgId, "uname", g.user.Login, "dashId", g.dashId, "action", permission) + } else { + g.log.Debug("User denied access to execute action", "userId", g.user.UserId, "orgId", g.orgId, "uname", g.user.Login, "dashId", g.dashId, "action", permission) + } + + return hasPermission, err } func (g *dashboardGuardianImpl) checkAcl(permission m.PermissionType, acl []*m.DashboardAclInfoDTO) (bool, error) { diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index daaef945b96..afe6269510f 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -66,6 +66,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro alert_notification.updated, alert_notification.settings, alert_notification.is_default, + alert_notification.disable_resolve_message, alert_notification.send_reminder, alert_notification.frequency FROM alert_notification @@ -106,6 +107,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS alert_notification.updated, alert_notification.settings, alert_notification.is_default, + alert_notification.disable_resolve_message, alert_notification.send_reminder, alert_notification.frequency FROM alert_notification @@ -166,15 +168,16 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error } alertNotification := &m.AlertNotification{ - OrgId: cmd.OrgId, - Name: cmd.Name, - Type: cmd.Type, - Settings: cmd.Settings, - SendReminder: cmd.SendReminder, - Frequency: frequency, - Created: time.Now(), - Updated: time.Now(), - IsDefault: cmd.IsDefault, + OrgId: cmd.OrgId, + Name: cmd.Name, + Type: cmd.Type, + Settings: cmd.Settings, + SendReminder: cmd.SendReminder, + DisableResolveMessage: cmd.DisableResolveMessage, + Frequency: frequency, + Created: time.Now(), + Updated: time.Now(), + IsDefault: cmd.IsDefault, } if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil { @@ -210,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { current.Type = cmd.Type current.IsDefault = cmd.IsDefault current.SendReminder = cmd.SendReminder + current.DisableResolveMessage = cmd.DisableResolveMessage if current.SendReminder { if cmd.Frequency == "" { @@ -224,7 +228,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { current.Frequency = frequency } - sess.UseBool("is_default", "send_reminder") + sess.UseBool("is_default", "send_reminder", "disable_resolve_message") if affected, err := sess.ID(cmd.Id).Update(current); err != nil { return err diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go index ed682bae5c6..629a6292eb5 100644 --- a/pkg/services/sqlstore/alert_notification_test.go +++ b/pkg/services/sqlstore/alert_notification_test.go @@ -219,6 +219,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) { So(cmd.Result.OrgId, ShouldNotEqual, 0) So(cmd.Result.Type, ShouldEqual, "email") So(cmd.Result.Frequency, ShouldEqual, 10*time.Second) + So(cmd.Result.DisableResolveMessage, ShouldBeFalse) Convey("Cannot save Alert Notification with the same name", func() { err = CreateAlertNotificationCommand(cmd) @@ -227,18 +228,20 @@ func TestAlertNotificationSQLAccess(t *testing.T) { Convey("Can update alert notification", func() { newCmd := &models.UpdateAlertNotificationCommand{ - Name: "NewName", - Type: "webhook", - OrgId: cmd.Result.OrgId, - SendReminder: true, - Frequency: "60s", - Settings: simplejson.New(), - Id: cmd.Result.Id, + Name: "NewName", + Type: "webhook", + OrgId: cmd.Result.OrgId, + SendReminder: true, + DisableResolveMessage: true, + Frequency: "60s", + Settings: simplejson.New(), + Id: cmd.Result.Id, } err := UpdateAlertNotification(newCmd) So(err, ShouldBeNil) So(newCmd.Result.Name, ShouldEqual, "NewName") So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second) + So(newCmd.Result.DisableResolveMessage, ShouldBeTrue) }) Convey("Can update alert notification to disable sending of reminders", func() { diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index e43279208e7..1b853d17b5f 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -320,13 +320,18 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error { "DELETE FROM dashboard WHERE id = ?", "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", "DELETE FROM dashboard_version WHERE dashboard_id = ?", - "DELETE FROM dashboard WHERE folder_id = ?", "DELETE FROM annotation WHERE dashboard_id = ?", "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", } + if dashboard.IsFolder { + deletes = append(deletes, "DELETE FROM dashboard_provisioning WHERE dashboard_id in (select id from dashboard where folder_id = ?)") + deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ?") + } + for _, sql := range deletes { _, err := sess.Exec(sql, dashboard.Id) + if err != nil { return err } diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/sqlstore/dashboard_provisioning_test.go index 7ef45df3152..1b7a3976727 100644 --- a/pkg/services/sqlstore/dashboard_provisioning_test.go +++ b/pkg/services/sqlstore/dashboard_provisioning_test.go @@ -13,17 +13,30 @@ func TestDashboardProvisioningTest(t *testing.T) { Convey("Testing Dashboard provisioning", t, func() { InitTestDB(t) - saveDashboardCmd := &models.SaveDashboardCommand{ + folderCmd := &models.SaveDashboardCommand{ OrgId: 1, FolderId: 0, - IsFolder: false, + IsFolder: true, Dashboard: simplejson.NewFromAny(map[string]interface{}{ "id": nil, "title": "test dashboard", }), } - Convey("Saving dashboards with extras", func() { + err := SaveDashboard(folderCmd) + So(err, ShouldBeNil) + + saveDashboardCmd := &models.SaveDashboardCommand{ + OrgId: 1, + IsFolder: false, + FolderId: folderCmd.Result.Id, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test dashboard", + }), + } + + Convey("Saving dashboards with provisioning meta data", func() { now := time.Now() cmd := &models.SaveProvisionedDashboardCommand{ @@ -67,6 +80,21 @@ func TestDashboardProvisioningTest(t *testing.T) { So(err, ShouldBeNil) So(query.Result, ShouldBeFalse) }) + + Convey("Deleteing folder should delete provision meta data", func() { + deleteCmd := &models.DeleteDashboardCommand{ + Id: folderCmd.Result.Id, + OrgId: 1, + } + + So(DeleteDashboard(deleteCmd), ShouldBeNil) + + query := &models.IsDashboardProvisionedQuery{DashboardId: cmd.Result.Id} + + err = GetProvisionedDataByDashboardId(query) + So(err, ShouldBeNil) + So(query.Result, ShouldBeFalse) + }) }) }) } diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 00d520bcfc6..7f70e5c25fc 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -27,6 +27,7 @@ func GetDataSourceById(query *m.GetDataSourceByIdQuery) error { datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id} has, err := x.Get(&datasource) + if err != nil { return err } diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index cadcccf6c95..198a47b50ff 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -71,6 +71,9 @@ func addAlertMigrations(mg *Migrator) { mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{ Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0", })) + mg.AddMigration("Add column disable_resolve_message", NewAddColumnMigration(alert_notification, &Column{ + Name: "disable_resolve_message", Type: DB_Bool, Nullable: false, Default: "0", + })) mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0])) diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 67f1bd7f75a..f904b44c3c8 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -53,6 +53,7 @@ type SqlStore struct { dbCfg DatabaseConfig engine *xorm.Engine log log.Logger + Dialect migrator.Dialect skipEnsureAdmin bool } @@ -125,10 +126,12 @@ func (ss *SqlStore) Init() error { } ss.engine = engine + ss.Dialect = migrator.NewDialect(ss.engine) // temporarily still set global var x = engine - dialect = migrator.NewDialect(x) + dialect = ss.Dialect + migrator := migrator.NewMigrator(x) migrations.AddMigrations(migrator) @@ -347,7 +350,11 @@ func InitTestDB(t *testing.T) *SqlStore { t.Fatalf("Failed to init test database: %v", err) } - dialect = migrator.NewDialect(engine) + sqlstore.Dialect = migrator.NewDialect(engine) + + // temp global var until we get rid of global vars + dialect = sqlstore.Dialect + if err := dialect.CleanDB(); err != nil { t.Fatalf("Failed to clean test db %v", err) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 58901e55c6b..077822a37a4 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -13,15 +13,12 @@ import ( "regexp" "runtime" "strings" - - "gopkg.in/ini.v1" - - "github.com/go-macaron/session" - "time" + "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/util" + "gopkg.in/ini.v1" ) type Scheme string @@ -49,6 +46,7 @@ var ( // build BuildVersion string BuildCommit string + BuildBranch string BuildStamp int64 IsEnterprise bool ApplicationName string @@ -213,6 +211,8 @@ type Cfg struct { TempDataLifetime time.Duration MetricsEndpointEnabled bool + + EnableAlphaPanels bool } type CommandLineArgs struct { @@ -694,6 +694,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { explore := iniFile.Section("explore") ExploreEnabled = explore.Key("enabled").MustBool(false) + panels := iniFile.Section("panels") + cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false) + cfg.readSessionConfig() cfg.readSmtpSettings() cfg.readQuotaSettings() diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 61bbc04394a..437457df52a 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -86,9 +86,10 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc } func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { - result := &tsdb.Response{ + results := &tsdb.Response{ Results: make(map[string]*tsdb.QueryResult), } + resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries)) eg, ectx := errgroup.WithContext(ctx) @@ -102,10 +103,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo RefId := queryContext.Queries[i].RefId query, err := parseQuery(queryContext.Queries[i].Model) if err != nil { - result.Results[RefId] = &tsdb.QueryResult{ + results.Results[RefId] = &tsdb.QueryResult{ Error: err, } - return result, nil + return results, nil } query.RefId = RefId @@ -118,10 +119,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo } if query.Id == "" && query.Expression != "" { - result.Results[query.RefId] = &tsdb.QueryResult{ + results.Results[query.RefId] = &tsdb.QueryResult{ Error: fmt.Errorf("Invalid query: id should be set if using expression"), } - return result, nil + return results, nil } eg.Go(func() error { @@ -130,12 +131,13 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo return err } if err != nil { - result.Results[query.RefId] = &tsdb.QueryResult{ + resultChan <- &tsdb.QueryResult{ + RefId: query.RefId, Error: err, } return nil } - result.Results[queryRes.RefId] = queryRes + resultChan <- queryRes return nil }) } @@ -149,10 +151,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo return err } for _, queryRes := range queryResponses { - result.Results[queryRes.RefId] = queryRes if err != nil { - result.Results[queryRes.RefId].Error = err + queryRes.Error = err } + resultChan <- queryRes } return nil }) @@ -162,8 +164,12 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo if err := eg.Wait(); err != nil { return nil, err } + close(resultChan) + for result := range resultChan { + results.Results[result.RefId] = result + } - return result, nil + return results, nil } func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) { diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index ee9d9583c4e..718f9e0d253 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -35,6 +35,7 @@ type CustomMetricsCache struct { var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache +var regionCache sync.Map func init() { metricsMap = map[string][]string{ @@ -233,15 +234,50 @@ func parseMultiSelectValue(input string) []string { // Whenever this list is updated, frontend list should also be updated. // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { - regions := []string{ - "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "ca-central-1", "cn-north-1", "cn-northwest-1", - "eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2", "us-isob-east-1", "us-iso-east-1", + dsInfo := e.getDsInfo("default") + profile := dsInfo.Profile + if cache, ok := regionCache.Load(profile); ok { + if cache2, ok2 := cache.([]suggestData); ok2 { + return cache2, nil + } } + regions := []string{ + "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1", + "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2", + "cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1", + } + err := e.ensureClientSession("default") + if err != nil { + return nil, err + } + r, err := e.ec2Svc.DescribeRegions(&ec2.DescribeRegionsInput{}) + if err != nil { + // ignore error for backward compatibility + plog.Error("Failed to get regions", "error", err) + } else { + for _, region := range r.Regions { + exists := false + + for _, existingRegion := range regions { + if existingRegion == *region.RegionName { + exists = true + break + } + } + + if !exists { + regions = append(regions, *region.RegionName) + } + } + } + sort.Strings(regions) + result := make([]suggestData, 0) for _, region := range regions { result = append(result, suggestData{Text: region, Value: region}) } + regionCache.Store(profile, result) return result, nil } diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index e3903e8027e..34c3379b4df 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -9,20 +9,26 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" "github.com/bmizerany/assert" + "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" ) type mockedEc2 struct { ec2iface.EC2API - Resp ec2.DescribeInstancesOutput + Resp ec2.DescribeInstancesOutput + RespRegions ec2.DescribeRegionsOutput } func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error { fn(&m.Resp, true) return nil } +func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeRegionsOutput, error) { + return &m.RespRegions, nil +} func TestCloudWatchMetrics(t *testing.T) { @@ -82,6 +88,31 @@ func TestCloudWatchMetrics(t *testing.T) { }) }) + Convey("When calling handleGetRegions", t, func() { + executor := &CloudWatchExecutor{ + ec2Svc: mockedEc2{RespRegions: ec2.DescribeRegionsOutput{ + Regions: []*ec2.Region{ + { + RegionName: aws.String("ap-northeast-2"), + }, + }, + }}, + } + jsonData := simplejson.New() + jsonData.Set("defaultRegion", "default") + executor.DataSource = &models.DataSource{ + JsonData: jsonData, + SecureJsonData: securejsondata.SecureJsonData{}, + } + + result, _ := executor.handleGetRegions(context.Background(), simplejson.New(), &tsdb.TsdbQuery{}) + + Convey("Should return regions", func() { + So(result[0].Text, ShouldEqual, "ap-northeast-1") + So(result[1].Text, ShouldEqual, "ap-northeast-2") + }) + }) + Convey("When calling handleGetEc2InstanceAttribute", t, func() { executor := &CloudWatchExecutor{ ec2Svc: mockedEc2{Resp: ec2.DescribeInstancesOutput{ diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index 2960ba0edc4..ff0ed8d0620 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -164,14 +164,12 @@ func formatTimeRange(input string) string { func fixIntervalFormat(target string) string { rMinute := regexp.MustCompile(`'(\d+)m'`) - rMin := regexp.MustCompile("m") target = rMinute.ReplaceAllStringFunc(target, func(m string) string { - return rMin.ReplaceAllString(m, "min") + return strings.Replace(m, "m", "min", -1) }) rMonth := regexp.MustCompile(`'(\d+)M'`) - rMon := regexp.MustCompile("M") target = rMonth.ReplaceAllStringFunc(target, func(M string) string { - return rMon.ReplaceAllString(M, "mon") + return strings.Replace(M, "M", "mon", -1) }) return target } diff --git a/pkg/tsdb/mysql/macros.go b/pkg/tsdb/mysql/macros.go index a037aa9277a..839f805568e 100644 --- a/pkg/tsdb/mysql/macros.go +++ b/pkg/tsdb/mysql/macros.go @@ -60,7 +60,7 @@ func (m *mySqlMacroEngine) evaluateMacro(name string, args []string) (string, er return "", fmt.Errorf("missing time column argument for macro %v", name) } - return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil + return fmt.Sprintf("%s BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", args[0], m.timeRange.GetFromAsSecondsEpoch(), m.timeRange.GetToAsSecondsEpoch()), nil case "__timeGroup": if len(args) < 2 { return "", fmt.Errorf("macro %v needs time column and interval", name) diff --git a/pkg/tsdb/mysql/macros_test.go b/pkg/tsdb/mysql/macros_test.go index 3c9a5a26c94..24bf18873d5 100644 --- a/pkg/tsdb/mysql/macros_test.go +++ b/pkg/tsdb/mysql/macros_test.go @@ -60,7 +60,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { @@ -92,7 +92,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { @@ -112,7 +112,7 @@ func TestMacroEngine(t *testing.T) { sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)") So(err, ShouldBeNil) - So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339))) + So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN FROM_UNIXTIME(%d) AND FROM_UNIXTIME(%d)", from.Unix(), to.Unix())) }) Convey("interpolate __unixEpochFilter function", func() { diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 645f6b49bbb..35b03e489a0 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "strconv" + "strings" "github.com/go-sql-driver/mysql" "github.com/go-xorm/core" @@ -20,10 +21,14 @@ func init() { func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { logger := log.New("tsdb.mysql") + protocol := "tcp" + if strings.HasPrefix(datasource.Url, "/") { + protocol = "unix" + } cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true", datasource.User, datasource.Password, - "tcp", + protocol, datasource.Url, datasource.Database, ) diff --git a/pkg/tsdb/stackdriver/ensure_default_project.go b/pkg/tsdb/stackdriver/ensure_default_project.go new file mode 100644 index 00000000000..265fdda5151 --- /dev/null +++ b/pkg/tsdb/stackdriver/ensure_default_project.go @@ -0,0 +1,24 @@ +package stackdriver + +import ( + "context" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/tsdb" +) + +func (e *StackdriverExecutor) ensureDefaultProject(ctx context.Context, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) { + queryResult := &tsdb.QueryResult{Meta: simplejson.New(), RefId: tsdbQuery.Queries[0].RefId} + result := &tsdb.Response{ + Results: make(map[string]*tsdb.QueryResult), + } + defaultProject, err := e.getDefaultProject(ctx) + if err != nil { + return nil, err + } + + e.dsInfo.JsonData.Set("defaultProject", defaultProject) + queryResult.Meta.Set("defaultProject", defaultProject) + result.Results[tsdbQuery.Queries[0].RefId] = queryResult + return result, nil +} diff --git a/pkg/tsdb/stackdriver/stackdriver.go b/pkg/tsdb/stackdriver/stackdriver.go index 96242dfdec4..2ad47cc4b83 100644 --- a/pkg/tsdb/stackdriver/stackdriver.go +++ b/pkg/tsdb/stackdriver/stackdriver.go @@ -16,6 +16,7 @@ import ( "time" "golang.org/x/net/context/ctxhttp" + "golang.org/x/oauth2/google" "github.com/grafana/grafana/pkg/api/pluginproxy" "github.com/grafana/grafana/pkg/components/null" @@ -34,6 +35,11 @@ var ( metricNameFormat *regexp.Regexp ) +const ( + gceAuthentication string = "gce" + jwtAuthentication string = "jwt" +) + // StackdriverExecutor executes queries for the Stackdriver datasource type StackdriverExecutor struct { httpClient *http.Client @@ -71,6 +77,8 @@ func (e *StackdriverExecutor) Query(ctx context.Context, dsInfo *models.DataSour switch queryType { case "annotationQuery": result, err = e.executeAnnotationQuery(ctx, tsdbQuery) + case "ensureDefaultProjectQuery": + result, err = e.ensureDefaultProject(ctx, tsdbQuery) case "timeSeriesQuery": fallthrough default: @@ -85,6 +93,16 @@ func (e *StackdriverExecutor) executeTimeSeriesQuery(ctx context.Context, tsdbQu Results: make(map[string]*tsdb.QueryResult), } + authenticationType := e.dsInfo.JsonData.Get("authenticationType").MustString(jwtAuthentication) + if authenticationType == gceAuthentication { + defaultProject, err := e.getDefaultProject(ctx) + if err != nil { + return nil, fmt.Errorf("Failed to retrieve default project from GCE metadata server. error: %v", err) + } + + e.dsInfo.JsonData.Set("defaultProject", defaultProject) + } + queries, err := e.buildQueries(tsdbQuery) if err != nil { return nil, err @@ -168,8 +186,7 @@ func reverse(s string) string { } func interpolateFilterWildcards(value string) string { - re := regexp.MustCompile("[*]") - matches := len(re.FindAllStringIndex(value, -1)) + matches := strings.Count(value, "*") if matches == 2 && strings.HasSuffix(value, "*") && strings.HasPrefix(value, "*") { value = strings.Replace(value, "*", "", -1) value = fmt.Sprintf(`has_substring("%s")`, value) @@ -337,11 +354,21 @@ func (e *StackdriverExecutor) unmarshalResponse(res *http.Response) (Stackdriver func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data StackdriverResponse, query *StackdriverQuery) error { metricLabels := make(map[string][]string) resourceLabels := make(map[string][]string) + var resourceTypes []string + + for _, series := range data.TimeSeries { + if !containsLabel(resourceTypes, series.Resource.Type) { + resourceTypes = append(resourceTypes, series.Resource.Type) + } + } for _, series := range data.TimeSeries { points := make([]tsdb.TimePoint, 0) defaultMetricName := series.Metric.Type + if len(resourceTypes) > 1 { + defaultMetricName += " " + series.Resource.Type + } for key, value := range series.Metric.Labels { if !containsLabel(metricLabels[key], value) { @@ -385,7 +412,7 @@ func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data Sta points = append(points, tsdb.NewTimePoint(null.FloatFrom(value), float64((point.Interval.EndTime).Unix())*1000)) } - metricName := formatLegendKeys(series.Metric.Type, defaultMetricName, series.Metric.Labels, series.Resource.Labels, make(map[string]string), query) + metricName := formatLegendKeys(series.Metric.Type, defaultMetricName, series.Resource.Type, series.Metric.Labels, series.Resource.Labels, make(map[string]string), query) queryRes.Series = append(queryRes.Series, &tsdb.TimeSeries{ Name: metricName, @@ -411,7 +438,7 @@ func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data Sta bucketBound := calcBucketBound(point.Value.DistributionValue.BucketOptions, i) additionalLabels := map[string]string{"bucket": bucketBound} buckets[i] = &tsdb.TimeSeries{ - Name: formatLegendKeys(series.Metric.Type, defaultMetricName, series.Metric.Labels, series.Resource.Labels, additionalLabels, query), + Name: formatLegendKeys(series.Metric.Type, defaultMetricName, series.Resource.Type, series.Metric.Labels, series.Resource.Labels, additionalLabels, query), Points: make([]tsdb.TimePoint, 0), } if maxKey < i { @@ -427,7 +454,7 @@ func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data Sta bucketBound := calcBucketBound(point.Value.DistributionValue.BucketOptions, i) additionalLabels := map[string]string{"bucket": bucketBound} buckets[i] = &tsdb.TimeSeries{ - Name: formatLegendKeys(series.Metric.Type, defaultMetricName, series.Metric.Labels, series.Resource.Labels, additionalLabels, query), + Name: formatLegendKeys(series.Metric.Type, defaultMetricName, series.Resource.Type, series.Metric.Labels, series.Resource.Labels, additionalLabels, query), Points: make([]tsdb.TimePoint, 0), } } @@ -442,6 +469,7 @@ func (e *StackdriverExecutor) parseResponse(queryRes *tsdb.QueryResult, data Sta queryRes.Meta.Set("resourceLabels", resourceLabels) queryRes.Meta.Set("metricLabels", metricLabels) queryRes.Meta.Set("groupBys", query.GroupBys) + queryRes.Meta.Set("resourceTypes", resourceTypes) return nil } @@ -455,7 +483,7 @@ func containsLabel(labels []string, newLabel string) bool { return false } -func formatLegendKeys(metricType string, defaultMetricName string, metricLabels map[string]string, resourceLabels map[string]string, additionalLabels map[string]string, query *StackdriverQuery) string { +func formatLegendKeys(metricType string, defaultMetricName string, resourceType string, metricLabels map[string]string, resourceLabels map[string]string, additionalLabels map[string]string, query *StackdriverQuery) string { if query.AliasBy == "" { return defaultMetricName } @@ -469,6 +497,10 @@ func formatLegendKeys(metricType string, defaultMetricName string, metricLabels return []byte(metricType) } + if metaPartName == "resource.type" && resourceType != "" { + return []byte(resourceType) + } + metricPart := replaceWithMetricPart(metaPartName, metricType) if metricPart != nil { @@ -550,8 +582,6 @@ func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models. if !ok { return nil, errors.New("Unable to find datasource plugin Stackdriver") } - projectName := dsInfo.JsonData.Get("defaultProject").MustString() - proxyPass := fmt.Sprintf("stackdriver%s", "v3/projects/"+projectName+"/timeSeries") var stackdriverRoute *plugins.AppPluginRoute for _, route := range plugin.Routes { @@ -561,7 +591,22 @@ func (e *StackdriverExecutor) createRequest(ctx context.Context, dsInfo *models. } } + projectName := dsInfo.JsonData.Get("defaultProject").MustString() + proxyPass := fmt.Sprintf("stackdriver%s", "v3/projects/"+projectName+"/timeSeries") + pluginproxy.ApplyRoute(ctx, req, proxyPass, stackdriverRoute, dsInfo) return req, nil } + +func (e *StackdriverExecutor) getDefaultProject(ctx context.Context) (string, error) { + authenticationType := e.dsInfo.JsonData.Get("authenticationType").MustString(jwtAuthentication) + if authenticationType == gceAuthentication { + defaultCredentials, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/monitoring.read") + if err != nil { + return "", fmt.Errorf("Failed to retrieve default project from GCE metadata server. error: %v", err) + } + return defaultCredentials.ProjectID, nil + } + return e.dsInfo.JsonData.Get("defaultProject").MustString(), nil +} diff --git a/public/app/app.ts b/public/app/app.ts index 298bf5609cd..9647fbe5416 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -26,8 +26,12 @@ _.move = (array, fromIndex, toIndex) => { return array; }; -import { coreModule, registerAngularDirectives } from './core/core'; -import { setupAngularRoutes } from './routes/routes'; +import { coreModule, angularModules } from 'app/core/core_module'; +import { registerAngularDirectives } from 'app/core/core'; +import { setupAngularRoutes } from 'app/routes/routes'; + +import 'app/routes/GrafanaCtrl'; +import 'app/features/all'; // import symlinked extensions const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); @@ -109,39 +113,26 @@ export class GrafanaApp { 'react', ]; - const moduleTypes = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes']; - - _.each(moduleTypes, type => { - const moduleName = 'grafana.' + type; - this.useModule(angular.module(moduleName, [])); - }); - // makes it possible to add dynamic stuff - this.useModule(coreModule); + _.each(angularModules, m => { + this.useModule(m); + }); // register react angular wrappers coreModule.config(setupAngularRoutes); registerAngularDirectives(); - const preBootRequires = [import('app/features/all')]; + // disable tool tip animation + $.fn.tooltip.defaults.animation = false; - Promise.all(preBootRequires) - .then(() => { - // disable tool tip animation - $.fn.tooltip.defaults.animation = false; - - // bootstrap the app - angular.bootstrap(document, this.ngModuleDependencies).invoke(() => { - _.each(this.preBootModules, module => { - _.extend(module, this.registerFunctions); - }); - - this.preBootModules = null; - }); - }) - .catch(err => { - console.log('Application boot failed:', err); + // bootstrap the app + angular.bootstrap(document, this.ngModuleDependencies).invoke(() => { + _.each(this.preBootModules, module => { + _.extend(module, this.registerFunctions); }); + + this.preBootModules = null; + }); } } diff --git a/public/app/core/actions/appNotification.ts b/public/app/core/actions/appNotification.ts new file mode 100644 index 00000000000..b79b642eef1 --- /dev/null +++ b/public/app/core/actions/appNotification.ts @@ -0,0 +1,28 @@ +import { AppNotification } from 'app/types/'; + +export enum ActionTypes { + AddAppNotification = 'ADD_APP_NOTIFICATION', + ClearAppNotification = 'CLEAR_APP_NOTIFICATION', +} + +interface AddAppNotificationAction { + type: ActionTypes.AddAppNotification; + payload: AppNotification; +} + +interface ClearAppNotificationAction { + type: ActionTypes.ClearAppNotification; + payload: number; +} + +export type Action = AddAppNotificationAction | ClearAppNotificationAction; + +export const clearAppNotification = (appNotificationId: number) => ({ + type: ActionTypes.ClearAppNotification, + payload: appNotificationId, +}); + +export const notifyApp = (appNotification: AppNotification) => ({ + type: ActionTypes.AddAppNotification, + payload: appNotification, +}); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index 451a13dae99..f7ce2dda945 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -1,4 +1,5 @@ import { updateLocation } from './location'; import { updateNavIndex, UpdateNavIndexAction } from './navModel'; +import { notifyApp, clearAppNotification } from './appNotification'; -export { updateLocation, updateNavIndex, UpdateNavIndexAction }; +export { updateLocation, updateNavIndex, UpdateNavIndexAction, notifyApp, clearAppNotification }; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 6974d40aac8..7be28272f11 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -5,10 +5,12 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA'; import { SearchResult } from './components/search/SearchResult'; import { TagFilter } from './components/TagFilter/TagFilter'; import { SideMenu } from './components/sidemenu/SideMenu'; +import AppNotificationList from './components/AppNotifications/AppNotificationList'; export function registerAngularDirectives() { react2AngularDirective('passwordStrength', PasswordStrength, ['password']); react2AngularDirective('sidemenu', SideMenu, []); + react2AngularDirective('appNotificationsList', AppNotificationList, []); react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); react2AngularDirective('emptyListCta', EmptyListCTA, ['model']); react2AngularDirective('searchResult', SearchResult, []); diff --git a/public/app/core/components/AppNotifications/AppNotificationItem.tsx b/public/app/core/components/AppNotifications/AppNotificationItem.tsx new file mode 100644 index 00000000000..5169c39e7a0 --- /dev/null +++ b/public/app/core/components/AppNotifications/AppNotificationItem.tsx @@ -0,0 +1,38 @@ +import React, { Component } from 'react'; +import { AppNotification } from 'app/types'; + +interface Props { + appNotification: AppNotification; + onClearNotification: (id) => void; +} + +export default class AppNotificationItem extends Component { + shouldComponentUpdate(nextProps) { + return this.props.appNotification.id !== nextProps.appNotification.id; + } + + componentDidMount() { + const { appNotification, onClearNotification } = this.props; + setTimeout(() => { + onClearNotification(appNotification.id); + }, appNotification.timeout); + } + + render() { + const { appNotification, onClearNotification } = this.props; + return ( +
+
+ +
+
+
{appNotification.title}
+
{appNotification.text}
+
+ +
+ ); + } +} diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx new file mode 100644 index 00000000000..c91f8372384 --- /dev/null +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -0,0 +1,60 @@ +import React, { PureComponent } from 'react'; +import appEvents from 'app/core/app_events'; +import AppNotificationItem from './AppNotificationItem'; +import { notifyApp, clearAppNotification } from 'app/core/actions'; +import { connectWithStore } from 'app/core/utils/connectWithReduxStore'; +import { AppNotification, StoreState } from 'app/types'; +import { + createErrorNotification, + createSuccessNotification, + createWarningNotification, +} from '../../copy/appNotification'; + +export interface Props { + appNotifications: AppNotification[]; + notifyApp: typeof notifyApp; + clearAppNotification: typeof clearAppNotification; +} + +export class AppNotificationList extends PureComponent { + componentDidMount() { + const { notifyApp } = this.props; + + appEvents.on('alert-warning', options => notifyApp(createWarningNotification(options[0], options[1]))); + appEvents.on('alert-success', options => notifyApp(createSuccessNotification(options[0], options[1]))); + appEvents.on('alert-error', options => notifyApp(createErrorNotification(options[0], options[1]))); + } + + onClearAppNotification = id => { + this.props.clearAppNotification(id); + }; + + render() { + const { appNotifications } = this.props; + + return ( +
+ {appNotifications.map((appNotification, index) => { + return ( + this.onClearAppNotification(id)} + /> + ); + })} +
+ ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + appNotifications: state.appNotifications.appNotifications, +}); + +const mapDispatchToProps = { + notifyApp, + clearAppNotification, +}; + +export default connectWithStore(AppNotificationList, mapStateToProps, mapDispatchToProps); diff --git a/public/app/core/components/Forms/Forms.tsx b/public/app/core/components/Label/Label.tsx similarity index 99% rename from public/app/core/components/Forms/Forms.tsx rename to public/app/core/components/Label/Label.tsx index 543e1a1d6df..6d4fd20dbfe 100644 --- a/public/app/core/components/Forms/Forms.tsx +++ b/public/app/core/components/Label/Label.tsx @@ -19,3 +19,4 @@ export const Label: SFC = props => { ); }; + diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index a60a7dd4af6..71cc937ddfa 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -18,6 +18,10 @@ export interface Props { } class AddPermissions extends Component { + static defaultProps = { + showPermissionLevels: true, + }; + constructor(props) { super(props); this.state = this.getCleanState(); diff --git a/public/app/core/components/Picker/DescriptionPicker.tsx b/public/app/core/components/Picker/DescriptionPicker.tsx index 4c39dcd8a79..a917e3977f5 100644 --- a/public/app/core/components/Picker/DescriptionPicker.tsx +++ b/public/app/core/components/Picker/DescriptionPicker.tsx @@ -22,10 +22,6 @@ export interface Props { const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value); class DescriptionPicker extends Component { - constructor(props) { - super(props); - } - render() { const { optionsWithDesc, onSelected, disabled, className, value } = this.props; const selectedOption = getSelectedOption(optionsWithDesc, value); diff --git a/public/app/core/components/Switch/Switch.tsx b/public/app/core/components/Switch/Switch.tsx new file mode 100644 index 00000000000..ba09267ebd2 --- /dev/null +++ b/public/app/core/components/Switch/Switch.tsx @@ -0,0 +1,46 @@ +import React, { PureComponent } from 'react'; +import _ from 'lodash'; + +export interface Props { + label: string; + checked: boolean; + labelClass?: string; + switchClass?: string; + onChange: (event) => any; +} + +export interface State { + id: any; +} + +export class Switch extends PureComponent { + state = { + id: _.uniqueId(), + }; + + internalOnChange = event => { + event.stopPropagation(); + this.props.onChange(event); + }; + + render() { + const { labelClass, switchClass, label, checked } = this.props; + const labelId = `check-${this.state.id}`; + const labelClassName = `gf-form-label ${labelClass} pointer`; + const switchClassName = `gf-form-switch ${switchClass}`; + + return ( +
+ {label && ( + + )} +
+ +
+
+ ); + } +} diff --git a/public/app/core/components/help/help.ts b/public/app/core/components/help/help.ts index eac47b6e0a2..8e8a5ed45d2 100644 --- a/public/app/core/components/help/help.ts +++ b/public/app/core/components/help/help.ts @@ -34,6 +34,7 @@ export class HelpCtrl { { keys: ['p', 's'], description: 'Open Panel Share Modal' }, { keys: ['p', 'd'], description: 'Duplicate Panel' }, { keys: ['p', 'r'], description: 'Remove Panel' }, + { keys: ['p', 'l'], description: 'Toggle panel legend' }, ], 'Time Range': [ { keys: ['t', 'z'], description: 'Zoom out time range' }, diff --git a/public/app/core/components/query_part/query_part_editor.ts b/public/app/core/components/query_part/query_part_editor.ts index 6181d020471..2cab966ed46 100644 --- a/public/app/core/components/query_part/query_part_editor.ts +++ b/public/app/core/components/query_part/query_part_editor.ts @@ -103,7 +103,7 @@ export function queryPartEditorDirective($compile, templateSrv) { $scope.$apply(() => { $scope.handleEvent({ $event: { name: 'get-param-options' } }).then(result => { const dynamicOptions = _.map(result, op => { - return op.value; + return _.escape(op.value); }); callback(dynamicOptions); }); @@ -117,6 +117,7 @@ export function queryPartEditorDirective($compile, templateSrv) { minLength: 0, items: 1000, updater: value => { + value = _.unescape(value); setTimeout(() => { inputBlur.call($input[0], paramIndex); }, 0); diff --git a/public/app/core/components/scroll/scroll.ts b/public/app/core/components/scroll/scroll.ts index bd355817f92..49931ecaac4 100644 --- a/public/app/core/components/scroll/scroll.ts +++ b/public/app/core/components/scroll/scroll.ts @@ -18,6 +18,7 @@ export function geminiScrollbar() { let scrollRoot = elem.parent(); const scroller = elem; + console.log('scroll'); if (attrs.grafanaScrollbar && attrs.grafanaScrollbar === 'scrollonroot') { scrollRoot = scroller; } diff --git a/public/app/core/components/sql_part/sql_part_editor.ts b/public/app/core/components/sql_part/sql_part_editor.ts index 8097dddeb3b..1d29c577560 100644 --- a/public/app/core/components/sql_part/sql_part_editor.ts +++ b/public/app/core/components/sql_part/sql_part_editor.ts @@ -109,12 +109,12 @@ export function sqlPartEditorDirective($compile, templateSrv) { $scope.$apply(() => { $scope.handleEvent({ $event: { name: 'get-param-options', param: param } }).then(result => { const dynamicOptions = _.map(result, op => { - return op.value; + return _.escape(op.value); }); // add current value to dropdown if it's not in dynamicOptions if (_.indexOf(dynamicOptions, part.params[paramIndex]) === -1) { - dynamicOptions.unshift(part.params[paramIndex]); + dynamicOptions.unshift(_.escape(part.params[paramIndex])); } callback(dynamicOptions); @@ -129,6 +129,7 @@ export function sqlPartEditorDirective($compile, templateSrv) { minLength: 0, items: 1000, updater: value => { + value = _.unescape(value); if (value === part.params[paramIndex]) { clearTimeout(cancelBlur); $input.focus(); diff --git a/public/app/core/config.ts b/public/app/core/config.ts index bf5abe37d7f..1473f8a91f8 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { PanelPlugin } from 'app/types/plugins'; export interface BuildInfo { version: string; @@ -9,7 +10,7 @@ export interface BuildInfo { export class Settings { datasources: any; - panels: any; + panels: PanelPlugin[]; appSubUrl: string; windowTitlePrefix: string; buildInfo: BuildInfo; diff --git a/public/app/core/constants.ts b/public/app/core/constants.ts index 2642c5e400a..00981156614 100644 --- a/public/app/core/constants.ts +++ b/public/app/core/constants.ts @@ -8,3 +8,6 @@ export const DEFAULT_ROW_HEIGHT = 250; export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; export const LS_PANEL_COPY_KEY = 'panel-copy'; + +export const DASHBOARD_TOOLBAR_HEIGHT = 55; +export const DASHBOARD_TOP_PADDING = 20; diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts new file mode 100644 index 00000000000..c34480d7aad --- /dev/null +++ b/public/app/core/copy/appNotification.ts @@ -0,0 +1,46 @@ +import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types'; + +const defaultSuccessNotification: AppNotification = { + title: '', + text: '', + severity: AppNotificationSeverity.Success, + icon: 'fa fa-check', + timeout: AppNotificationTimeout.Success, +}; + +const defaultWarningNotification: AppNotification = { + title: '', + text: '', + severity: AppNotificationSeverity.Warning, + icon: 'fa fa-exclamation', + timeout: AppNotificationTimeout.Warning, +}; + +const defaultErrorNotification: AppNotification = { + title: '', + text: '', + severity: AppNotificationSeverity.Error, + icon: 'fa fa-exclamation-triangle', + timeout: AppNotificationTimeout.Error, +}; + +export const createSuccessNotification = (title: string, text?: string): AppNotification => ({ + ...defaultSuccessNotification, + title: title, + text: text, + id: Date.now(), +}); + +export const createErrorNotification = (title: string, text?: string): AppNotification => ({ + ...defaultErrorNotification, + title: title, + text: text, + id: Date.now(), +}); + +export const createWarningNotification = (title: string, text?: string): AppNotification => ({ + ...defaultWarningNotification, + title: title, + text: text, + id: Date.now(), +}); diff --git a/public/app/core/core.ts b/public/app/core/core.ts index 173d6b80b15..18a625d3307 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -19,7 +19,6 @@ import './components/colorpicker/spectrum_picker'; import './services/search_srv'; import './services/ng_react'; -import { grafanaAppDirective } from './components/grafana_app'; import { searchDirective } from './components/search/search'; import { infoPopover } from './components/info_popover'; import { navbarDirective } from './components/navbar/navbar'; @@ -60,7 +59,6 @@ export { registerAngularDirectives, arrayJoin, coreModule, - grafanaAppDirective, navbarDirective, searchDirective, liveSrv, diff --git a/public/app/core/core_module.ts b/public/app/core/core_module.ts index f6c30e6cf15..c8401975c18 100644 --- a/public/app/core/core_module.ts +++ b/public/app/core/core_module.ts @@ -1,2 +1,18 @@ import angular from 'angular'; -export default angular.module('grafana.core', ['ngRoute']); + +const coreModule = angular.module('grafana.core', ['ngRoute']); + +// legacy modules +const angularModules = [ + coreModule, + angular.module('grafana.controllers', []), + angular.module('grafana.directives', []), + angular.module('grafana.factories', []), + angular.module('grafana.services', []), + angular.module('grafana.filters', []), + angular.module('grafana.routes', []), +]; + +export { angularModules, coreModule }; + +export default coreModule; diff --git a/public/app/core/directives/dash_class.ts b/public/app/core/directives/dash_class.ts index 224bc2c772d..37124eb7d4b 100644 --- a/public/app/core/directives/dash_class.ts +++ b/public/app/core/directives/dash_class.ts @@ -2,16 +2,21 @@ import _ from 'lodash'; import coreModule from '../core_module'; /** @ngInject */ -export function dashClass() { +function dashClass($timeout) { return { link: ($scope, elem) => { - $scope.onAppEvent('panel-fullscreen-enter', () => { - elem.toggleClass('panel-in-fullscreen', true); + $scope.ctrl.dashboard.events.on('view-mode-changed', panel => { + console.log('view-mode-changed', panel.fullscreen); + if (panel.fullscreen) { + elem.addClass('panel-in-fullscreen'); + } else { + $timeout(() => { + elem.removeClass('panel-in-fullscreen'); + }); + } }); - $scope.onAppEvent('panel-fullscreen-exit', () => { - elem.toggleClass('panel-in-fullscreen', false); - }); + elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true); $scope.$watch('ctrl.dashboardViewState.state.editview', newValue => { if (newValue) { diff --git a/public/app/core/directives/metric_segment.ts b/public/app/core/directives/metric_segment.ts index de904e95fc6..85576dcffee 100644 --- a/public/app/core/directives/metric_segment.ts +++ b/public/app/core/directives/metric_segment.ts @@ -3,7 +3,7 @@ import $ from 'jquery'; import coreModule from '../core_module'; /** @ngInject */ -export function metricSegment($compile, $sce) { +export function metricSegment($compile, $sce, templateSrv) { const inputTemplate = ' { const selected = _.find($scope.altSegments, { value: value }); if (selected) { segment.value = selected.value; - segment.html = selected.html || selected.value; + segment.html = selected.html || $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(selected.value)); segment.fake = false; segment.expandable = selected.expandable; @@ -56,7 +54,7 @@ export function metricSegment($compile, $sce) { } } else if (segment.custom !== 'false') { segment.value = value; - segment.html = $sce.trustAsHtml(value); + segment.html = $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(value)); segment.expandable = true; segment.fake = false; } @@ -95,7 +93,7 @@ export function metricSegment($compile, $sce) { // add custom values if (segment.custom !== 'false') { if (!segment.fake && _.indexOf(options, segment.value) === -1) { - options.unshift(segment.value); + options.unshift(_.escape(segment.value)); } } @@ -105,6 +103,7 @@ export function metricSegment($compile, $sce) { }; $scope.updater = value => { + value = _.unescape(value); if (value === segment.value) { clearTimeout(cancelBlur); $input.focus(); diff --git a/public/app/core/reducers/appNotification.test.ts b/public/app/core/reducers/appNotification.test.ts new file mode 100644 index 00000000000..183b699f5fc --- /dev/null +++ b/public/app/core/reducers/appNotification.test.ts @@ -0,0 +1,51 @@ +import { appNotificationsReducer } from './appNotification'; +import { ActionTypes } from '../actions/appNotification'; +import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/'; + +describe('clear alert', () => { + it('should filter alert', () => { + const id1 = 1540301236048; + const id2 = 1540301248293; + + const initialState = { + appNotifications: [ + { + id: id1, + severity: AppNotificationSeverity.Success, + icon: 'success', + title: 'test', + text: 'test alert', + timeout: AppNotificationTimeout.Success, + }, + { + id: id2, + severity: AppNotificationSeverity.Warning, + icon: 'warning', + title: 'test2', + text: 'test alert fail 2', + timeout: AppNotificationTimeout.Warning, + }, + ], + }; + + const result = appNotificationsReducer(initialState, { + type: ActionTypes.ClearAppNotification, + payload: id2, + }); + + const expectedResult = { + appNotifications: [ + { + id: id1, + severity: AppNotificationSeverity.Success, + icon: 'success', + title: 'test', + text: 'test alert', + timeout: AppNotificationTimeout.Success, + }, + ], + }; + + expect(result).toEqual(expectedResult); + }); +}); diff --git a/public/app/core/reducers/appNotification.ts b/public/app/core/reducers/appNotification.ts new file mode 100644 index 00000000000..2c8bbbbd84d --- /dev/null +++ b/public/app/core/reducers/appNotification.ts @@ -0,0 +1,19 @@ +import { AppNotification, AppNotificationsState } from 'app/types/'; +import { Action, ActionTypes } from '../actions/appNotification'; + +export const initialState: AppNotificationsState = { + appNotifications: [] as AppNotification[], +}; + +export const appNotificationsReducer = (state = initialState, action: Action): AppNotificationsState => { + switch (action.type) { + case ActionTypes.AddAppNotification: + return { ...state, appNotifications: state.appNotifications.concat([action.payload]) }; + case ActionTypes.ClearAppNotification: + return { + ...state, + appNotifications: state.appNotifications.filter(appNotification => appNotification.id !== action.payload), + }; + } + return state; +}; diff --git a/public/app/core/reducers/index.ts b/public/app/core/reducers/index.ts index be13528c91c..1c8670ed0d6 100644 --- a/public/app/core/reducers/index.ts +++ b/public/app/core/reducers/index.ts @@ -1,7 +1,9 @@ import { navIndexReducer as navIndex } from './navModel'; import { locationReducer as location } from './location'; +import { appNotificationsReducer as appNotifications } from './appNotification'; export default { navIndex, location, + appNotifications, }; diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index 2089cfe9f59..7c7dffd04b9 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -1,6 +1,7 @@ import { Action } from 'app/core/actions/location'; import { LocationState } from 'app/types'; import { renderUrl } from 'app/core/utils/url'; +import _ from 'lodash'; export const initialState: LocationState = { url: '', @@ -12,11 +13,17 @@ export const initialState: LocationState = { export const locationReducer = (state = initialState, action: Action): LocationState => { switch (action.type) { case 'UPDATE_LOCATION': { - const { path, query, routeParams } = action.payload; + const { path, routeParams } = action.payload; + let query = action.payload.query || state.query; + + if (action.payload.partial) { + query = _.defaults(query, state.query); + } + return { url: renderUrl(path || state.path, query), path: path || state.path, - query: query || state.query, + query: query, routeParams: routeParams || state.routeParams, }; } diff --git a/public/app/core/services/alert_srv.ts b/public/app/core/services/alert_srv.ts index 2d447651b75..4995b148abd 100644 --- a/public/app/core/services/alert_srv.ts +++ b/public/app/core/services/alert_srv.ts @@ -1,100 +1,12 @@ -import angular from 'angular'; -import _ from 'lodash'; import coreModule from 'app/core/core_module'; -import appEvents from 'app/core/app_events'; export class AlertSrv { - list: any[]; + constructor() {} - /** @ngInject */ - constructor(private $timeout, private $rootScope) { - this.list = []; - } - - init() { - this.$rootScope.onAppEvent( - 'alert-error', - (e, alert) => { - this.set(alert[0], alert[1], 'error', 12000); - }, - this.$rootScope - ); - - this.$rootScope.onAppEvent( - 'alert-warning', - (e, alert) => { - this.set(alert[0], alert[1], 'warning', 5000); - }, - this.$rootScope - ); - - this.$rootScope.onAppEvent( - 'alert-success', - (e, alert) => { - this.set(alert[0], alert[1], 'success', 3000); - }, - this.$rootScope - ); - - appEvents.on('alert-warning', options => this.set(options[0], options[1], 'warning', 5000)); - appEvents.on('alert-success', options => this.set(options[0], options[1], 'success', 3000)); - appEvents.on('alert-error', options => this.set(options[0], options[1], 'error', 7000)); - } - - getIconForSeverity(severity) { - switch (severity) { - case 'success': - return 'fa fa-check'; - case 'error': - return 'fa fa-exclamation-triangle'; - default: - return 'fa fa-exclamation'; - } - } - - set(title, text, severity, timeout) { - if (_.isObject(text)) { - console.log('alert error', text); - if (text.statusText) { - text = `HTTP Error (${text.status}) ${text.statusText}`; - } - } - - const newAlert = { - title: title || '', - text: text || '', - severity: severity || 'info', - icon: this.getIconForSeverity(severity), - }; - - const newAlertJson = angular.toJson(newAlert); - - // remove same alert if it already exists - _.remove(this.list, value => { - return angular.toJson(value) === newAlertJson; - }); - - this.list.push(newAlert); - if (timeout > 0) { - this.$timeout(() => { - this.list = _.without(this.list, newAlert); - }, timeout); - } - - if (!this.$rootScope.$$phase) { - this.$rootScope.$digest(); - } - - return newAlert; - } - - clear(alert) { - this.list = _.without(this.list, alert); - } - - clearAll() { - this.list = []; + set() { + console.log('old depricated alert srv being used'); } } +// this is just added to not break old plugins that might be using it coreModule.service('alertSrv', AlertSrv); diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 3e8132a695b..144567efeb9 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -9,7 +9,7 @@ export class BackendSrv { private noBackendCache: boolean; /** @ngInject */ - constructor(private $http, private alertSrv, private $q, private $timeout, private contextSrv) {} + constructor(private $http, private $q, private $timeout, private contextSrv) {} get(url, params?) { return this.request({ method: 'GET', url: url, params: params }); @@ -49,14 +49,14 @@ export class BackendSrv { } if (err.status === 422) { - this.alertSrv.set('Validation failed', data.message, 'warning', 4000); + appEvents.emit('alert-warning', ['Validation failed', data.message]); throw data; } - data.severity = 'error'; + let severity = 'error'; if (err.status < 500) { - data.severity = 'warning'; + severity = 'warning'; } if (data.message) { @@ -66,7 +66,8 @@ export class BackendSrv { description = message; message = 'Error'; } - this.alertSrv.set(message, description, data.severity, 10000); + + appEvents.emit('alert-' + severity, [message, description]); } throw data; @@ -93,7 +94,7 @@ export class BackendSrv { if (options.method !== 'GET') { if (results && results.data.message) { if (options.showSuccessAlert !== false) { - this.alertSrv.set(results.data.message, '', 'success', 3000); + appEvents.emit('alert-success', [results.data.message]); } } } diff --git a/public/app/core/services/dynamic_directive_srv.ts b/public/app/core/services/dynamic_directive_srv.ts index ccd86856755..9b7ede59853 100644 --- a/public/app/core/services/dynamic_directive_srv.ts +++ b/public/app/core/services/dynamic_directive_srv.ts @@ -3,7 +3,7 @@ import coreModule from '../core_module'; class DynamicDirectiveSrv { /** @ngInject */ - constructor(private $compile, private $rootScope) {} + constructor(private $compile) {} addDirective(element, name, scope) { const child = angular.element(document.createElement(name)); @@ -14,25 +14,19 @@ class DynamicDirectiveSrv { } link(scope, elem, attrs, options) { - options - .directive(scope) - .then(directiveInfo => { - if (!directiveInfo || !directiveInfo.fn) { - elem.empty(); - return; - } + const directiveInfo = options.directive(scope); + if (!directiveInfo || !directiveInfo.fn) { + elem.empty(); + return; + } - if (!directiveInfo.fn.registered) { - coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn); - directiveInfo.fn.registered = true; - } + if (!directiveInfo.fn.registered) { + console.log('register panel tab'); + coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn); + directiveInfo.fn.registered = true; + } - this.addDirective(elem, directiveInfo.name, scope); - }) - .catch(err => { - console.log('Plugin load:', err); - this.$rootScope.appEvent('alert-error', ['Plugin error', err.toString()]); - }); + this.addDirective(elem, directiveInfo.name, scope); } create(options) { diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index d8dfc958dd4..6fe57dfa77a 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -148,7 +148,7 @@ export class KeybindingSrv { this.bind('mod+o', () => { dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3; appEvents.emit('graph-hover-clear'); - this.$rootScope.$broadcast('refresh'); + dashboard.startRefresh(); }); this.bind('mod+s', e => { @@ -242,6 +242,18 @@ export class KeybindingSrv { } }); + // toggle panel legend + this.bind('p l', () => { + if (dashboard.meta.focusPanelId) { + const panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId); + if (panelInfo.panel.legend) { + const panelRef = dashboard.getPanelById(dashboard.meta.focusPanelId); + panelRef.legend.show = !panelRef.legend.show; + panelRef.refresh(); + } + } + }); + // collapse all rows this.bind('d shift+c', () => { dashboard.collapseRows(); @@ -257,7 +269,7 @@ export class KeybindingSrv { }); this.bind('d r', () => { - this.$rootScope.$broadcast('refresh'); + dashboard.startRefresh(); }); this.bind('d s', () => { diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts index 2e35b87deb4..a6cb5a7d331 100644 --- a/public/app/core/specs/backend_srv.test.ts +++ b/public/app/core/specs/backend_srv.test.ts @@ -9,7 +9,7 @@ describe('backend_srv', () => { return Promise.resolve({}); }; - const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}, {}); + const _backendSrv = new BackendSrv(_httpBackend, {}, {}, {}); describe('when handling errors', () => { it('should return the http status code', async () => { diff --git a/public/app/core/specs/table_model.test.ts b/public/app/core/specs/table_model.test.ts index 990daaaa2da..19b2a7543fb 100644 --- a/public/app/core/specs/table_model.test.ts +++ b/public/app/core/specs/table_model.test.ts @@ -1,4 +1,4 @@ -import TableModel from 'app/core/table_model'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; describe('when sorting table desc', () => { let table; @@ -79,3 +79,118 @@ describe('when sorting with nulls', () => { expect(values).toEqual([null, null, 'd', 'c', 'b', 'a', '', '']); }); }); + +describe('mergeTables', () => { + const time = new Date().getTime(); + + const singleTable = new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value' }], + rows: [[time, 'Label Value 1', 42]], + }); + + const multipleTablesSameColumns = [ + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #A' }], + rows: [[time, 'Label Value 1', 'Label Value 2', 42]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #B' }], + rows: [[time, 'Label Value 1', 'Label Value 2', 13]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #C' }], + rows: [[time, 'Label Value 1', 'Label Value 2', 4]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #C' }], + rows: [[time, 'Label Value 1', 'Label Value 2', 7]], + }), + ]; + + const multipleTablesDifferentColumns = [ + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #A' }], + rows: [[time, 'Label Value 1', 42]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 2' }, { text: 'Value #B' }], + rows: [[time, 'Label Value 2', 13]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #C' }], + rows: [[time, 'Label Value 3', 7]], + }), + ]; + + it('should return the single table as is', () => { + const table = mergeTablesIntoModel(new TableModel(), singleTable); + expect(table.columns.length).toBe(3); + expect(table.columns[0].text).toBe('Time'); + expect(table.columns[1].text).toBe('Label Key 1'); + expect(table.columns[2].text).toBe('Value'); + }); + + it('should return the union of columns for multiple tables', () => { + const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns); + expect(table.columns.length).toBe(6); + expect(table.columns[0].text).toBe('Time'); + expect(table.columns[1].text).toBe('Label Key 1'); + expect(table.columns[2].text).toBe('Label Key 2'); + expect(table.columns[3].text).toBe('Value #A'); + expect(table.columns[4].text).toBe('Value #B'); + expect(table.columns[5].text).toBe('Value #C'); + }); + + it('should return 1 row for a single table', () => { + const table = mergeTablesIntoModel(new TableModel(), singleTable); + expect(table.rows.length).toBe(1); + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe(42); + }); + + it('should return 2 rows for a multiple tables with same column values plus one extra row', () => { + const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns); + expect(table.rows.length).toBe(2); + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe('Label Value 2'); + expect(table.rows[0][3]).toBe(42); + expect(table.rows[0][4]).toBe(13); + expect(table.rows[0][5]).toBe(4); + expect(table.rows[1][0]).toBe(time); + expect(table.rows[1][1]).toBe('Label Value 1'); + expect(table.rows[1][2]).toBe('Label Value 2'); + expect(table.rows[1][3]).toBeUndefined(); + expect(table.rows[1][4]).toBeUndefined(); + expect(table.rows[1][5]).toBe(7); + }); + + it('should return 2 rows for multiple tables with different column values', () => { + const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesDifferentColumns); + expect(table.rows.length).toBe(2); + expect(table.columns.length).toBe(6); + + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe(42); + expect(table.rows[0][3]).toBe('Label Value 2'); + expect(table.rows[0][4]).toBe(13); + expect(table.rows[0][5]).toBeUndefined(); + + expect(table.rows[1][0]).toBe(time); + expect(table.rows[1][1]).toBe('Label Value 3'); + expect(table.rows[1][2]).toBeUndefined(); + expect(table.rows[1][3]).toBeUndefined(); + expect(table.rows[1][4]).toBeUndefined(); + expect(table.rows[1][5]).toBe(7); + }); +}); diff --git a/public/app/core/table_model.ts b/public/app/core/table_model.ts index f8b96d0537b..91a7cd0c1fb 100644 --- a/public/app/core/table_model.ts +++ b/public/app/core/table_model.ts @@ -1,3 +1,5 @@ +import _ from 'lodash'; + interface Column { text: string; title?: string; @@ -14,11 +16,20 @@ export default class TableModel { type: string; columnMap: any; - constructor() { + constructor(table?: any) { this.columns = []; this.columnMap = {}; this.rows = []; this.type = 'table'; + + if (table) { + if (table.columns) { + table.columns.forEach(col => this.addColumn(col)); + } + if (table.rows) { + table.rows.forEach(row => this.addRow(row)); + } + } } sort(options) { @@ -52,3 +63,104 @@ export default class TableModel { this.rows.push(row); } } + +// Returns true if both rows have matching non-empty fields as well as matching +// indexes where one field is empty and the other is not +function areRowsMatching(columns, row, otherRow) { + let foundFieldToMatch = false; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) { + if (row[columnIndex] !== otherRow[columnIndex]) { + return false; + } + } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) { + foundFieldToMatch = true; + } + } + return foundFieldToMatch; +} + +export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel { + const model = dst || new TableModel(); + + if (arguments.length === 1) { + return model; + } + + // Single query returns data columns and rows as is + if (arguments.length === 2) { + model.columns = [...tables[0].columns]; + model.rows = [...tables[0].rows]; + return model; + } + + // Track column indexes of union: name -> index + const columnNames = {}; + + // Union of all non-value columns + const columnsUnion = tables.slice().reduce((acc, series) => { + series.columns.forEach(col => { + const { text } = col; + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); + } + }); + return acc; + }, []); + + // Map old column index to union index per series, e.g., + // given columnNames {A: 0, B: 1} and + // data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]] + const columnIndexMapper = tables.map(series => series.columns.map(col => columnNames[col.text])); + + // Flatten rows of all series and adjust new column indexes + const flattenedRows = tables.reduce((acc, series, seriesIndex) => { + const mapper = columnIndexMapper[seriesIndex]; + series.rows.forEach(row => { + const alteredRow = []; + // Shifting entries according to index mapper + mapper.forEach((to, from) => { + alteredRow[to] = row[from]; + }); + acc.push(alteredRow); + }); + return acc; + }, []); + + // Merge rows that have same values for columns + const mergedRows = {}; + const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => { + if (!mergedRows[rowIndex]) { + // Look from current row onwards + let offset = rowIndex + 1; + // More than one row can be merged into current row + while (offset < flattenedRows.length) { + // Find next row that could be merged + const match = _.findIndex(flattenedRows, otherRow => areRowsMatching(columnsUnion, row, otherRow), offset); + if (match > -1) { + const matchedRow = flattenedRows[match]; + // Merge values from match into current row if there is a gap in the current row + for (let columnIndex = 0; columnIndex < columnsUnion.length; columnIndex++) { + if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) { + row[columnIndex] = matchedRow[columnIndex]; + } + } + // Don't visit this row again + mergedRows[match] = matchedRow; + // Keep looking for more rows to merge + offset = match + 1; + } else { + // No match found, stop looking + break; + } + } + acc.push(row); + } + return acc; + }, []); + + model.columns = columnsUnion; + model.rows = compactedRows; + return model; +} diff --git a/public/app/core/utils/connectWithReduxStore.tsx b/public/app/core/utils/connectWithReduxStore.tsx new file mode 100644 index 00000000000..92c61db4e77 --- /dev/null +++ b/public/app/core/utils/connectWithReduxStore.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { store } from '../../store/configureStore'; + +export function connectWithStore(WrappedComponent, ...args) { + const ConnectedWrappedComponent = connect(...args)(WrappedComponent); + + return props => { + return ; + }; +} diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 915b47e14e2..4252730338d 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -8,23 +8,17 @@ const DEFAULT_EXPLORE_STATE: ExploreState = { datasourceMissing: false, datasourceName: '', exploreDatasources: [], - graphResult: null, + graphRange: DEFAULT_RANGE, history: [], - latency: 0, - loading: false, - logsResult: null, queries: [], - queryErrors: [], - queryHints: [], + queryTransactions: [], range: DEFAULT_RANGE, - requestOptions: null, showingGraph: true, showingLogs: true, showingTable: true, supportsGraph: null, supportsLogs: null, supportsTable: null, - tableResult: null, }; describe('state functions', () => { diff --git a/public/app/core/utils/rangeutil.ts b/public/app/core/utils/rangeutil.ts index 484dd0e3327..2079aa39006 100644 --- a/public/app/core/utils/rangeutil.ts +++ b/public/app/core/utils/rangeutil.ts @@ -1,5 +1,8 @@ import _ from 'lodash'; import moment from 'moment'; + +import { RawTimeRange } from 'app/types/series'; + import * as dateMath from './datemath'; const spans = { @@ -129,7 +132,7 @@ export function describeTextRange(expr: any) { return opt; } -export function describeTimeRange(range) { +export function describeTimeRange(range: RawTimeRange): string { const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()]; if (option) { return option.display; diff --git a/public/app/features/alerting/NotificationsEditCtrl.ts b/public/app/features/alerting/NotificationsEditCtrl.ts index 315a9a619a1..2607121bb0e 100644 --- a/public/app/features/alerting/NotificationsEditCtrl.ts +++ b/public/app/features/alerting/NotificationsEditCtrl.ts @@ -12,6 +12,7 @@ export class AlertNotificationEditCtrl { defaults: any = { type: 'email', sendReminder: false, + disableResolveMessage: false, frequency: '15m', settings: { httpMethod: 'POST', diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html index 7b198736b83..b2cd2f21e4d 100644 --- a/public/app/features/alerting/partials/notification_edit.html +++ b/public/app/features/alerting/partials/notification_edit.html @@ -21,21 +21,28 @@ + + diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index 19850da52d9..4fe6e10b2cf 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -1,25 +1,32 @@ -import './editor_ctrl'; - +// Libaries import angular from 'angular'; import _ from 'lodash'; + +// Components +import './editor_ctrl'; import coreModule from 'app/core/core_module'; + +// Utils & Services import { makeRegions, dedupAnnotations } from './events_processing'; +// Types +import { DashboardModel } from '../dashboard/dashboard_model'; + export class AnnotationsSrv { globalAnnotationsPromise: any; alertStatesPromise: any; datasourcePromises: any; /** @ngInject */ - constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) { - $rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope); - $rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope); - } + constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {} - clearCache() { - this.globalAnnotationsPromise = null; - this.alertStatesPromise = null; - this.datasourcePromises = null; + init(dashboard: DashboardModel) { + // clear promises on refresh events + dashboard.on('refresh', () => { + this.globalAnnotationsPromise = null; + this.alertStatesPromise = null; + this.datasourcePromises = null; + }); } getAnnotations(options) { diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts index f75743513f1..5ec4e5e3929 100644 --- a/public/app/features/dashboard/all.ts +++ b/public/app/features/dashboard/all.ts @@ -22,7 +22,6 @@ import './export_data/export_data_modal'; import './ad_hoc_filters'; import './repeat_option/repeat_option'; import './dashgrid/DashboardGridDirective'; -import './dashgrid/PanelLoader'; import './dashgrid/RowOptions'; import './folder_picker/folder_picker'; import './move_to_folder_modal/move_to_folder'; diff --git a/public/app/features/dashboard/dashboard_ctrl.ts b/public/app/features/dashboard/dashboard_ctrl.ts index d61eb08d5b9..5871a579f3c 100644 --- a/public/app/features/dashboard/dashboard_ctrl.ts +++ b/public/app/features/dashboard/dashboard_ctrl.ts @@ -1,11 +1,16 @@ +// Utils import config from 'app/core/config'; - +import appEvents from 'app/core/app_events'; import coreModule from 'app/core/core_module'; -import { PanelContainer } from './dashgrid/PanelContainer'; + +// Services +import { AnnotationsSrv } from '../annotations/annotations_srv'; + +// Types import { DashboardModel } from './dashboard_model'; import { PanelModel } from './panel_model'; -export class DashboardCtrl implements PanelContainer { +export class DashboardCtrl { dashboard: DashboardModel; dashboardViewState: any; loadedFallbackDashboard: boolean; @@ -22,8 +27,8 @@ export class DashboardCtrl implements PanelContainer { private dashboardSrv, private unsavedChangesSrv, private dashboardViewStateSrv, - public playlistSrv, - private panelLoader + private annotationsSrv: AnnotationsSrv, + public playlistSrv ) { // temp hack due to way dashboards are loaded // can't use controllerAs on route yet @@ -51,6 +56,7 @@ export class DashboardCtrl implements PanelContainer { // init services this.timeSrv.init(dashboard); this.alertingSrv.init(dashboard, data.alerts); + this.annotationsSrv.init(dashboard); // template values service needs to initialize completely before // the rest of the dashboard can load @@ -74,7 +80,7 @@ export class DashboardCtrl implements PanelContainer { this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard); this.setWindowTitleAndTheme(); - this.$scope.appEvent('dashboard-initialized', dashboard); + appEvents.emit('dashboard-initialized', dashboard); }) .catch(this.onInitFailed.bind(this, 'Dashboard init failed', true)); } @@ -119,14 +125,6 @@ export class DashboardCtrl implements PanelContainer { return this.dashboard; } - getPanelLoader() { - return this.panelLoader; - } - - timezoneChanged() { - this.$rootScope.$broadcast('refresh'); - } - getPanelContainer() { return this; } @@ -168,10 +166,17 @@ export class DashboardCtrl implements PanelContainer { this.dashboard.removePanel(panel); } + onDestroy() { + if (this.dashboard) { + this.dashboard.destroy(); + } + } + init(dashboard) { this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this)); this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this)); this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this)); + this.$scope.$on('$destroy', this.onDestroy.bind(this)); this.setupDashboard(dashboard); } } diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 818765124bf..65a234a2b94 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -200,6 +200,43 @@ export class DashboardModel { this.events.emit('view-mode-changed', panel); } + timeRangeUpdated() { + this.events.emit('time-range-updated'); + } + + startRefresh() { + this.events.emit('refresh'); + + for (const panel of this.panels) { + if (!this.otherPanelInFullscreen(panel)) { + panel.refresh(); + } + } + } + + render() { + this.events.emit('render'); + + for (const panel of this.panels) { + panel.render(); + } + } + + panelInitialized(panel: PanelModel) { + if (!this.otherPanelInFullscreen(panel)) { + panel.refresh(); + } + } + + otherPanelInFullscreen(panel: PanelModel) { + return this.meta.fullscreen && !panel.fullscreen; + } + + changePanelType(panel: PanelModel, pluginId: string) { + panel.changeType(pluginId); + this.events.emit('panel-type-changed', panel); + } + private ensureListExist(data) { if (!data) { data = {}; diff --git a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx index a26a0401d56..68cee112f42 100644 --- a/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx +++ b/public/app/features/dashboard/dashgrid/AddPanelPanel.tsx @@ -3,7 +3,7 @@ import _ from 'lodash'; import classNames from 'classnames'; import config from 'app/core/config'; import { PanelModel } from '../panel_model'; -import { PanelContainer } from './PanelContainer'; +import { DashboardModel } from '../dashboard_model'; import ScrollBar from 'app/core/components/ScrollBar/ScrollBar'; import store from 'app/core/store'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; @@ -11,7 +11,7 @@ import Highlighter from 'react-highlight-words'; export interface AddPanelPanelProps { panel: PanelModel; - getPanelContainer: () => PanelContainer; + dashboard: DashboardModel; } export interface AddPanelPanelState { @@ -93,8 +93,7 @@ export class AddPanelPanel extends React.Component { - const panelContainer = this.props.getPanelContainer(); - const dashboard = panelContainer.getDashboard(); + const dashboard = this.props.dashboard; const { gridPos } = this.props.panel; const newPanel: any = { @@ -123,9 +122,7 @@ export class AddPanelPanel extends React.Component 0 ? size.width : lastGridWidth; if (width !== lastGridWidth) { - onWidthChange(); - lastGridWidth = width; + if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) { + onWidthChange(); + lastGridWidth = width; + } } return ( @@ -60,18 +58,15 @@ function GridWrapper({ const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper); export interface DashboardGridProps { - getPanelContainer: () => PanelContainer; + dashboard: DashboardModel; } export class DashboardGrid extends React.Component { gridToPanelMap: any; - panelContainer: PanelContainer; - dashboard: DashboardModel; panelMap: { [id: string]: PanelModel }; constructor(props) { super(props); - this.panelContainer = this.props.getPanelContainer(); this.onLayoutChange = this.onLayoutChange.bind(this); this.onResize = this.onResize.bind(this); this.onResizeStop = this.onResizeStop.bind(this); @@ -81,20 +76,21 @@ export class DashboardGrid extends React.Component { this.state = { animated: false }; // subscribe to dashboard events - this.dashboard = this.panelContainer.getDashboard(); - this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this)); - this.dashboard.on('panel-removed', this.triggerForceUpdate.bind(this)); - this.dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this)); - this.dashboard.on('view-mode-changed', this.triggerForceUpdate.bind(this)); - this.dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this)); - this.dashboard.on('row-expanded', this.triggerForceUpdate.bind(this)); + const dashboard = this.props.dashboard; + dashboard.on('panel-added', this.triggerForceUpdate.bind(this)); + dashboard.on('panel-removed', this.triggerForceUpdate.bind(this)); + dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this)); + dashboard.on('view-mode-changed', this.onViewModeChanged.bind(this)); + dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this)); + dashboard.on('row-expanded', this.triggerForceUpdate.bind(this)); + dashboard.on('panel-type-changed', this.triggerForceUpdate.bind(this)); } buildLayout() { const layout = []; this.panelMap = {}; - for (const panel of this.dashboard.panels) { + for (const panel of this.props.dashboard.panels) { const stringId = panel.id.toString(); this.panelMap[stringId] = panel; @@ -129,7 +125,7 @@ export class DashboardGrid extends React.Component { this.panelMap[newPos.i].updateGridPos(newPos); } - this.dashboard.sortPanelsByGridPos(); + this.props.dashboard.sortPanelsByGridPos(); } triggerForceUpdate() { @@ -137,11 +133,15 @@ export class DashboardGrid extends React.Component { } onWidthChange() { - for (const panel of this.dashboard.panels) { + for (const panel of this.props.dashboard.panels) { panel.resizeDone(); } } + onViewModeChanged(payload) { + this.setState({ animated: !payload.fullscreen }); + } + updateGridPos(item, layout) { this.panelMap[item.i].updateGridPos(item); @@ -165,21 +165,18 @@ export class DashboardGrid extends React.Component { componentDidMount() { setTimeout(() => { - this.setState(() => { - return { animated: true }; - }); + this.setState({ animated: true }); }); } renderPanels() { const panelElements = []; - for (const panel of this.dashboard.panels) { + for (const panel of this.props.dashboard.panels) { const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen }); panelElements.push( - /** panel-id is set for html bookmarks */ -
- +
+
); } @@ -192,13 +189,14 @@ export class DashboardGrid extends React.Component { {this.renderPanels()} diff --git a/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts b/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts index 366b04a01f5..a8e4b0dcae9 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts +++ b/public/app/features/dashboard/dashgrid/DashboardGridDirective.ts @@ -1,6 +1,4 @@ import { react2AngularDirective } from 'app/core/utils/react2angular'; import { DashboardGrid } from './DashboardGrid'; -react2AngularDirective('dashboardGrid', DashboardGrid, [ - ['getPanelContainer', { watchDepth: 'reference', wrapApply: false }], -]); +react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]); diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 27fe64d4660..7dd8a06996d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -1,54 +1,161 @@ import React from 'react'; -import {PanelModel} from '../panel_model'; -import {PanelContainer} from './PanelContainer'; -import {AttachedPanel} from './PanelLoader'; -import {DashboardRow} from './DashboardRow'; -import {AddPanelPanel} from './AddPanelPanel'; +import config from 'app/core/config'; +import { PanelModel } from '../panel_model'; +import { DashboardModel } from '../dashboard_model'; +import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; +import { DashboardRow } from './DashboardRow'; +import { AddPanelPanel } from './AddPanelPanel'; +import { importPluginModule } from 'app/features/plugins/plugin_loader'; +import { PluginExports, PanelPlugin } from 'app/types/plugins'; +import { PanelChrome } from './PanelChrome'; +import { PanelEditor } from './PanelEditor'; -export interface DashboardPanelProps { +export interface Props { + panelType: string; panel: PanelModel; - getPanelContainer: () => PanelContainer; + dashboard: DashboardModel; } -export class DashboardPanel extends React.Component { +export interface State { + pluginExports: PluginExports; +} + +export class DashboardPanel extends React.Component { element: any; - attachedPanel: AttachedPanel; + angularPanel: AngularComponent; + pluginInfo: any; + specialPanels = {}; constructor(props) { super(props); - this.state = {}; + + this.state = { + pluginExports: null, + }; + + this.specialPanels['row'] = this.renderRow.bind(this); + this.specialPanels['add-panel'] = this.renderAddPanel.bind(this); } - componentDidMount() { - if (!this.element) { + isSpecial() { + return this.specialPanels[this.props.panel.type]; + } + + renderRow() { + return ; + } + + renderAddPanel() { + return ; + } + + onPluginTypeChanged = (plugin: PanelPlugin) => { + this.props.panel.changeType(plugin.id); + this.loadPlugin(); + }; + + onAngularPluginTypeChanged = () => { + this.loadPlugin(); + }; + + loadPlugin() { + if (this.isSpecial()) { return; } - const panelContainer = this.props.getPanelContainer(); - const dashboard = panelContainer.getDashboard(); - const loader = panelContainer.getPanelLoader(); - this.attachedPanel = loader.load(this.element, this.props.panel, dashboard); + // handle plugin loading & changing of plugin type + if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) { + this.pluginInfo = config.panels[this.props.panel.type]; + + if (this.pluginInfo.exports) { + this.cleanUpAngularPanel(); + this.setState({ pluginExports: this.pluginInfo.exports }); + } else { + importPluginModule(this.pluginInfo.module).then(pluginExports => { + this.cleanUpAngularPanel(); + // cache plugin exports (saves a promise async cycle next time) + this.pluginInfo.exports = pluginExports; + // update panel state + this.setState({ pluginExports: pluginExports }); + }); + } + } + } + + componentDidMount() { + this.loadPlugin(); + } + + componentDidUpdate() { + this.loadPlugin(); + + // handle angular plugin loading + if (!this.element || this.angularPanel) { + return; + } + + const loader = getAngularLoader(); + const template = ''; + const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard }; + this.angularPanel = loader.load(this.element, scopeProps, template); + } + + cleanUpAngularPanel() { + if (this.angularPanel) { + this.angularPanel.destroy(); + this.angularPanel = null; + } } componentWillUnmount() { - if (this.attachedPanel) { - this.attachedPanel.destroy(); - } + this.cleanUpAngularPanel(); + } + + renderReactPanel() { + const { pluginExports } = this.state; + const containerClass = this.props.panel.isEditing ? 'panel-editor-container' : 'panel-height-helper'; + const panelWrapperClass = this.props.panel.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper'; + + // this might look strange with these classes that change when edit, but + // I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel + return ( +
+
+ +
+ {this.props.panel.isEditing && ( +
+ +
+ )} +
+ ); } render() { - // special handling for rows - if (this.props.panel.type === 'row') { - return ; + if (this.isSpecial()) { + return this.specialPanels[this.props.panel.type](); } - if (this.props.panel.type === 'add-panel') { - return ; + if (!this.state.pluginExports) { + return null; } - return ( -
this.element = element} className="panel-height-helper" /> - ); + if (this.state.pluginExports.PanelComponent) { + return this.renderReactPanel(); + } + + // legacy angular rendering + return
(this.element = element)} className="panel-height-helper" />; } } - diff --git a/public/app/features/dashboard/dashgrid/DashboardRow.tsx b/public/app/features/dashboard/dashgrid/DashboardRow.tsx index 378cf4c2c7c..5b8ced9b2b1 100644 --- a/public/app/features/dashboard/dashgrid/DashboardRow.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardRow.tsx @@ -1,19 +1,16 @@ import React from 'react'; import classNames from 'classnames'; import { PanelModel } from '../panel_model'; -import { PanelContainer } from './PanelContainer'; +import { DashboardModel } from '../dashboard_model'; import templateSrv from 'app/features/templating/template_srv'; import appEvents from 'app/core/app_events'; export interface DashboardRowProps { panel: PanelModel; - getPanelContainer: () => PanelContainer; + dashboard: DashboardModel; } export class DashboardRow extends React.Component { - dashboard: any; - panelContainer: any; - constructor(props) { super(props); @@ -21,9 +18,6 @@ export class DashboardRow extends React.Component { collapsed: this.props.panel.collapsed, }; - this.panelContainer = this.props.getPanelContainer(); - this.dashboard = this.panelContainer.getDashboard(); - this.toggle = this.toggle.bind(this); this.openSettings = this.openSettings.bind(this); this.delete = this.delete.bind(this); @@ -31,7 +25,7 @@ export class DashboardRow extends React.Component { } toggle() { - this.dashboard.toggleRow(this.props.panel); + this.props.dashboard.toggleRow(this.props.panel); this.setState(prevState => { return { collapsed: !prevState.collapsed }; @@ -39,7 +33,7 @@ export class DashboardRow extends React.Component { } update() { - this.dashboard.processRepeats(); + this.props.dashboard.processRepeats(); this.forceUpdate(); } @@ -61,14 +55,10 @@ export class DashboardRow extends React.Component { altActionText: 'Delete row only', icon: 'fa-trash', onConfirm: () => { - const panelContainer = this.props.getPanelContainer(); - const dashboard = panelContainer.getDashboard(); - dashboard.removeRow(this.props.panel, true); + this.props.dashboard.removeRow(this.props.panel, true); }, onAltAction: () => { - const panelContainer = this.props.getPanelContainer(); - const dashboard = panelContainer.getDashboard(); - dashboard.removeRow(this.props.panel, false); + this.props.dashboard.removeRow(this.props.panel, false); }, }); } @@ -87,7 +77,7 @@ export class DashboardRow extends React.Component { const title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars); const count = this.props.panel.panels ? this.props.panel.panels.length : 0; const panels = count === 1 ? 'panel' : 'panels'; - const canEdit = this.dashboard.meta.canEdit === true; + const canEdit = this.props.dashboard.meta.canEdit === true; return (
diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx new file mode 100644 index 00000000000..d0122363668 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx @@ -0,0 +1,151 @@ +// Library +import React, { Component } from 'react'; + +// Services +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; + +// Types +import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types'; + +interface RenderProps { + loading: LoadingState; + timeSeries: TimeSeries[]; +} + +export interface Props { + datasource: string | null; + queries: any[]; + panelId?: number; + dashboardId?: number; + isVisible?: boolean; + timeRange?: TimeRange; + refreshCounter: number; + children: (r: RenderProps) => JSX.Element; +} + +export interface State { + isFirstLoad: boolean; + loading: LoadingState; + response: DataQueryResponse; +} + +export class DataPanel extends Component { + static defaultProps = { + isVisible: true, + panelId: 1, + dashboardId: 1, + }; + + constructor(props: Props) { + super(props); + + this.state = { + loading: LoadingState.NotStarted, + response: { + data: [], + }, + isFirstLoad: true, + }; + } + + componentDidMount() { + console.log('DataPanel mount'); + } + + async componentDidUpdate(prevProps: Props) { + if (!this.hasPropsChanged(prevProps)) { + return; + } + + this.issueQueries(); + } + + hasPropsChanged(prevProps: Props) { + return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible; + } + + issueQueries = async () => { + const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props; + + if (!isVisible) { + return; + } + + if (!queries.length) { + this.setState({ loading: LoadingState.Done }); + return; + } + + this.setState({ loading: LoadingState.Loading }); + + try { + const dataSourceSrv = getDatasourceSrv(); + const ds = await dataSourceSrv.get(datasource); + + const queryOptions: DataQueryOptions = { + timezone: 'browser', + panelId: panelId, + dashboardId: dashboardId, + range: timeRange, + rangeRaw: timeRange.raw, + interval: '1s', + intervalMs: 60000, + targets: queries, + maxDataPoints: 500, + scopedVars: {}, + cacheTimeout: null, + }; + + console.log('Issuing DataPanel query', queryOptions); + const resp = await ds.query(queryOptions); + console.log('Issuing DataPanel query Resp', resp); + + this.setState({ + loading: LoadingState.Done, + response: resp, + isFirstLoad: false, + }); + } catch (err) { + console.log('Loading error', err); + this.setState({ loading: LoadingState.Error, isFirstLoad: false }); + } + }; + + render() { + const { response, loading, isFirstLoad } = this.state; + console.log('data panel render'); + const timeSeries = response.data; + + if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) { + return ( +
+

Loading

+
+ ); + } + + return ( + <> + {this.loadingSpinner} + {this.props.children({ + timeSeries, + loading, + })} + + ); + } + + private get loadingSpinner(): JSX.Element { + const { loading } = this.state; + + if (loading === LoadingState.Loading) { + return ( +
+ +
+ ); + } + + return null; + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx new file mode 100644 index 00000000000..82b366d8126 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -0,0 +1,84 @@ +// Libraries +import React, { ComponentClass, PureComponent } from 'react'; + +// Services +import { getTimeSrv } from '../time_srv'; + +// Components +import { PanelHeader } from './PanelHeader'; +import { DataPanel } from './DataPanel'; + +// Types +import { PanelModel } from '../panel_model'; +import { DashboardModel } from '../dashboard_model'; +import { TimeRange, PanelProps } from 'app/types'; + +export interface Props { + panel: PanelModel; + dashboard: DashboardModel; + component: ComponentClass; +} + +export interface State { + refreshCounter: number; + timeRange?: TimeRange; +} + +export class PanelChrome extends PureComponent { + constructor(props) { + super(props); + + this.state = { + refreshCounter: 0, + }; + } + + componentDidMount() { + this.props.panel.events.on('refresh', this.onRefresh); + this.props.dashboard.panelInitialized(this.props.panel); + } + + componentWillUnmount() { + this.props.panel.events.off('refresh', this.onRefresh); + } + + onRefresh = () => { + const timeSrv = getTimeSrv(); + const timeRange = timeSrv.timeRange(); + + this.setState({ + refreshCounter: this.state.refreshCounter + 1, + timeRange: timeRange, + }); + }; + + get isVisible() { + return !this.props.dashboard.otherPanelInFullscreen(this.props.panel); + } + + render() { + const { panel, dashboard } = this.props; + const { datasource, targets } = panel; + const { refreshCounter, timeRange } = this.state; + const PanelComponent = this.props.component; + + return ( +
+ +
+ + {({ loading, timeSeries }) => { + return ; + }} + +
+
+ ); + } +} diff --git a/public/app/features/dashboard/dashgrid/PanelContainer.ts b/public/app/features/dashboard/dashgrid/PanelContainer.ts deleted file mode 100644 index 87f3235a176..00000000000 --- a/public/app/features/dashboard/dashgrid/PanelContainer.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DashboardModel } from '../dashboard_model'; -import { PanelLoader } from './PanelLoader'; - -export interface PanelContainer { - getPanelLoader(): PanelLoader; - getDashboard(): DashboardModel; -} diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/dashgrid/PanelEditor.tsx new file mode 100644 index 00000000000..26ac8b7d2c1 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelEditor.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import classNames from 'classnames'; +import { PanelModel } from '../panel_model'; +import { DashboardModel } from '../dashboard_model'; +import { store } from 'app/store/configureStore'; +import { QueriesTab } from './QueriesTab'; +import { PanelPlugin, PluginExports } from 'app/types/plugins'; +import { VizTypePicker } from './VizTypePicker'; +import { updateLocation } from 'app/core/actions'; + +interface PanelEditorProps { + panel: PanelModel; + dashboard: DashboardModel; + panelType: string; + pluginExports: PluginExports; + onTypeChanged: (newType: PanelPlugin) => void; +} + +interface PanelEditorTab { + id: string; + text: string; + icon: string; +} + +export class PanelEditor extends React.Component { + tabs: PanelEditorTab[]; + + constructor(props) { + super(props); + + this.tabs = [ + { id: 'queries', text: 'Queries', icon: 'fa fa-database' }, + { id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' }, + ]; + } + + renderQueriesTab() { + return ; + } + + renderPanelOptions() { + const { pluginExports } = this.props; + + if (pluginExports.PanelOptions) { + const PanelOptions = pluginExports.PanelOptions; + return ; + } else { + return

Visualization has no options

; + } + } + + renderVizTab() { + return ( +
+
+ +
+
+
Options
+ {this.renderPanelOptions()} +
+
+ ); + } + + onChangeTab = (tab: PanelEditorTab) => { + store.dispatch( + updateLocation({ + query: { tab: tab.id }, + partial: true, + }) + ); + }; + + render() { + const { location } = store.getState(); + const activeTab = location.query.tab || 'queries'; + + return ( +
+
+
    + {this.tabs.map(tab => { + return ; + })} +
+ + +
+ +
+ {activeTab === 'queries' && this.renderQueriesTab()} + {activeTab === 'visualization' && this.renderVizTab()} +
+
+ ); + } +} + +interface TabItemParams { + tab: PanelEditorTab; + activeTab: string; + onClick: (tab: PanelEditorTab) => void; +} + +function TabItem({ tab, activeTab, onClick }: TabItemParams) { + const tabClasses = classNames({ + 'gf-tabs-link': true, + active: activeTab === tab.id, + }); + + return ( +
  • + onClick(tab)}> + {tab.text} + +
  • + ); +} diff --git a/public/app/features/dashboard/dashgrid/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader.tsx new file mode 100644 index 00000000000..12d5cd37253 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import classNames from 'classnames'; +import { PanelModel } from '../panel_model'; +import { DashboardModel } from '../dashboard_model'; +import { store } from 'app/store/configureStore'; +import { updateLocation } from 'app/core/actions'; + +interface PanelHeaderProps { + panel: PanelModel; + dashboard: DashboardModel; +} + +export class PanelHeader extends React.Component { + onEditPanel = () => { + store.dispatch( + updateLocation({ + query: { + panelId: this.props.panel.id, + edit: true, + fullscreen: true, + }, + }) + ); + }; + + onViewPanel = () => { + store.dispatch( + updateLocation({ + query: { + panelId: this.props.panel.id, + edit: false, + fullscreen: true, + }, + }) + ); + }; + + render() { + const isFullscreen = false; + const isLoading = false; + const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen }); + + return ( +
    + + + + + + {isLoading && ( + + + + )} + +
    + + + {this.props.panel.title} + + + + + + 4m + + +
    +
    + ); + } +} diff --git a/public/app/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx new file mode 100644 index 00000000000..f13f212826a --- /dev/null +++ b/public/app/features/dashboard/dashgrid/QueriesTab.tsx @@ -0,0 +1,53 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Services & utils +import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; + +// Types +import { PanelModel } from '../panel_model'; +import { DashboardModel } from '../dashboard_model'; + +interface Props { + panel: PanelModel; + dashboard: DashboardModel; +} + +export class QueriesTab extends PureComponent { + element: any; + component: AngularComponent; + + constructor(props) { + super(props); + } + + componentDidMount() { + if (!this.element) { + return; + } + + const { panel, dashboard } = this.props; + + const loader = getAngularLoader(); + const template = ''; + const scopeProps = { + ctrl: { + panel: panel, + dashboard: dashboard, + refresh: () => panel.refresh(), + }, + }; + + this.component = loader.load(this.element, scopeProps, template); + } + + componentWillUnmount() { + if (this.component) { + this.component.destroy(); + } + } + + render() { + return
    (this.element = element)} className="panel-height-helper" />; + } +} diff --git a/public/app/features/dashboard/dashgrid/VizTypePicker.tsx b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx new file mode 100644 index 00000000000..9402133df34 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx @@ -0,0 +1,69 @@ +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import config from 'app/core/config'; +import { PanelPlugin } from 'app/types/plugins'; +import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar'; +import _ from 'lodash'; + +interface Props { + currentType: string; + onTypeChanged: (newType: PanelPlugin) => void; +} + +interface State { + pluginList: PanelPlugin[]; +} + +export class VizTypePicker extends PureComponent { + constructor(props) { + super(props); + + this.state = { + pluginList: this.getPanelPlugins(''), + }; + } + + getPanelPlugins(filter) { + const panels = _.chain(config.panels) + .filter({ hideFromList: false }) + .map(item => item) + .value(); + + // add sort by sort property + return _.sortBy(panels, 'sort'); + } + + renderVizPlugin = (plugin, index) => { + const cssClass = classNames({ + 'viz-picker__item': true, + 'viz-picker__item--selected': plugin.id === this.props.currentType, + }); + + return ( +
    this.props.onTypeChanged(plugin)} title={plugin.name}> + +
    {plugin.name}
    +
    + ); + }; + + render() { + return ( +
    +
    +
    + +
    +
    +
    + +
    {this.state.pluginList.map(this.renderVizPlugin)}
    +
    +
    +
    + ); + } +} diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index c4095e7948b..7312d6db784 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -42,6 +42,8 @@ export class DashNavCtrl { } else if (search.fullscreen) { delete search.fullscreen; delete search.edit; + delete search.tab; + delete search.panelId; } this.$location.search(search); } diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts index 9a1e7fb9200..ebf8a6bb224 100644 --- a/public/app/features/dashboard/panel_model.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -13,6 +13,13 @@ const notPersistedProperties: { [str: string]: boolean } = { events: true, fullscreen: true, isEditing: true, + hasRefreshed: true, +}; + +const defaults: any = { + gridPos: { x: 0, y: 0, h: 3, w: 6 }, + datasource: null, + targets: [{}], }; export class PanelModel { @@ -31,10 +38,14 @@ export class PanelModel { collapsed?: boolean; panels?: any; soloMode?: boolean; + targets: any[]; + datasource: string; + thresholds?: any; // non persisted fullscreen: boolean; isEditing: boolean; + hasRefreshed: boolean; events: Emitter; constructor(model) { @@ -45,9 +56,8 @@ export class PanelModel { this[property] = model[property]; } - if (!this.gridPos) { - this.gridPos = { x: 0, y: 0, h: 3, w: 6 }; - } + // defaults + _.defaultsDeep(this, _.cloneDeep(defaults)); } getSaveModel() { @@ -57,6 +67,10 @@ export class PanelModel { continue; } + if (_.isEqual(this[property], defaults[property])) { + continue; + } + model[property] = _.cloneDeep(this[property]); } @@ -82,7 +96,6 @@ export class PanelModel { this.gridPos.h = newPos.h; if (sizeChanged) { - console.log('PanelModel sizeChanged event and render events fired'); this.events.emit('panel-size-changed'); } } @@ -91,6 +104,34 @@ export class PanelModel { this.events.emit('panel-size-changed'); } + refresh() { + this.hasRefreshed = true; + this.events.emit('refresh'); + } + + render() { + if (!this.hasRefreshed) { + this.refresh(); + } else { + this.events.emit('render'); + } + } + + panelInitialized() { + this.events.emit('panel-initialized'); + } + + initEditMode() { + this.events.emit('panel-init-edit-mode'); + } + + changeType(pluginId: string) { + this.type = pluginId; + + delete this.thresholds; + delete this.alert; + } + destroy() { this.events.removeAllListeners(); } diff --git a/public/app/features/dashboard/permissions/DashboardPermissions.tsx b/public/app/features/dashboard/permissions/DashboardPermissions.tsx index 5651242a485..c07bef42930 100644 --- a/public/app/features/dashboard/permissions/DashboardPermissions.tsx +++ b/public/app/features/dashboard/permissions/DashboardPermissions.tsx @@ -1,5 +1,4 @@ import React, { PureComponent } from 'react'; -import { connect } from 'react-redux'; import Tooltip from 'app/core/components/Tooltip/Tooltip'; import SlideDown from 'app/core/components/Animations/SlideDown'; import { StoreState, FolderInfo } from 'app/types'; @@ -13,7 +12,7 @@ import { import PermissionList from 'app/core/components/PermissionList/PermissionList'; import AddPermission from 'app/core/components/PermissionList/AddPermission'; import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; -import { store } from 'app/store/configureStore'; +import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; export interface Props { dashboardId: number; @@ -95,13 +94,6 @@ export class DashboardPermissions extends PureComponent { } } -function connectWithStore(WrappedComponent, ...args) { - const ConnectedWrappedComponent = connect(...args)(WrappedComponent); - return props => { - return ; - }; -} - const mapStateToProps = (state: StoreState) => ({ permissions: state.dashboard.permissions, }); diff --git a/public/app/features/dashboard/settings/settings.ts b/public/app/features/dashboard/settings/settings.ts index 048a51efead..b6a70ee4b98 100755 --- a/public/app/features/dashboard/settings/settings.ts +++ b/public/app/features/dashboard/settings/settings.ts @@ -32,7 +32,7 @@ export class SettingsCtrl { this.$scope.$on('$destroy', () => { this.dashboard.updateSubmenuVisibility(); - this.$rootScope.$broadcast('refresh'); + this.dashboard.startRefresh(); setTimeout(() => { this.$rootScope.appEvent('dash-scroll', { restore: true }); }); diff --git a/public/app/features/dashboard/share_snapshot_ctrl.ts b/public/app/features/dashboard/share_snapshot_ctrl.ts index ec487801948..ac09d63054d 100644 --- a/public/app/features/dashboard/share_snapshot_ctrl.ts +++ b/public/app/features/dashboard/share_snapshot_ctrl.ts @@ -46,8 +46,7 @@ export class ShareSnapshotCtrl { $scope.loading = true; $scope.snapshot.external = external; - - $rootScope.$broadcast('refresh'); + $scope.dashboard.startRefresh(); $timeout(() => { $scope.saveSnapshot(external); diff --git a/public/app/features/dashboard/specs/AddPanelPanel.test.tsx b/public/app/features/dashboard/specs/AddPanelPanel.test.tsx index 872d9296d12..c5f66fed32a 100644 --- a/public/app/features/dashboard/specs/AddPanelPanel.test.tsx +++ b/public/app/features/dashboard/specs/AddPanelPanel.test.tsx @@ -14,7 +14,7 @@ jest.mock('app/core/store', () => ({ })); describe('AddPanelPanel', () => { - let wrapper, dashboardMock, getPanelContainer, panel; + let wrapper, dashboardMock, panel; beforeEach(() => { config.panels = [ @@ -23,6 +23,9 @@ describe('AddPanelPanel', () => { hideFromList: false, name: 'Singlestat', sort: 2, + module: '', + baseUrl: '', + meta: {}, info: { logos: { small: '', @@ -34,6 +37,9 @@ describe('AddPanelPanel', () => { hideFromList: true, name: 'Hidden', sort: 100, + meta: {}, + module: '', + baseUrl: '', info: { logos: { small: '', @@ -45,6 +51,9 @@ describe('AddPanelPanel', () => { hideFromList: false, name: 'Graph', sort: 1, + meta: {}, + module: '', + baseUrl: '', info: { logos: { small: '', @@ -56,6 +65,9 @@ describe('AddPanelPanel', () => { hideFromList: false, name: 'Zabbix', sort: 100, + meta: {}, + module: '', + baseUrl: '', info: { logos: { small: '', @@ -67,6 +79,9 @@ describe('AddPanelPanel', () => { hideFromList: false, name: 'Piechart', sort: 100, + meta: {}, + module: '', + baseUrl: '', info: { logos: { small: '', @@ -77,13 +92,8 @@ describe('AddPanelPanel', () => { dashboardMock = { toggleRow: jest.fn() }; - getPanelContainer = jest.fn().mockReturnValue({ - getDashboard: jest.fn().mockReturnValue(dashboardMock), - getPanelLoader: jest.fn(), - }); - panel = new PanelModel({ collapsed: false }); - wrapper = shallow(); + wrapper = shallow(); }); it('should fetch all panels sorted with core plugins first', () => { diff --git a/public/app/features/dashboard/specs/DashboardRow.test.tsx b/public/app/features/dashboard/specs/DashboardRow.test.tsx index 3d89c22f962..77c6cb39d9d 100644 --- a/public/app/features/dashboard/specs/DashboardRow.test.tsx +++ b/public/app/features/dashboard/specs/DashboardRow.test.tsx @@ -4,7 +4,7 @@ import { DashboardRow } from '../dashgrid/DashboardRow'; import { PanelModel } from '../panel_model'; describe('DashboardRow', () => { - let wrapper, panel, getPanelContainer, dashboardMock; + let wrapper, panel, dashboardMock; beforeEach(() => { dashboardMock = { @@ -14,13 +14,8 @@ describe('DashboardRow', () => { }, }; - getPanelContainer = jest.fn().mockReturnValue({ - getDashboard: jest.fn().mockReturnValue(dashboardMock), - getPanelLoader: jest.fn(), - }); - panel = new PanelModel({ collapsed: false }); - wrapper = shallow(); + wrapper = shallow(); }); it('Should not have collapsed class when collaped is false', () => { @@ -41,14 +36,14 @@ describe('DashboardRow', () => { it('should not show row drag handle when cannot edit', () => { dashboardMock.meta.canEdit = false; - wrapper = shallow(); + wrapper = shallow(); expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0); }); it('should have zero actions when cannot edit', () => { dashboardMock.meta.canEdit = false; panel = new PanelModel({ collapsed: false }); - wrapper = shallow(); + wrapper = shallow(); expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0); }); }); diff --git a/public/app/features/dashboard/specs/exporter.test.ts b/public/app/features/dashboard/specs/exporter.test.ts index c7a232f925b..f21e151f3dd 100644 --- a/public/app/features/dashboard/specs/exporter.test.ts +++ b/public/app/features/dashboard/specs/exporter.test.ts @@ -240,5 +240,5 @@ stubs['-- Grafana --'] = { }; function getStub(arg) { - return Promise.resolve(stubs[arg]); + return Promise.resolve(stubs[arg || 'gfdb']); } diff --git a/public/app/features/dashboard/specs/viewstate_srv.test.ts b/public/app/features/dashboard/specs/viewstate_srv.test.ts index 905ffb8b355..f9963afbf85 100644 --- a/public/app/features/dashboard/specs/viewstate_srv.test.ts +++ b/public/app/features/dashboard/specs/viewstate_srv.test.ts @@ -2,6 +2,7 @@ import 'app/features/dashboard/view_state_srv'; import config from 'app/core/config'; import { DashboardViewState } from '../view_state_srv'; +import { DashboardModel } from '../dashboard_model'; describe('when updating view state', () => { const location = { @@ -10,14 +11,13 @@ describe('when updating view state', () => { }; const $scope = { + appEvent: jest.fn(), onAppEvent: jest.fn(() => {}), - dashboard: { - meta: {}, - panels: [], - }, + dashboard: new DashboardModel({ + panels: [{ id: 1 }], + }), }; - const $rootScope = {}; let viewState; beforeEach(() => { @@ -33,7 +33,7 @@ describe('when updating view state', () => { location.search = jest.fn(() => { return { fullscreen: true, edit: true, panelId: 1 }; }); - viewState = new DashboardViewState($scope, location, {}, $rootScope); + viewState = new DashboardViewState($scope, location, {}); }); it('should update querystring and view state', () => { @@ -55,7 +55,7 @@ describe('when updating view state', () => { describe('to fullscreen false', () => { beforeEach(() => { - viewState = new DashboardViewState($scope, location, {}, $rootScope); + viewState = new DashboardViewState($scope, location, {}); }); it('should remove params from query string', () => { viewState.update({ fullscreen: true, panelId: 1, edit: true }); diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 9e923f6bcb7..749edef58fb 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -1,7 +1,8 @@ import { StoreState } from 'app/types'; import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from 'app/core/services/backend_srv'; - +import appEvents from 'app/core/app_events'; +import { loadPluginDashboards } from '../../plugins/state/actions'; import { DashboardAcl, DashboardAclDTO, @@ -113,3 +114,18 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar await dispatch(getDashboardPermissions(dashboardId)); }; } + +export function importDashboard(data, dashboardTitle: string): ThunkResult { + return async dispatch => { + await getBackendSrv().post('/api/dashboards/import', data); + appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]); + dispatch(loadPluginDashboards()); + }; +} + +export function removeDashboard(uri: string): ThunkResult { + return async dispatch => { + await getBackendSrv().delete(`/api/dashboards/${uri}`); + dispatch(loadPluginDashboards()); + }; +} diff --git a/public/app/features/dashboard/submenu/submenu.ts b/public/app/features/dashboard/submenu/submenu.ts index e1288b2b2ed..184d29facee 100644 --- a/public/app/features/dashboard/submenu/submenu.ts +++ b/public/app/features/dashboard/submenu/submenu.ts @@ -7,13 +7,13 @@ export class SubmenuCtrl { dashboard: any; /** @ngInject */ - constructor(private $rootScope, private variableSrv, private $location) { + constructor(private variableSrv, private $location) { this.annotations = this.dashboard.templating.list; this.variables = this.variableSrv.variables; } annotationStateChanged() { - this.$rootScope.$broadcast('refresh'); + this.dashboard.startRefresh(); } variableUpdated(variable) { diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/time_srv.ts index a96bc89daa7..03b4a408125 100644 --- a/public/app/features/dashboard/time_srv.ts +++ b/public/app/features/dashboard/time_srv.ts @@ -1,8 +1,14 @@ +// Libraries import moment from 'moment'; import _ from 'lodash'; -import coreModule from 'app/core/core_module'; + +// Utils import kbn from 'app/core/utils/kbn'; +import coreModule from 'app/core/core_module'; import * as dateMath from 'app/core/utils/datemath'; +// Types + +import { TimeRange } from 'app/types'; export class TimeSrv { time: any; @@ -24,7 +30,6 @@ export class TimeSrv { document.addEventListener('visibilitychange', () => { if (this.autoRefreshBlocked && document.visibilityState === 'visible') { this.autoRefreshBlocked = false; - this.refreshDashboard(); } }); @@ -142,7 +147,7 @@ export class TimeSrv { } refreshDashboard() { - this.$rootScope.$broadcast('refresh'); + this.dashboard.timeRangeUpdated(); } private startNextRefreshTimer(afterMs) { @@ -201,7 +206,7 @@ export class TimeSrv { return range; } - timeRange() { + timeRange(): TimeRange { // make copies if they are moment (do not want to return out internal moment, because they are mutable!) const raw = { from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from, @@ -223,17 +228,21 @@ export class TimeSrv { const timespan = range.to.valueOf() - range.from.valueOf(); const center = range.to.valueOf() - timespan / 2; - let to = center + timespan * factor / 2; - let from = center - timespan * factor / 2; - - if (to > Date.now() && range.to <= Date.now()) { - const offset = to - Date.now(); - from = from - offset; - to = Date.now(); - } + const to = center + timespan * factor / 2; + const from = center - timespan * factor / 2; this.setTime({ from: moment.utc(from), to: moment.utc(to) }); } } +let singleton; + +export function setTimeSrv(srv: TimeSrv) { + singleton = srv; +} + +export function getTimeSrv(): TimeSrv { + return singleton; +} + coreModule.service('timeSrv', TimeSrv); diff --git a/public/app/features/dashboard/timepicker/settings.html b/public/app/features/dashboard/timepicker/settings.html index 3cb8ca061fb..fd5170013c2 100644 --- a/public/app/features/dashboard/timepicker/settings.html +++ b/public/app/features/dashboard/timepicker/settings.html @@ -5,7 +5,7 @@
    - +
    diff --git a/public/app/features/dashboard/timepicker/timepicker.ts b/public/app/features/dashboard/timepicker/timepicker.ts index c133203cefc..c89e49b54b3 100644 --- a/public/app/features/dashboard/timepicker/timepicker.ts +++ b/public/app/features/dashboard/timepicker/timepicker.ts @@ -31,9 +31,10 @@ export class TimePickerCtrl { $rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope); $rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope); - $rootScope.onAppEvent('refresh', this.onRefresh.bind(this), $scope); $rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope); + this.dashboard.on('refresh', this.onRefresh.bind(this), $scope); + // init options this.panel = this.dashboard.timepicker; _.defaults(this.panel, TimePickerCtrl.defaults); diff --git a/public/app/features/dashboard/upload.ts b/public/app/features/dashboard/upload.ts index 42871327eb6..ec4ad9a03cb 100644 --- a/public/app/features/dashboard/upload.ts +++ b/public/app/features/dashboard/upload.ts @@ -11,7 +11,7 @@ const template = ` `; /** @ngInject */ -function uploadDashboardDirective(timer, alertSrv, $location) { +function uploadDashboardDirective(timer, $location) { return { restrict: 'E', template: template, @@ -59,7 +59,7 @@ function uploadDashboardDirective(timer, alertSrv, $location) { // Something elem[0].addEventListener('change', file_selected, false); } else { - alertSrv.set('Oops', 'Sorry, the HTML5 File APIs are not fully supported in this browser.', 'error'); + appEvents.emit('alert-error', ['Oops', 'The HTML5 File APIs are not fully supported in this browser']); } }, }; diff --git a/public/app/features/dashboard/view_state_srv.ts b/public/app/features/dashboard/view_state_srv.ts index d9ad6827567..8805050831e 100644 --- a/public/app/features/dashboard/view_state_srv.ts +++ b/public/app/features/dashboard/view_state_srv.ts @@ -1,6 +1,7 @@ import angular from 'angular'; import _ from 'lodash'; import config from 'app/core/config'; +import appEvents from 'app/core/app_events'; import { DashboardModel } from './dashboard_model'; // represents the transient view state @@ -10,12 +11,11 @@ export class DashboardViewState { panelScopes: any; $scope: any; dashboard: DashboardModel; - editStateChanged: any; fullscreenPanel: any; oldTimeRange: any; /** @ngInject */ - constructor($scope, private $location, private $timeout, private $rootScope) { + constructor($scope, private $location, private $timeout) { const self = this; self.state = {}; self.panelScopes = []; @@ -33,10 +33,6 @@ export class DashboardViewState { self.update(payload); }); - $scope.onAppEvent('panel-initialized', (evt, payload) => { - self.registerPanel(payload.scope); - }); - // this marks changes to location during this digest cycle as not to add history item // don't want url changes like adding orgId to add browser history $location.replace(); @@ -75,9 +71,6 @@ export class DashboardViewState { } } - // remember if editStateChanged - this.editStateChanged = (state.edit || false) !== (this.state.edit || false); - _.extend(this.state, state); this.dashboard.meta.fullscreen = this.state.fullscreen; @@ -124,110 +117,59 @@ export class DashboardViewState { } syncState() { - if (this.panelScopes.length === 0) { - return; - } - if (this.dashboard.meta.fullscreen) { - const panelScope = this.getPanelScope(this.state.panelId); - if (!panelScope) { + const panel = this.dashboard.getPanelById(this.state.panelId); + + if (!panel) { return; } - if (this.fullscreenPanel) { - // if already fullscreen - if (this.fullscreenPanel === panelScope && this.editStateChanged === false) { - return; - } else { - this.leaveFullscreen(false); - } - } - - if (!panelScope.ctrl.editModeInitiated) { - panelScope.ctrl.initEditMode(); - } - - if (!panelScope.ctrl.fullscreen) { - this.enterFullscreen(panelScope); + if (!panel.fullscreen) { + this.enterFullscreen(panel); + } else { + // already in fullscreen view just update the view mode + this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit); } } else if (this.fullscreenPanel) { - this.leaveFullscreen(true); + this.leaveFullscreen(); } } - getPanelScope(id) { - return _.find(this.panelScopes, panelScope => { - return panelScope.ctrl.panel.id === id; - }); - } + leaveFullscreen() { + const panel = this.fullscreenPanel; - leaveFullscreen(render) { - const self = this; - const ctrl = self.fullscreenPanel.ctrl; + this.dashboard.setViewMode(panel, false, false); - ctrl.editMode = false; - ctrl.fullscreen = false; - - this.dashboard.setViewMode(ctrl.panel, false, false); - this.$scope.appEvent('panel-fullscreen-exit', { panelId: ctrl.panel.id }); - this.$scope.appEvent('dash-scroll', { restore: true }); - - if (!render) { - return false; - } + delete this.fullscreenPanel; this.$timeout(() => { - if (self.oldTimeRange !== ctrl.range) { - self.$rootScope.$broadcast('refresh'); + appEvents.emit('dash-scroll', { restore: true }); + + if (this.oldTimeRange !== this.dashboard.time) { + this.dashboard.startRefresh(); } else { - self.$rootScope.$broadcast('render'); + this.dashboard.render(); } - delete self.fullscreenPanel; }); - return true; } - enterFullscreen(panelScope) { - const ctrl = panelScope.ctrl; + enterFullscreen(panel) { + const isEditing = this.state.edit && this.dashboard.meta.canEdit; - ctrl.editMode = this.state.edit && this.dashboard.meta.canEdit; - ctrl.fullscreen = true; - - this.oldTimeRange = ctrl.range; - this.fullscreenPanel = panelScope; + this.oldTimeRange = this.dashboard.time; + this.fullscreenPanel = panel; // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode() this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 }); - this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode); - this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id }); - } - - registerPanel(panelScope) { - const self = this; - self.panelScopes.push(panelScope); - - if (!self.dashboard.meta.soloMode) { - if (self.state.panelId === panelScope.ctrl.panel.id) { - if (self.state.edit) { - panelScope.ctrl.editPanel(); - } else { - panelScope.ctrl.viewPanel(); - } - } - } - - const unbind = panelScope.$on('$destroy', () => { - self.panelScopes = _.without(self.panelScopes, panelScope); - unbind(); - }); + this.dashboard.setViewMode(panel, true, isEditing); } } /** @ngInject */ -export function dashboardViewStateSrv($location, $timeout, $rootScope) { +export function dashboardViewStateSrv($location, $timeout) { return { create: $scope => { - return new DashboardViewState($scope, $location, $timeout, $rootScope); + return new DashboardViewState($scope, $location, $timeout); }, }; } diff --git a/public/app/features/datasources/DashboardsTable.test.tsx b/public/app/features/datasources/DashboardsTable.test.tsx new file mode 100644 index 00000000000..10a14f464e4 --- /dev/null +++ b/public/app/features/datasources/DashboardsTable.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DashboardsTable, { Props } from './DashboardsTable'; +import { PluginDashboard } from '../../types'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dashboards: [] as PluginDashboard[], + onImport: jest.fn(), + onRemove: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render table', () => { + const wrapper = setup({ + dashboards: [ + { + dashboardId: 0, + description: '', + folderId: 0, + imported: false, + importedRevision: 0, + importedUri: '', + importedUrl: '', + path: 'dashboards/carbon_metrics.json', + pluginId: 'graphite', + removed: false, + revision: 1, + slug: '', + title: 'Graphite Carbon Metrics', + }, + { + dashboardId: 0, + description: '', + folderId: 0, + imported: true, + importedRevision: 0, + importedUri: '', + importedUrl: '', + path: 'dashboards/carbon_metrics.json', + pluginId: 'graphite', + removed: false, + revision: 1, + slug: '', + title: 'Graphite Carbon Metrics', + }, + ], + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/DashboardsTable.tsx new file mode 100644 index 00000000000..b732782c23b --- /dev/null +++ b/public/app/features/datasources/DashboardsTable.tsx @@ -0,0 +1,55 @@ +import React, { SFC } from 'react'; +import { PluginDashboard } from '../../types'; + +export interface Props { + dashboards: PluginDashboard[]; + onImport: (dashboard, overwrite) => void; + onRemove: (dashboard) => void; +} + +const DashboardsTable: SFC = ({ dashboards, onImport, onRemove }) => { + function buttonText(dashboard: PluginDashboard) { + return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import'; + } + + return ( + + + {dashboards.map((dashboard, index) => { + return ( + + + + + + ); + })} + +
    + + + {dashboard.imported ? ( + {dashboard.title} + ) : ( + {dashboard.title} + )} + + {!dashboard.imported ? ( + + ) : ( + + )} + {dashboard.imported && ( + + )} +
    + ); +}; + +export default DashboardsTable; diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx new file mode 100644 index 00000000000..7409572b9cd --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourceDashboards, Props } from './DataSourceDashboards'; +import { DataSource, NavModel, PluginDashboard } from 'app/types'; + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + dashboards: [] as PluginDashboard[], + dataSource: {} as DataSource, + pageId: 1, + importDashboard: jest.fn(), + loadDataSource: jest.fn(), + loadPluginDashboards: jest.fn(), + removeDashboard: jest.fn(), + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx new file mode 100644 index 00000000000..51f9c9ca945 --- /dev/null +++ b/public/app/features/datasources/DataSourceDashboards.tsx @@ -0,0 +1,93 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import DashboardTable from './DashboardsTable'; +import { DataSource, NavModel, PluginDashboard } from 'app/types'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { getRouteParamsId } from 'app/core/selectors/location'; +import { loadDataSource } from './state/actions'; +import { loadPluginDashboards } from '../plugins/state/actions'; +import { importDashboard, removeDashboard } from '../dashboard/state/actions'; +import { getDataSource } from './state/selectors'; + +export interface Props { + navModel: NavModel; + dashboards: PluginDashboard[]; + dataSource: DataSource; + pageId: number; + importDashboard: typeof importDashboard; + loadDataSource: typeof loadDataSource; + loadPluginDashboards: typeof loadPluginDashboards; + removeDashboard: typeof removeDashboard; +} + +export class DataSourceDashboards extends PureComponent { + async componentDidMount() { + const { loadDataSource, pageId } = this.props; + + await loadDataSource(pageId); + this.props.loadPluginDashboards(); + } + + onImport = (dashboard: PluginDashboard, overwrite: boolean) => { + const { dataSource, importDashboard } = this.props; + const data = { + pluginId: dashboard.pluginId, + path: dashboard.path, + overwrite: overwrite, + inputs: [], + }; + + if (dataSource) { + data.inputs.push({ + name: '*', + type: 'datasource', + pluginId: dataSource.type, + value: dataSource.name, + }); + } + + importDashboard(data, dashboard.title); + }; + + onRemove = (dashboard: PluginDashboard) => { + this.props.removeDashboard(dashboard.importedUri); + }; + + render() { + const { dashboards, navModel } = this.props; + return ( +
    + +
    + this.onImport(dashboard, overwrite)} + onRemove={dashboard => this.onRemove(dashboard)} + /> +
    +
    + ); + } +} + +function mapStateToProps(state) { + const pageId = getRouteParamsId(state.location); + + return { + navModel: getNavModel(state.navIndex, `datasource-dashboards-${pageId}`), + pageId: pageId, + dashboards: state.plugins.dashboards, + dataSource: getDataSource(state.dataSources, pageId), + }; +} + +const mapDispatchToProps = { + importDashboard, + loadDataSource, + loadPluginDashboards, + removeDashboard, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceDashboards)); diff --git a/public/app/features/datasources/DataSourceSettings.tsx b/public/app/features/datasources/DataSourceSettings.tsx new file mode 100644 index 00000000000..f7d641d34b0 --- /dev/null +++ b/public/app/features/datasources/DataSourceSettings.tsx @@ -0,0 +1,125 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { DataSource, Plugin } from 'app/types'; + +export interface Props { + dataSource: DataSource; + dataSourceMeta: Plugin; +} +interface State { + name: string; +} + +enum DataSourceStates { + Alpha = 'alpha', + Beta = 'beta', +} + +export class DataSourceSettings extends PureComponent { + constructor(props) { + super(props); + + this.state = { + name: props.dataSource.name, + }; + } + + onNameChange = event => { + this.setState({ + name: event.target.value, + }); + }; + + onSubmit = event => { + event.preventDefault(); + console.log(event); + }; + + onDelete = event => { + console.log(event); + }; + + isReadyOnly() { + return this.props.dataSource.readOnly === true; + } + + shouldRenderInfoBox() { + const { state } = this.props.dataSourceMeta; + + return state === DataSourceStates.Alpha || state === DataSourceStates.Beta; + } + + getInfoText() { + const { dataSourceMeta } = this.props; + + switch (dataSourceMeta.state) { + case DataSourceStates.Alpha: + return ( + 'This plugin is marked as being in alpha state, which means it is in early development phase and updates' + + ' will include breaking changes.' + ); + + case DataSourceStates.Beta: + return ( + 'This plugin is marked as being in a beta development state. This means it is in currently in active' + + ' development and could be missing important features.' + ); + } + + return null; + } + + render() { + const { name } = this.state; + + return ( +
    +

    Settings

    +
    +
    +
    +
    + Name + +
    +
    +
    + {this.shouldRenderInfoBox() &&
    {this.getInfoText()}
    } + {this.isReadyOnly() && ( +
    + This datasource was added by config and cannot be modified using the UI. Please contact your server admin + to update this datasource. +
    + )} +
    + + + + Back + +
    +
    +
    + ); + } +} + +function mapStateToProps(state) { + return { + dataSource: state.dataSources.dataSource, + dataSourceMeta: state.dataSources.dataSourceMeta, + }; +} + +export default connect(mapStateToProps)(DataSourceSettings); diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index 527ecf6db83..c6eaa893d97 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -4,7 +4,6 @@ import { hot } from 'react-hot-loader'; import PageHeader from 'app/core/components/PageHeader/PageHeader'; import { NavModel, Plugin } from 'app/types'; import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions'; -import { updateLocation } from '../../core/actions'; import { getNavModel } from 'app/core/selectors/navModel'; import { getDataSourceTypes } from './state/selectors'; @@ -13,7 +12,6 @@ export interface Props { dataSourceTypes: Plugin[]; addDataSource: typeof addDataSource; loadDataSourceTypes: typeof loadDataSourceTypes; - updateLocation: typeof updateLocation; dataSourceTypeSearchQuery: string; setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery; } @@ -81,7 +79,6 @@ function mapStateToProps(state) { const mapDispatchToProps = { addDataSource, loadDataSourceTypes, - updateLocation, setDataSourceTypeSearchQuery, }; diff --git a/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap new file mode 100644 index 00000000000..a85cbd4ad9b --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + +
    +`; + +exports[`Render should render table 1`] = ` + + + + + + + + + + + + + +
    + + + + Graphite Carbon Metrics + + + +
    + + + + Graphite Carbon Metrics + + + + +
    +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap new file mode 100644 index 00000000000..7a4f05a227f --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
    + +
    + +
    +
    +`; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 33d6b79c5df..bb8fce8424a 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -2,12 +2,15 @@ import { ThunkAction } from 'redux-thunk'; import { DataSource, Plugin, StoreState } from 'app/types'; import { getBackendSrv } from '../../../core/services/backend_srv'; import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; -import { updateLocation } from '../../../core/actions'; +import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions'; import { UpdateLocationAction } from '../../../core/actions/location'; +import { buildNavModel } from './navModel'; export enum ActionTypes { LoadDataSources = 'LOAD_DATA_SOURCES', LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES', + LoadDataSource = 'LOAD_DATA_SOURCE', + LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META', SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY', SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE', SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY', @@ -38,11 +41,31 @@ export interface SetDataSourceTypeSearchQueryAction { payload: string; } +export interface LoadDataSourceAction { + type: ActionTypes.LoadDataSource; + payload: DataSource; +} + +export interface LoadDataSourceMetaAction { + type: ActionTypes.LoadDataSourceMeta; + payload: Plugin; +} + const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({ type: ActionTypes.LoadDataSources, payload: dataSources, }); +const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({ + type: ActionTypes.LoadDataSource, + payload: dataSource, +}); + +const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({ + type: ActionTypes.LoadDataSourceMeta, + payload: dataSourceMeta, +}); + const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({ type: ActionTypes.LoadDataSourceTypes, payload: dataSourceTypes, @@ -69,7 +92,10 @@ export type Action = | SetDataSourcesLayoutModeAction | UpdateLocationAction | LoadDataSourceTypesAction - | SetDataSourceTypeSearchQueryAction; + | SetDataSourceTypeSearchQueryAction + | LoadDataSourceAction + | UpdateNavIndexAction + | LoadDataSourceMetaAction; type ThunkResult = ThunkAction; @@ -80,6 +106,16 @@ export function loadDataSources(): ThunkResult { }; } +export function loadDataSource(id: number): ThunkResult { + return async dispatch => { + const dataSource = await getBackendSrv().get(`/api/datasources/${id}`); + const pluginInfo = await getBackendSrv().get(`/api/plugins/${dataSource.type}/settings`); + dispatch(dataSourceLoaded(dataSource)); + dispatch(dataSourceMetaLoaded(pluginInfo)); + dispatch(updateNavIndex(buildNavModel(dataSource, pluginInfo))); + }; +} + export function addDataSource(plugin: Plugin): ThunkResult { return async (dispatch, getStore) => { await dispatch(loadDataSources()); diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts new file mode 100644 index 00000000000..e0b6b39588e --- /dev/null +++ b/public/app/features/datasources/state/navModel.ts @@ -0,0 +1,109 @@ +import { DataSource, NavModel, NavModelItem, PluginMeta } from 'app/types'; +import config from 'app/core/config'; + +export function buildNavModel(dataSource: DataSource, pluginMeta: PluginMeta): NavModelItem { + const navModel = { + img: pluginMeta.info.logos.large, + id: 'datasource-' + dataSource.id, + subTitle: `Type: ${pluginMeta.name}`, + url: '', + text: dataSource.name, + breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }], + children: [ + { + active: false, + icon: 'fa fa-fw fa-sliders', + id: `datasource-settings-${dataSource.id}`, + text: 'Settings', + url: `datasources/edit/${dataSource.id}`, + }, + ], + }; + + if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) { + navModel.children.push({ + active: false, + icon: 'fa fa-fw fa-th-large', + id: `datasource-dashboards-${dataSource.id}`, + text: 'Dashboards', + url: `datasources/edit/${dataSource.id}/dashboards`, + }); + } + + if (config.buildInfo.isEnterprise) { + navModel.children.push({ + active: false, + icon: 'fa fa-fw fa-lock', + id: `datasource-permissions-${dataSource.id}`, + text: 'Permissions', + url: `datasources/edit/${dataSource.id}/permissions`, + }); + } + + return navModel; +} + +export function getDataSourceLoadingNav(pageName: string): NavModel { + const main = buildNavModel( + { + access: '', + basicAuth: false, + database: '', + id: 1, + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: 'Loading', + orgId: 1, + password: '', + readOnly: false, + type: 'Loading', + typeLogoUrl: 'public/img/icn-datasource.svg', + url: '', + user: '', + }, + { + id: '1', + name: '', + info: { + author: { + name: '', + url: '', + }, + description: '', + links: [''], + logos: { + large: '', + small: '', + }, + screenshots: '', + updated: '', + version: '', + }, + includes: [{ type: '', name: '', path: '' }], + } + ); + + let node: NavModelItem; + + // find active page + for (const child of main.children) { + if (child.id.indexOf(pageName) > 0) { + child.active = true; + node = child; + break; + } + } + + return { + main: main, + node: node, + }; +} + +function hasDashboards(includes) { + return ( + includes.filter(include => { + return include.type === 'dashboard'; + }).length > 0 + ); +} diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 9b84799dcea..7e235f5ea0a 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -4,11 +4,13 @@ import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelec const initialState: DataSourcesState = { dataSources: [] as DataSource[], + dataSource: {} as DataSource, layoutMode: LayoutModes.Grid, searchQuery: '', dataSourcesCount: 0, dataSourceTypes: [] as Plugin[], dataSourceTypeSearchQuery: '', + dataSourceMeta: {} as Plugin, hasFetched: false, }; @@ -17,6 +19,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo case ActionTypes.LoadDataSources: return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length }; + case ActionTypes.LoadDataSource: + return { ...state, dataSource: action.payload }; + case ActionTypes.SetDataSourcesSearchQuery: return { ...state, searchQuery: action.payload }; @@ -28,6 +33,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo case ActionTypes.SetDataSourceTypeSearchQuery: return { ...state, dataSourceTypeSearchQuery: action.payload }; + + case ActionTypes.LoadDataSourceMeta: + return { ...state, dataSourceMeta: action.payload }; } return state; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts index 80e1400114f..eef176eb49a 100644 --- a/public/app/features/datasources/state/selectors.ts +++ b/public/app/features/datasources/state/selectors.ts @@ -1,3 +1,5 @@ +import { DataSource } from '../../../types'; + export const getDataSources = state => { const regex = new RegExp(state.searchQuery, 'i'); @@ -14,6 +16,13 @@ export const getDataSourceTypes = state => { }); }; +export const getDataSource = (state, dataSourceId): DataSource | null => { + if (state.dataSource.id === parseInt(dataSourceId, 10)) { + return state.dataSource; + } + return null; +}; + export const getDataSourcesSearchQuery = state => state.searchQuery; export const getDataSourcesLayoutMode = state => state.layoutMode; export const getDataSourcesCount = state => state.dataSourcesCount; diff --git a/public/app/features/explore/ErrorBoundary.tsx b/public/app/features/explore/ErrorBoundary.tsx new file mode 100644 index 00000000000..959d15eea80 --- /dev/null +++ b/public/app/features/explore/ErrorBoundary.tsx @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; + +export default class ErrorBoundary extends Component<{}, any> { + constructor(props) { + super(props); + this.state = { error: null, errorInfo: null }; + } + + componentDidCatch(error, errorInfo) { + // Catch errors in any components below and re-render with error message + this.setState({ + error: error, + errorInfo: errorInfo, + }); + } + + render() { + if (this.state.errorInfo) { + // Error path + return ( +
    +

    An unexpected error happened.

    +
    + {this.state.error && this.state.error.toString()} +
    + {this.state.errorInfo.componentStack} +
    +
    + ); + } + // Normally, just render children + return this.props.children; + } +} diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 5a496ea910b..d29f4c22e0c 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { hot } from 'react-hot-loader'; import Select from 'react-select'; +import _ from 'lodash'; -import { ExploreState, ExploreUrlState, Query } from 'app/types/explore'; +import { ExploreState, ExploreUrlState, HistoryItem, Query, QueryTransaction, ResultType } from 'app/types/explore'; import kbn from 'app/core/utils/kbn'; import colors from 'app/core/utils/colors'; import store from 'app/core/store'; @@ -13,27 +14,19 @@ import ResetStyles from 'app/core/components/Picker/ResetStyles'; import PickerOption from 'app/core/components/Picker/PickerOption'; import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'; import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; -import ElapsedTime from './ElapsedTime'; +import ErrorBoundary from './ErrorBoundary'; import QueryRows from './QueryRows'; import Graph from './Graph'; import Logs from './Logs'; import Table from './Table'; import TimePicker from './TimePicker'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; +import { RawTimeRange } from 'app/types/series'; const MAX_HISTORY_ITEMS = 100; -function makeHints(hints) { - const hintsByIndex = []; - hints.forEach(hint => { - if (hint) { - hintsByIndex[hint.index] = hint; - } - }); - return hintsByIndex; -} - function makeTimeSeriesList(dataList, options) { return dataList.map((seriesData, index) => { const datapoints = seriesData.datapoints || []; @@ -52,6 +45,25 @@ function makeTimeSeriesList(dataList, options) { }); } +/** + * Update the query history. Side-effect: store history in local storage + */ +function updateHistory(history: HistoryItem[], datasourceId: string, queries: string[]): HistoryItem[] { + const ts = Date.now(); + queries.forEach(query => { + history = [{ query, ts }, ...history]; + }); + + if (history.length > MAX_HISTORY_ITEMS) { + history = history.slice(0, MAX_HISTORY_ITEMS); + } + + // Combine all queries of a datasource type into one history + const historyKey = `grafana.explore.history.${datasourceId}`; + store.setObject(historyKey, history); + return history; +} + interface ExploreProps { datasourceSrv: any; onChangeSplit: (split: boolean, state?: ExploreState) => void; @@ -82,6 +94,7 @@ export class Explore extends React.PureComponent { } else { const { datasource, queries, range } = props.urlState as ExploreUrlState; initialQueries = ensureQueries(queries); + const initialRange = range || { ...DEFAULT_RANGE }; this.state = { datasource: null, datasourceError: null, @@ -89,23 +102,17 @@ export class Explore extends React.PureComponent { datasourceMissing: false, datasourceName: datasource, exploreDatasources: [], - graphResult: null, + graphRange: initialRange, history: [], - latency: 0, - loading: false, - logsResult: null, queries: initialQueries, - queryErrors: [], - queryHints: [], - range: range || { ...DEFAULT_RANGE }, - requestOptions: null, + queryTransactions: [], + range: initialRange, showingGraph: true, showingLogs: true, showingTable: true, supportsGraph: null, supportsLogs: null, supportsTable: null, - tableResult: null, }; } this.queryExpressions = initialQueries.map(q => q.query); @@ -141,11 +148,6 @@ export class Explore extends React.PureComponent { } } - componentDidCatch(error) { - this.setState({ datasourceError: error }); - console.error(error); - } - async setDatasource(datasource) { const supportsGraph = datasource.meta.metrics; const supportsLogs = datasource.meta.logs; @@ -157,7 +159,7 @@ export class Explore extends React.PureComponent { const testResult = await datasource.testDatasource(); datasourceError = testResult.status === 'success' ? null : testResult.message; } catch (error) { - datasourceError = (error && error.statusText) || error; + datasourceError = (error && error.statusText) || 'Network error'; } const historyKey = `grafana.explore.history.${datasourceId}`; @@ -199,14 +201,32 @@ export class Explore extends React.PureComponent { }; onAddQueryRow = index => { - const { queries } = this.state; + // Local cache this.queryExpressions[index + 1] = ''; - const nextQueries = [ - ...queries.slice(0, index + 1), - { query: '', key: generateQueryKey() }, - ...queries.slice(index + 1), - ]; - this.setState({ queries: nextQueries }); + + this.setState(state => { + const { queries, queryTransactions } = state; + + // Add row by generating new react key + const nextQueries = [ + ...queries.slice(0, index + 1), + { query: '', key: generateQueryKey() }, + ...queries.slice(index + 1), + ]; + + // Ongoing transactions need to update their row indices + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.rowIndex > index) { + return { + ...qt, + rowIndex: qt.rowIndex + 1, + }; + } + return qt; + }); + + return { queries: nextQueries, queryTransactions: nextQueryTransactions }; + }); }; onChangeDatasource = async option => { @@ -214,12 +234,7 @@ export class Explore extends React.PureComponent { datasource: null, datasourceError: null, datasourceLoading: true, - graphResult: null, - latency: 0, - logsResult: null, - queryErrors: [], - queryHints: [], - tableResult: null, + queryTransactions: [], }); const datasourceName = option.value; const datasource = await this.props.datasourceSrv.get(datasourceName); @@ -230,31 +245,31 @@ export class Explore extends React.PureComponent { // Keep current value in local cache this.queryExpressions[index] = value; - // Replace query row on override if (override) { - const { queries } = this.state; - const nextQuery: Query = { - key: generateQueryKey(index), - query: value, - }; - const nextQueries = [...queries]; - nextQueries[index] = nextQuery; + this.setState(state => { + // Replace query row + const { queries, queryTransactions } = state; + const nextQuery: Query = { + key: generateQueryKey(index), + query: value, + }; + const nextQueries = [...queries]; + nextQueries[index] = nextQuery; - this.setState( - { - queryErrors: [], - queryHints: [], + // Discard ongoing transaction related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + + return { queries: nextQueries, - }, - this.onSubmit - ); + queryTransactions: nextQueryTransactions, + }; + }, this.onSubmit); } }; - onChangeTime = nextRange => { - const range = { - from: nextRange.from, - to: nextRange.to, + onChangeTime = (nextRange: RawTimeRange) => { + const range: RawTimeRange = { + ...nextRange, }; this.setState({ range }, () => this.onSubmit()); }; @@ -263,13 +278,8 @@ export class Explore extends React.PureComponent { this.queryExpressions = ['']; this.setState( { - graphResult: null, - logsResult: null, - latency: 0, queries: ensureQueries(), - queryErrors: [], - queryHints: [], - tableResult: null, + queryTransactions: [], }, this.saveState ); @@ -283,11 +293,41 @@ export class Explore extends React.PureComponent { }; onClickGraphButton = () => { - this.setState(state => ({ showingGraph: !state.showingGraph })); + this.setState( + state => { + const showingGraph = !state.showingGraph; + let nextQueryTransactions = state.queryTransactions; + if (!showingGraph) { + // Discard transactions related to Graph query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph'); + } + return { queryTransactions: nextQueryTransactions, showingGraph }; + }, + () => { + if (this.state.showingGraph) { + this.onSubmit(); + } + } + ); }; onClickLogsButton = () => { - this.setState(state => ({ showingLogs: !state.showingLogs })); + this.setState( + state => { + const showingLogs = !state.showingLogs; + let nextQueryTransactions = state.queryTransactions; + if (!showingLogs) { + // Discard transactions related to Logs query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs'); + } + return { queryTransactions: nextQueryTransactions, showingLogs }; + }, + () => { + if (this.state.showingLogs) { + this.onSubmit(); + } + } + ); }; onClickSplit = () => { @@ -299,47 +339,103 @@ export class Explore extends React.PureComponent { }; onClickTableButton = () => { - this.setState(state => ({ showingTable: !state.showingTable })); + this.setState( + state => { + const showingTable = !state.showingTable; + let nextQueryTransactions = state.queryTransactions; + if (!showingTable) { + // Discard transactions related to Table query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); + } + return { queryTransactions: nextQueryTransactions, showingTable }; + }, + () => { + if (this.state.showingTable) { + this.onSubmit(); + } + } + ); }; onClickTableCell = (columnKey: string, rowValue: string) => { this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue }); }; - onModifyQueries = (action: object, index?: number) => { - const { datasource, queries } = this.state; + onModifyQueries = (action, index?: number) => { + const { datasource } = this.state; if (datasource && datasource.modifyQuery) { - let nextQueries; - if (index === undefined) { - // Modify all queries - nextQueries = queries.map((q, i) => ({ - key: generateQueryKey(i), - query: datasource.modifyQuery(this.queryExpressions[i], action), - })); - } else { - // Modify query only at index - nextQueries = [ - ...queries.slice(0, index), - { - key: generateQueryKey(index), - query: datasource.modifyQuery(this.queryExpressions[index], action), - }, - ...queries.slice(index + 1), - ]; - } - this.queryExpressions = nextQueries.map(q => q.query); - this.setState({ queries: nextQueries }, () => this.onSubmit()); + const preventSubmit = action.preventSubmit; + this.setState( + state => { + const { queries, queryTransactions } = state; + let nextQueries; + let nextQueryTransactions; + if (index === undefined) { + // Modify all queries + nextQueries = queries.map((q, i) => ({ + key: generateQueryKey(i), + query: datasource.modifyQuery(this.queryExpressions[i], action), + })); + // Discard all ongoing transactions + nextQueryTransactions = []; + } else { + // Modify query only at index + nextQueries = queries.map((q, i) => { + // Synchronise all queries with local query cache to ensure consistency + q.query = this.queryExpressions[i]; + return i === index + ? { + key: generateQueryKey(index), + query: datasource.modifyQuery(q.query, action), + } + : q; + }); + nextQueryTransactions = queryTransactions + // Consume the hint corresponding to the action + .map(qt => { + if (qt.hints != null && qt.rowIndex === index) { + qt.hints = qt.hints.filter(hint => hint.fix.action !== action); + } + return qt; + }) + // Preserve previous row query transaction to keep results visible if next query is incomplete + .filter(qt => preventSubmit || qt.rowIndex !== index); + } + this.queryExpressions = nextQueries.map(q => q.query); + return { + queries: nextQueries, + queryTransactions: nextQueryTransactions, + }; + }, + // Accepting certain fixes do not result in a well-formed query which should not be submitted + !preventSubmit ? () => this.onSubmit() : null + ); } }; onRemoveQueryRow = index => { - const { queries } = this.state; - if (queries.length <= 1) { - return; - } - const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; - this.queryExpressions = nextQueries.map(q => q.query); - this.setState({ queries: nextQueries }, () => this.onSubmit()); + // Remove from local cache + this.queryExpressions = [...this.queryExpressions.slice(0, index), ...this.queryExpressions.slice(index + 1)]; + + this.setState( + state => { + const { queries, queryTransactions } = state; + if (queries.length <= 1) { + return null; + } + // Remove row from react state + const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)]; + + // Discard transactions related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + + return { + queries: nextQueries, + queryTransactions: nextQueryTransactions, + }; + }, + () => this.onSubmit() + ); }; onSubmit = () => { @@ -348,7 +444,7 @@ export class Explore extends React.PureComponent { this.runTableQuery(); } if (showingGraph && supportsGraph) { - this.runGraphQuery(); + this.runGraphQueries(); } if (showingLogs && supportsLogs) { this.runLogsQuery(); @@ -356,132 +452,263 @@ export class Explore extends React.PureComponent { this.saveState(); }; - onQuerySuccess(datasourceId: string, queries: string[]): void { - // save queries to history - let { history } = this.state; - const { datasource } = this.state; + buildQueryOptions( + query: string, + rowIndex: number, + targetOptions: { format: string; hinting?: boolean; instant?: boolean } + ) { + const { datasource, range } = this.state; + const resolution = this.el.offsetWidth; + const absoluteRange: RawTimeRange = { + from: parseDate(range.from, false), + to: parseDate(range.to, true), + }; + const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); + const targets = [ + { + ...targetOptions, + // Target identifier is needed for table transformations + refId: rowIndex + 1, + expr: query, + }, + ]; + // Clone range for query request + const queryRange: RawTimeRange = { ...range }; + + return { + interval, + targets, + range: queryRange, + }; + } + + startQueryTransaction(query: string, rowIndex: number, resultType: ResultType, options: any): QueryTransaction { + const queryOptions = this.buildQueryOptions(query, rowIndex, options); + const transaction: QueryTransaction = { + query, + resultType, + rowIndex, + id: generateQueryKey(), + done: false, + latency: 0, + options: queryOptions, + }; + + // Using updater style because we might be modifying queryTransactions in quick succession + this.setState(state => { + const { queryTransactions } = state; + // Discarding existing transactions of same type + const remainingTransactions = queryTransactions.filter( + qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex) + ); + + // Append new transaction + const nextQueryTransactions = [...remainingTransactions, transaction]; + + return { + queryTransactions: nextQueryTransactions, + }; + }); + + return transaction; + } + + completeQueryTransaction( + transactionId: string, + result: any, + latency: number, + queries: string[], + datasourceId: string + ) { + const { datasource } = this.state; if (datasource.meta.id !== datasourceId) { // Navigated away, queries did not matter return; } - const ts = Date.now(); - queries.forEach(query => { - history = [{ query, ts }, ...history]; - }); + this.setState(state => { + const { history, queryTransactions } = state; - if (history.length > MAX_HISTORY_ITEMS) { - history = history.slice(0, MAX_HISTORY_ITEMS); + // Transaction might have been discarded + const transaction = queryTransactions.find(qt => qt.id === transactionId); + if (!transaction) { + return null; + } + + // Get query hints + let hints; + if (datasource.getQueryHints) { + hints = datasource.getQueryHints(transaction.query, result); + } + + // Mark transactions as complete + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.id === transactionId) { + return { + ...qt, + hints, + latency, + result, + done: true, + }; + } + return qt; + }); + + const nextHistory = updateHistory(history, datasourceId, queries); + + return { + history: nextHistory, + queryTransactions: nextQueryTransactions, + }; + }); + } + + discardTransactions(rowIndex: number) { + this.setState(state => { + const remainingTransactions = state.queryTransactions.filter(qt => qt.rowIndex !== rowIndex); + return { queryTransactions: remainingTransactions }; + }); + } + + failQueryTransaction(transactionId: string, response: any, datasourceId: string) { + const { datasource } = this.state; + if (datasource.meta.id !== datasourceId) { + // Navigated away, queries did not matter + return; } - // Combine all queries of a datasource type into one history - const historyKey = `grafana.explore.history.${datasourceId}`; - store.setObject(historyKey, history); - this.setState({ history }); + console.error(response); + + let error: string | JSX.Element = response; + if (response.data) { + error = response.data.error; + if (response.data.response) { + error = ( + <> + {response.data.error} +
    {response.data.response}
    + + ); + } + } + + this.setState(state => { + // Transaction might have been discarded + if (!state.queryTransactions.find(qt => qt.id === transactionId)) { + return null; + } + + // Mark transactions as complete + const nextQueryTransactions = state.queryTransactions.map(qt => { + if (qt.id === transactionId) { + return { + ...qt, + error, + done: true, + }; + } + return qt; + }); + + return { + queryTransactions: nextQueryTransactions, + }; + }); } - buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) { - const { datasource, range } = this.state; - const resolution = this.el.offsetWidth; - const absoluteRange = { - from: parseDate(range.from, false), - to: parseDate(range.to, true), - }; - const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); - const targets = this.queryExpressions.map(q => ({ - ...targetOptions, - expr: q, - })); - return { - interval, - range, - targets, - }; - } - - async runGraphQuery() { - const { datasource } = this.state; + async runGraphQueries() { const queries = [...this.queryExpressions]; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, graphResult: null, queryErrors: [], queryHints: [] }); - const now = Date.now(); - const options = this.buildQueryOptions({ format: 'time_series', instant: false, hinting: true }); - try { - const res = await datasource.query(options); - const result = makeTimeSeriesList(res.data, options); - const queryHints = res.hints ? makeHints(res.hints) : []; - const latency = Date.now() - now; - this.setState({ latency, loading: false, graphResult: result, queryHints, requestOptions: options }); - this.onQuerySuccess(datasource.meta.id, queries); - } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryErrors: [queryError] }); - } + const { datasource } = this.state; + const datasourceId = datasource.meta.id; + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + if (query) { + const transaction = this.startQueryTransaction(query, rowIndex, 'Graph', { + format: 'time_series', + instant: false, + }); + try { + const now = Date.now(); + const res = await datasource.query(transaction.options); + const latency = Date.now() - now; + const results = makeTimeSeriesList(res.data, transaction.options); + this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); + this.setState({ graphRange: transaction.options.range }); + } catch (response) { + this.failQueryTransaction(transaction.id, response, datasourceId); + } + } else { + this.discardTransactions(rowIndex); + } + }); } async runTableQuery() { const queries = [...this.queryExpressions]; - const { datasource } = this.state; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], tableResult: null }); - const now = Date.now(); - const options = this.buildQueryOptions({ - format: 'table', - instant: true, + const { datasource } = this.state; + const datasourceId = datasource.meta.id; + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + if (query) { + const transaction = this.startQueryTransaction(query, rowIndex, 'Table', { + format: 'table', + instant: true, + valueWithRefId: true, + }); + try { + const now = Date.now(); + const res = await datasource.query(transaction.options); + const latency = Date.now() - now; + const results = res.data[0]; + this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); + } catch (response) { + this.failQueryTransaction(transaction.id, response, datasourceId); + } + } else { + this.discardTransactions(rowIndex); + } }); - try { - const res = await datasource.query(options); - const tableModel = res.data[0]; - const latency = Date.now() - now; - this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options }); - this.onQuerySuccess(datasource.meta.id, queries); - } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryErrors: [queryError] }); - } } async runLogsQuery() { const queries = [...this.queryExpressions]; - const { datasource } = this.state; if (!hasQuery(queries)) { return; } - this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], logsResult: null }); - const now = Date.now(); - const options = this.buildQueryOptions({ - format: 'logs', - }); - - try { - const res = await datasource.query(options); - const logsData = res.data; - const latency = Date.now() - now; - this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options }); - this.onQuerySuccess(datasource.meta.id, queries); - } catch (response) { - console.error(response); - const queryError = response.data ? response.data.error : response; - this.setState({ loading: false, queryErrors: [queryError] }); - } - } - - request = url => { const { datasource } = this.state; - return datasource.metadataRequest(url); - }; + const datasourceId = datasource.meta.id; + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + if (query) { + const transaction = this.startQueryTransaction(query, rowIndex, 'Logs', { format: 'logs' }); + try { + const now = Date.now(); + const res = await datasource.query(transaction.options); + const latency = Date.now() - now; + const results = res.data; + this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); + } catch (response) { + this.failQueryTransaction(transaction.id, response, datasourceId); + } + } else { + this.discardTransactions(rowIndex); + } + }); + } cloneState(): ExploreState { // Copy state, but copy queries including modifications return { ...this.state, + queryTransactions: [], queries: ensureQueries(this.queryExpressions.map(query => ({ query }))), }; } @@ -499,23 +726,17 @@ export class Explore extends React.PureComponent { datasourceLoading, datasourceMissing, exploreDatasources, - graphResult, + graphRange, history, - latency, - loading, - logsResult, queries, - queryErrors, - queryHints, + queryTransactions, range, - requestOptions, showingGraph, showingLogs, showingTable, supportsGraph, supportsLogs, supportsTable, - tableResult, } = this.state; const showingBoth = showingGraph && showingTable; const graphHeight = showingBoth ? '200px' : '400px'; @@ -524,6 +745,21 @@ export class Explore extends React.PureComponent { const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const exploreClass = split ? 'explore explore-split' : 'explore'; const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined; + const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); + const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); + const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); + // TODO don't recreate those on each re-render + const graphResult = _.flatten( + queryTransactions.filter(qt => qt.resultType === 'Graph' && qt.done && qt.result).map(qt => qt.result) + ); + const tableResult = mergeTablesIntoModel( + new TableModel(), + ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result) + ); + const logsResult = _.flatten( + queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result) + ); + const loading = queryTransactions.some(qt => !qt.done); return (
    @@ -581,9 +817,9 @@ export class Explore extends React.PureComponent {
    - {loading || latency ? : null}
    @@ -600,17 +836,16 @@ export class Explore extends React.PureComponent { {datasource && !datasourceError ? (
    {supportsGraph ? ( @@ -631,22 +866,25 @@ export class Explore extends React.PureComponent {
    - {supportsGraph && - showingGraph && - graphResult && ( - - )} - {supportsTable && showingTable ? ( - - ) : null} - {supportsLogs && showingLogs ? : null} + + {supportsGraph && + showingGraph && ( + + )} + {supportsTable && showingTable ? ( +
    +
    + + ) : null} + {supportsLogs && showingLogs ? : null} + ) : null} diff --git a/public/app/features/explore/Graph.test.tsx b/public/app/features/explore/Graph.test.tsx index 043b843f0a5..fe4deaf17aa 100644 --- a/public/app/features/explore/Graph.test.tsx +++ b/public/app/features/explore/Graph.test.tsx @@ -4,24 +4,11 @@ import { Graph } from './Graph'; import { mockData } from './__mocks__/mockData'; const setup = (propOverrides?: object) => { - const props = Object.assign( - { - data: mockData().slice(0, 19), - options: { - interval: '20s', - range: { from: 'now-6h', to: 'now' }, - targets: [ - { - format: 'time_series', - instant: false, - hinting: true, - expr: 'prometheus_http_request_duration_seconds_bucket', - }, - ], - }, - }, - propOverrides - ); + const props = { + data: mockData().slice(0, 19), + range: { from: 'now-6h', to: 'now' }, + ...propOverrides, + }; // Enzyme.shallow did not work well with jquery.flop. Mocking the draw function. Graph.prototype.draw = jest.fn(); diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index cb5def07fab..1f07b9bb46a 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -5,6 +5,8 @@ import { withSize } from 'react-sizeme'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.time'; + +import { RawTimeRange } from 'app/types/series'; import * as dateMath from 'app/core/utils/datemath'; import TimeSeries from 'app/core/time_series2'; @@ -74,7 +76,7 @@ interface GraphProps { height?: string; // e.g., '200px' id?: string; loading?: boolean; - options: any; + range: RawTimeRange; split?: boolean; size?: { width: number; height: number }; } @@ -101,7 +103,7 @@ export class Graph extends PureComponent { componentDidUpdate(prevProps: GraphProps) { if ( prevProps.data !== this.props.data || - prevProps.options !== this.props.options || + prevProps.range !== this.props.range || prevProps.split !== this.props.split || prevProps.height !== this.props.height || (prevProps.size && prevProps.size.width !== this.props.size.width) @@ -120,22 +122,22 @@ export class Graph extends PureComponent { }; draw() { - const { options: userOptions, size } = this.props; + const { range, size } = this.props; const data = this.getGraphData(); const $el = $(`#${this.props.id}`); - if (!data) { - $el.empty(); - return; + let series = [{ data: [[0, 0]] }]; + + if (data && data.length > 0) { + series = data.map((ts: TimeSeries) => ({ + color: ts.color, + label: ts.label, + data: ts.getFlotPairs('null'), + })); } - const series = data.map((ts: TimeSeries) => ({ - color: ts.color, - label: ts.label, - data: ts.getFlotPairs('null'), - })); const ticks = (size.width || 0) / 100; - let { from, to } = userOptions.range; + let { from, to } = range; if (!moment.isMoment(from)) { from = dateMath.parse(from, false); } @@ -157,7 +159,6 @@ export class Graph extends PureComponent { const options = { ...FLOT_OPTIONS, ...dynamicOptions, - ...userOptions, }; $.plot($el, series, options); } @@ -166,16 +167,11 @@ export class Graph extends PureComponent { const { height = '100px', id = 'graph', loading = false } = this.props; const data = this.getGraphData(); - if (!loading && data.length === 0) { - return ( -
    -
    The queries returned no time series to graph.
    -
    - ); - } return ( -
    - {this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && +
    + {loading &&
    } + {this.props.data && + this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && !this.state.showAllTimeSeries && (
    @@ -185,10 +181,8 @@ export class Graph extends PureComponent { }`}
    )} -
    -
    - -
    +
    +
    ); } diff --git a/public/app/features/explore/PlaceholdersBuffer.test.ts b/public/app/features/explore/PlaceholdersBuffer.test.ts new file mode 100644 index 00000000000..2ce31e79b05 --- /dev/null +++ b/public/app/features/explore/PlaceholdersBuffer.test.ts @@ -0,0 +1,72 @@ +import PlaceholdersBuffer from './PlaceholdersBuffer'; + +describe('PlaceholdersBuffer', () => { + it('does nothing if no placeholders are defined', () => { + const text = 'metric'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe(text); + expect(buffer.getNextMoveOffset()).toBe(0); + }); + + it('respects the traversal order of placeholders', () => { + const text = 'sum($2 offset $1) by ($3)'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum( offset ) by ()'); + expect(buffer.getNextMoveOffset()).toBe(12); + + buffer.setNextPlaceholderValue('1h'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum( offset 1h) by ()'); + expect(buffer.getNextMoveOffset()).toBe(-10); + + buffer.setNextPlaceholderValue('metric'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('sum(metric offset 1h) by ()'); + expect(buffer.getNextMoveOffset()).toBe(16); + + buffer.setNextPlaceholderValue('label'); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe('sum(metric offset 1h) by (label)'); + expect(buffer.getNextMoveOffset()).toBe(0); + }); + + it('respects the traversal order of adjacent placeholders', () => { + const text = '$1$3$2$4'; + const buffer = new PlaceholdersBuffer(text); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe(''); + expect(buffer.getNextMoveOffset()).toBe(0); + + buffer.setNextPlaceholderValue('1'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('1'); + expect(buffer.getNextMoveOffset()).toBe(0); + + buffer.setNextPlaceholderValue('2'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('12'); + expect(buffer.getNextMoveOffset()).toBe(-1); + + buffer.setNextPlaceholderValue('3'); + + expect(buffer.hasPlaceholders()).toBe(true); + expect(buffer.toString()).toBe('132'); + expect(buffer.getNextMoveOffset()).toBe(1); + + buffer.setNextPlaceholderValue('4'); + + expect(buffer.hasPlaceholders()).toBe(false); + expect(buffer.toString()).toBe('1324'); + expect(buffer.getNextMoveOffset()).toBe(0); + }); +}); diff --git a/public/app/features/explore/PlaceholdersBuffer.ts b/public/app/features/explore/PlaceholdersBuffer.ts new file mode 100644 index 00000000000..9a0db18ef04 --- /dev/null +++ b/public/app/features/explore/PlaceholdersBuffer.ts @@ -0,0 +1,112 @@ +/** + * Provides a stateful means of managing placeholders in text. + * + * Placeholders are numbers prefixed with the `$` character (e.g. `$1`). + * Each number value represents the order in which a placeholder should + * receive focus if multiple placeholders exist. + * + * Example scenario given `sum($3 offset $1) by($2)`: + * 1. `sum( offset |) by()` + * 2. `sum( offset 1h) by(|)` + * 3. `sum(| offset 1h) by (label)` + */ +export default class PlaceholdersBuffer { + private nextMoveOffset: number; + private orders: number[]; + private parts: string[]; + + constructor(text: string) { + const result = this.parse(text); + const nextPlaceholderIndex = result.orders.length ? result.orders[0] : 0; + this.nextMoveOffset = this.getOffsetBetween(result.parts, 0, nextPlaceholderIndex); + this.orders = result.orders; + this.parts = result.parts; + } + + clearPlaceholders() { + this.nextMoveOffset = 0; + this.orders = []; + } + + getNextMoveOffset(): number { + return this.nextMoveOffset; + } + + hasPlaceholders(): boolean { + return this.orders.length > 0; + } + + setNextPlaceholderValue(value: string) { + if (this.orders.length === 0) { + return; + } + const currentPlaceholderIndex = this.orders[0]; + this.parts[currentPlaceholderIndex] = value; + this.orders = this.orders.slice(1); + if (this.orders.length === 0) { + this.nextMoveOffset = 0; + return; + } + const nextPlaceholderIndex = this.orders[0]; + // Case should never happen but handle it gracefully in case + if (currentPlaceholderIndex === nextPlaceholderIndex) { + this.nextMoveOffset = 0; + return; + } + const backwardMove = currentPlaceholderIndex > nextPlaceholderIndex; + const indices = backwardMove + ? { start: nextPlaceholderIndex + 1, end: currentPlaceholderIndex + 1 } + : { start: currentPlaceholderIndex + 1, end: nextPlaceholderIndex }; + this.nextMoveOffset = (backwardMove ? -1 : 1) * this.getOffsetBetween(this.parts, indices.start, indices.end); + } + + toString(): string { + return this.parts.join(''); + } + + private getOffsetBetween(parts: string[], startIndex: number, endIndex: number) { + return parts.slice(startIndex, endIndex).reduce((offset, part) => offset + part.length, 0); + } + + private parse(text: string): ParseResult { + const placeholderRegExp = /\$(\d+)/g; + const parts = []; + const orders = []; + let textOffset = 0; + while (true) { + const match = placeholderRegExp.exec(text); + if (!match) { + break; + } + const part = text.slice(textOffset, match.index); + parts.push(part); + // Accounts for placeholders at text boundaries + if (part !== '') { + parts.push(''); + } + const order = parseInt(match[1], 10); + orders.push({ index: parts.length - 1, order }); + textOffset += part.length + match.length; + } + // Ensures string serialisation still works if no placeholders were parsed + // and also accounts for the remainder of text with placeholders + parts.push(text.slice(textOffset)); + return { + // Placeholder values do not necessarily appear sequentially so sort the + // indices to traverse in priority order + orders: orders.sort((o1, o2) => o1.order - o2.order).map(o => o.index), + parts, + }; + } +} + +type ParseResult = { + /** + * Indices to placeholder items in `parts` in traversal order. + */ + orders: number[]; + /** + * Parts comprising the original text with placeholders occupying distinct items. + */ + parts: string[]; +}; diff --git a/public/app/features/explore/PromQueryField.test.tsx b/public/app/features/explore/PromQueryField.test.tsx index c82a1cd448f..bc7eb301be1 100644 --- a/public/app/features/explore/PromQueryField.test.tsx +++ b/public/app/features/explore/PromQueryField.test.tsx @@ -1,201 +1,4 @@ -import React from 'react'; -import Enzyme, { shallow } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Plain from 'slate-plain-serializer'; - -import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField'; - -Enzyme.configure({ adapter: new Adapter() }); - -describe('PromQueryField typeahead handling', () => { - const defaultProps = { - request: () => ({ data: { data: [] } }), - }; - - it('returns default suggestions on emtpty context', () => { - const instance = shallow().instance() as PromQueryField; - const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - - describe('range suggestions', () => { - it('returns range suggestions in range context', () => { - const instance = shallow().instance() as PromQueryField; - const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] }); - expect(result.context).toBe('context-range'); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions).toEqual([ - { - items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }], - label: 'Range vector', - }, - ]); - }); - }); - - describe('metric suggestions', () => { - it('returns metrics suggestions by default', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - - it('returns default suggestions after a binary operator', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeUndefined(); - expect(result.suggestions.length).toEqual(2); - }); - }); - - describe('label suggestions', () => { - it('returns default label suggestions on label context and no metric', () => { - const instance = shallow().instance() as PromQueryField; - const value = Plain.deserialize('{}'); - const range = value.selection.merge({ - anchorOffset: 1, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); - }); - - it('returns label suggestions on label context and metric', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label suggestions on label context but leaves out labels that already exist', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('{job="foo",}'); - const range = value.selection.merge({ - anchorOffset: 11, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns a refresher on label context and unavailable metric', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{}'); - const range = value.selection.merge({ - anchorOffset: 7, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-labels'], - value: valueWithSelection, - }); - expect(result.context).toBeUndefined(); - expect(result.refresher).toBeInstanceOf(Promise); - expect(result.suggestions).toEqual([]); - }); - - it('returns label values on label context when given a metric and a label key', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('metric{bar=ba}'); - const range = value.selection.merge({ - anchorOffset: 13, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '=ba', - prefix: 'ba', - wrapperClasses: ['context-labels'], - labelKey: 'bar', - value: valueWithSelection, - }); - expect(result.context).toBe('context-label-values'); - expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); - }); - - it('returns label suggestions on aggregation context and metric w/ selector', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('sum(metric{foo="xx"}) by ()'); - const range = value.selection.merge({ - anchorOffset: 26, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-aggregation'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - - it('returns label suggestions on aggregation context and metric w/o selector', () => { - const instance = shallow( - - ).instance() as PromQueryField; - const value = Plain.deserialize('sum(metric) by ()'); - const range = value.selection.merge({ - anchorOffset: 16, - }); - const valueWithSelection = value.change().select(range).value; - const result = instance.getTypeahead({ - text: '', - prefix: '', - wrapperClasses: ['context-aggregation'], - value: valueWithSelection, - }); - expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); - }); - }); -}); +import { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField'; describe('groupMetricsByPrefix()', () => { it('returns an empty group for no metrics', () => { diff --git a/public/app/features/explore/PromQueryField.tsx b/public/app/features/explore/PromQueryField.tsx index 889666c5e35..c7afe606734 100644 --- a/public/app/features/explore/PromQueryField.tsx +++ b/public/app/features/explore/PromQueryField.tsx @@ -1,67 +1,23 @@ import _ from 'lodash'; -import moment from 'moment'; import React from 'react'; -import { Value } from 'slate'; import Cascader from 'rc-cascader'; import PluginPrism from 'slate-prism'; import Prism from 'prismjs'; +import { TypeaheadOutput } from 'app/types/explore'; + // dom also includes Element polyfills import { getNextCharacter, getPreviousCousin } from './utils/dom'; -import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql'; import BracesPlugin from './slate-plugins/braces'; import RunnerPlugin from './slate-plugins/runner'; -import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus'; -import TypeaheadField, { - Suggestion, - SuggestionGroup, - TypeaheadInput, - TypeaheadFieldState, - TypeaheadOutput, -} from './QueryField'; +import TypeaheadField, { TypeaheadInput, TypeaheadFieldState } from './QueryField'; -const DEFAULT_KEYS = ['job', 'instance']; -const EMPTY_SELECTOR = '{}'; const HISTOGRAM_GROUP = '__histograms__'; -const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms -const HISTORY_ITEM_COUNT = 5; -const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h const METRIC_MARK = 'metric'; const PRISM_SYNTAX = 'promql'; export const RECORDING_RULES_GROUP = '__recording_rules__'; -export const wrapLabel = (label: string) => ({ label }); -export const setFunctionMove = (suggestion: Suggestion): Suggestion => { - suggestion.move = -1; - return suggestion; -}; - -// Syntax highlighting -Prism.languages[PRISM_SYNTAX] = PrismPromql; -function setPrismTokens(language, field, values, alias = 'variable') { - Prism.languages[language][field] = { - alias, - pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`), - }; -} - -export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion { - const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; - const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label); - const count = historyForItem.length; - const recent = historyForItem[0]; - let hint = `Queried ${count} times in the last 24h.`; - if (recent) { - const lastQueried = moment(recent.ts).fromNow(); - hint = `${hint} Last queried ${lastQueried}.`; - } - return { - ...item, - documentation: hint, - }; -} - export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] { // Filter out recording rules and insert as first option const ruleRegex = /:\w+:/; @@ -111,7 +67,7 @@ export function willApplySuggestion( case 'context-label-values': { // Always add quotes and remove existing ones instead - if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) { + if (!typeaheadText.match(/^(!?=~?"|")/)) { suggestion = `"${suggestion}`; } if (getNextCharacter() !== '"') { @@ -133,48 +89,36 @@ interface CascaderOption { } interface PromQueryFieldProps { - error?: string; + datasource: any; + error?: string | JSX.Element; hint?: any; - histogramMetrics?: string[]; history?: any[]; initialQuery?: string | null; - labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] - labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] - metrics?: string[]; metricsByPrefix?: CascaderOption[]; onClickHintFix?: (action: any) => void; onPressEnter?: () => void; onQueryChange?: (value: string, override?: boolean) => void; - portalOrigin?: string; - request?: (url: string) => any; supportsLogs?: boolean; // To be removed after Logging gets its own query field } interface PromQueryFieldState { - histogramMetrics: string[]; - labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...] - labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] logLabelOptions: any[]; - metrics: string[]; metricsOptions: any[]; metricsByPrefix: CascaderOption[]; syntaxLoaded: boolean; } -interface PromTypeaheadInput { - text: string; - prefix: string; - wrapperClasses: string[]; - labelKey?: string; - value?: Value; -} - class PromQueryField extends React.PureComponent { plugins: any[]; + languageProvider: any; constructor(props: PromQueryFieldProps, context) { super(props, context); + if (props.datasource.languageProvider) { + this.languageProvider = props.datasource.languageProvider; + } + this.plugins = [ BracesPlugin(), RunnerPlugin({ handler: props.onPressEnter }), @@ -185,26 +129,16 @@ class PromQueryField extends React.PureComponent this.onReceiveMetrics()); } } @@ -262,15 +196,19 @@ class PromQueryField extends React.PureComponent { - const { histogramMetrics, metrics, metricsByPrefix } = this.state; + const { histogramMetrics, metrics } = this.languageProvider; if (!metrics) { return; } - // Update global prism config - setPrismTokens(PRISM_SYNTAX, METRIC_MARK, metrics); + Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax(); + Prism.languages[PRISM_SYNTAX][METRIC_MARK] = { + alias: 'variable', + pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`), + }; // Build metrics tree + const metricsByPrefix = groupMetricsByPrefix(metrics); const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm })); const metricsOptions = [ { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions }, @@ -281,6 +219,11 @@ class PromQueryField extends React.PureComponent { + if (!this.languageProvider) { + return { suggestions: [] }; + } + + const { history } = this.props; const { prefix, text, value, wrapperNode } = typeahead; // Get DOM-dependent context @@ -289,279 +232,20 @@ class PromQueryField extends React.PureComponent 3; - // Determine candidates by CSS context - if (_.includes(wrapperClasses, 'context-range')) { - // Suggestions for metric[|] - return this.getRangeTypeahead(); - } else if (_.includes(wrapperClasses, 'context-labels')) { - // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} - return this.getLabelTypeahead.apply(this, arguments); - } else if (_.includes(wrapperClasses, 'context-aggregation')) { - return this.getAggregationTypeahead.apply(this, arguments); - } else if ( - // Show default suggestions in a couple of scenarios - (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token - (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace - text.match(/[+\-*/^%]/) // Anything after binary operator - ) { - return this.getEmptyTypeahead(); - } - - return { - suggestions: [], - }; - } - - getEmptyTypeahead(): TypeaheadOutput { - const { history } = this.props; - const { metrics } = this.state; - const suggestions: SuggestionGroup[] = []; - - if (history && history.length > 0) { - const historyItems = _.chain(history) - .uniqBy('query') - .take(HISTORY_ITEM_COUNT) - .map(h => h.query) - .map(wrapLabel) - .map(item => addHistoryMetadata(item, history)) - .value(); - - suggestions.push({ - prefixMatch: true, - skipSort: true, - label: 'History', - items: historyItems, - }); - } - - suggestions.push({ - prefixMatch: true, - label: 'Functions', - items: FUNCTIONS.map(setFunctionMove), - }); - - if (metrics) { - suggestions.push({ - label: 'Metrics', - items: metrics.map(wrapLabel), - }); - } - return { suggestions }; - } - - getRangeTypeahead(): TypeaheadOutput { - return { - context: 'context-range', - suggestions: [ - { - label: 'Range vector', - items: [...RATE_RANGES].map(wrapLabel), - }, - ], - }; - } - - getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput { - let refresher: Promise = null; - const suggestions: SuggestionGroup[] = []; - - // sum(foo{bar="1"}) by (|) - const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; - // sum(foo{bar="1"}) by ( - const leftSide = line.slice(0, cursorOffset); - const openParensAggregationIndex = leftSide.lastIndexOf('('); - const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('('); - const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex; - // foo{bar="1"} - const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); - const selector = parseSelector(selectorString, selectorString.length - 2).selector; - - const labelKeys = this.state.labelKeys[selector]; - if (labelKeys) { - suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) }); - } else { - refresher = this.fetchSeriesLabels(selector); - } - - return { - refresher, - suggestions, - context: 'context-aggregation', - }; - } - - getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput { - let context: string; - let refresher: Promise = null; - const suggestions: SuggestionGroup[] = []; - const line = value.anchorBlock.getText(); - const cursorOffset: number = value.anchorOffset; - - // Get normalized selector - let selector; - let parsedSelector; - try { - parsedSelector = parseSelector(line, cursorOffset); - selector = parsedSelector.selector; - } catch { - selector = EMPTY_SELECTOR; - } - const containsMetric = selector.indexOf('__name__=') > -1; - const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; - - if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) { - // Label values - if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) { - const labelValues = this.state.labelValues[selector][labelKey]; - context = 'context-label-values'; - suggestions.push({ - label: `Label values for "${labelKey}"`, - items: labelValues.map(wrapLabel), - }); - } - } else { - // Label keys - const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS); - if (labelKeys) { - const possibleKeys = _.difference(labelKeys, existingKeys); - if (possibleKeys.length > 0) { - context = 'context-labels'; - suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) }); - } - } - } - - // Query labels for selector - // Temporarily add skip for logging - if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) { - if (selector === EMPTY_SELECTOR) { - // Query label values for default labels - refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); - } else { - refresher = this.fetchSeriesLabels(selector, !containsMetric); - } - } - - return { context, refresher, suggestions }; - } - - request = url => { - if (this.props.request) { - return this.props.request(url); - } - return fetch(url); - }; - - fetchHistogramMetrics() { - this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => { - const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR]; - if (histogramSeries && histogramSeries['__name__']) { - const histogramMetrics = histogramSeries['__name__'].slice().sort(); - this.setState({ histogramMetrics }, this.onReceiveMetrics); - } - }); - } - - // Temporarily here while reusing this field for logging - async fetchLogLabels() { - const url = '/api/prom/label'; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const labelKeys = body.data.slice().sort(); - const labelKeysBySelector = { - ...this.state.labelKeys, - [EMPTY_SELECTOR]: labelKeys, - }; - const labelValuesByKey = {}; - const logLabelOptions = []; - for (const key of labelKeys) { - const valuesUrl = `/api/prom/label/${key}/values`; - const res = await this.request(valuesUrl); - const body = await (res.data || res.json()); - const values = body.data.slice().sort(); - labelValuesByKey[key] = values; - logLabelOptions.push({ - label: key, - value: key, - children: values.map(value => ({ label: value, value })), - }); - } - const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey }; - this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions }); - } catch (e) { - console.error(e); - } - } - - async fetchLabelValues(key: string) { - const url = `/api/v1/label/${key}/values`; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const exisingValues = this.state.labelValues[EMPTY_SELECTOR]; - const values = { - ...exisingValues, - [key]: body.data, - }; - const labelValues = { - ...this.state.labelValues, - [EMPTY_SELECTOR]: values, - }; - this.setState({ labelValues }); - } catch (e) { - console.error(e); - } - } - - async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) { - const url = `/api/v1/series?match[]=${name}`; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const { keys, values } = processLabels(body.data, withName); - const labelKeys = { - ...this.state.labelKeys, - [name]: keys, - }; - const labelValues = { - ...this.state.labelValues, - [name]: values, - }; - this.setState({ labelKeys, labelValues }, callback); - } catch (e) { - console.error(e); - } - } - - async fetchMetricNames() { - const url = '/api/v1/label/__name__/values'; - try { - const res = await this.request(url); - const body = await (res.data || res.json()); - const metrics = body.data; - const metricsByPrefix = groupMetricsByPrefix(metrics); - this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics); - } catch (error) { - console.error(error); - } - } - render() { const { error, hint, initialQuery, supportsLogs } = this.props; const { logLabelOptions, metricsOptions, syntaxLoaded } = this.state; + const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined; return (
    @@ -571,10 +255,10 @@ class PromQueryField extends React.PureComponentLog labels ) : ( - - - - )} + + + + )}
    diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index c89893b4f28..9350682f1a0 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -5,95 +5,28 @@ import { Change, Value } from 'slate'; import { Editor } from 'slate-react'; import Plain from 'slate-plain-serializer'; +import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; + import ClearPlugin from './slate-plugins/clear'; import NewlinePlugin from './slate-plugins/newline'; import Typeahead from './Typeahead'; import { makeFragment, makeValue } from './Value'; +import PlaceholdersBuffer from './PlaceholdersBuffer'; export const TYPEAHEAD_DEBOUNCE = 100; -function getSuggestionByIndex(suggestions: SuggestionGroup[], index: number): Suggestion { +function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem { // Flatten suggestion groups const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []); const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length; return flattenedSuggestions[correctedIndex]; } -function hasSuggestions(suggestions: SuggestionGroup[]): boolean { +function hasSuggestions(suggestions: CompletionItemGroup[]): boolean { return suggestions && suggestions.length > 0; } -export interface Suggestion { - /** - * The label of this completion item. By default - * this is also the text that is inserted when selecting - * this completion. - */ - label: string; - /** - * The kind of this completion item. Based on the kind - * an icon is chosen by the editor. - */ - kind?: string; - /** - * A human-readable string with additional information - * about this item, like type or symbol information. - */ - detail?: string; - /** - * A human-readable string, can be Markdown, that represents a doc-comment. - */ - documentation?: string; - /** - * A string that should be used when comparing this item - * with other items. When `falsy` the `label` is used. - */ - sortText?: string; - /** - * A string that should be used when filtering a set of - * completion items. When `falsy` the `label` is used. - */ - filterText?: string; - /** - * A string or snippet that should be inserted in a document when selecting - * this completion. When `falsy` the `label` is used. - */ - insertText?: string; - /** - * Delete number of characters before the caret position, - * by default the letters from the beginning of the word. - */ - deleteBackwards?: number; - /** - * Number of steps to move after the insertion, can be negative. - */ - move?: number; -} - -export interface SuggestionGroup { - /** - * Label that will be displayed for all entries of this group. - */ - label: string; - /** - * List of suggestions of this group. - */ - items: Suggestion[]; - /** - * If true, match only by prefix (and not mid-word). - */ - prefixMatch?: boolean; - /** - * If true, do not filter items in this group based on the search. - */ - skipFilter?: boolean; - /** - * If true, do not sort items. - */ - skipSort?: boolean; -} - interface TypeaheadFieldProps { additionalPlugins?: any[]; cleanText?: (text: string) => string; @@ -110,7 +43,7 @@ interface TypeaheadFieldProps { } export interface TypeaheadFieldState { - suggestions: SuggestionGroup[]; + suggestions: CompletionItemGroup[]; typeaheadContext: string | null; typeaheadIndex: number; typeaheadPrefix: string; @@ -127,20 +60,17 @@ export interface TypeaheadInput { wrapperNode: Element; } -export interface TypeaheadOutput { - context?: string; - refresher?: Promise<{}>; - suggestions: SuggestionGroup[]; -} - class QueryField extends React.PureComponent { menuEl: HTMLElement | null; + placeholdersBuffer: PlaceholdersBuffer; plugins: any[]; resetTimer: any; constructor(props, context) { super(props, context); + this.placeholdersBuffer = new PlaceholdersBuffer(props.initialValue || ''); + // Base plugins this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins]; @@ -150,7 +80,7 @@ class QueryField extends React.PureComponent operation.type === 'insert_text'); + if (insertTextOperation) { + const suggestionText = insertTextOperation.text; + this.placeholdersBuffer.setNextPlaceholderValue(suggestionText); + if (this.placeholdersBuffer.hasPlaceholders()) { + nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus(); + } + } + return true; } break; @@ -396,6 +344,7 @@ class QueryField extends React.PureComponent { @@ -403,6 +352,8 @@ class QueryField extends React.PureComponent { + onClickMenu = (item: CompletionItem) => { // Manually triggering change const change = this.applyTypeahead(this.state.value.change(), item); this.onChange(change); diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 3d71c2f3566..aaa7dfcd20b 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -1,9 +1,43 @@ import React, { PureComponent } from 'react'; +import { QueryTransaction, HistoryItem, Query, QueryHint } from 'app/types/explore'; + // TODO make this datasource-plugin-dependent import QueryField from './PromQueryField'; +import QueryTransactions from './QueryTransactions'; -class QueryRow extends PureComponent { +function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { + const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); + if (transaction) { + return transaction.hints[0]; + } + return undefined; +} + +interface QueryRowEventHandlers { + onAddQueryRow: (index: number) => void; + onChangeQuery: (value: string, index: number, override?: boolean) => void; + onClickHintFix: (action: object, index?: number) => void; + onExecuteQuery: () => void; + onRemoveQueryRow: (index: number) => void; +} + +interface QueryRowCommonProps { + className?: string; + datasource: any; + history: HistoryItem[]; + // Temporarily + supportsLogs?: boolean; + transactions: QueryTransaction[]; +} + +type QueryRowProps = QueryRowCommonProps & + QueryRowEventHandlers & { + index: number; + query: string; + }; + +class QueryRow extends PureComponent { onChangeQuery = (value, override?: boolean) => { const { index, onChangeQuery } = this.props; if (onChangeQuery) { @@ -44,19 +78,25 @@ class QueryRow extends PureComponent { }; render() { - const { history, query, queryError, queryHint, request, supportsLogs } = this.props; + const { datasource, history, query, supportsLogs, transactions } = this.props; + const transactionWithError = transactions.find(t => t.error !== undefined); + const hint = getFirstHintFromTransactions(transactions); + const queryError = transactionWithError ? transactionWithError.error : null; return (
    +
    + +
    @@ -76,9 +116,14 @@ class QueryRow extends PureComponent { } } -export default class QueryRows extends PureComponent { +type QueryRowsProps = QueryRowCommonProps & + QueryRowEventHandlers & { + queries: Query[]; + }; + +export default class QueryRows extends PureComponent { render() { - const { className = '', queries, queryErrors, queryHints, ...handlers } = this.props; + const { className = '', queries, transactions, ...handlers } = this.props; return (
    {queries.map((q, index) => ( @@ -86,8 +131,7 @@ export default class QueryRows extends PureComponent { key={q.key} index={index} query={q.query} - queryError={queryErrors[index]} - queryHint={queryHints[index]} + transactions={transactions.filter(t => t.rowIndex === index)} {...handlers} /> ))} diff --git a/public/app/features/explore/QueryTransactions.tsx b/public/app/features/explore/QueryTransactions.tsx new file mode 100644 index 00000000000..0ce721f14e7 --- /dev/null +++ b/public/app/features/explore/QueryTransactions.tsx @@ -0,0 +1,42 @@ +import React, { PureComponent } from 'react'; + +import { QueryTransaction as QueryTransactionModel } from 'app/types/explore'; +import ElapsedTime from './ElapsedTime'; + +function formatLatency(value) { + return `${(value / 1000).toFixed(1)}s`; +} + +interface QueryTransactionProps { + transaction: QueryTransactionModel; +} + +class QueryTransaction extends PureComponent { + render() { + const { transaction } = this.props; + const className = transaction.done ? 'query-transaction' : 'query-transaction query-transaction--loading'; + return ( +
    +
    {transaction.resultType}:
    +
    + {transaction.done ? formatLatency(transaction.latency) : } +
    +
    + ); + } +} + +interface QueryTransactionsProps { + transactions: QueryTransactionModel[]; +} + +export default class QueryTransactions extends PureComponent { + render() { + const { transactions } = this.props; + return ( +
    + {transactions.map((t, i) => )} +
    + ); + } +} diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx index cbb3ab11f4e..4946a6a505d 100644 --- a/public/app/features/explore/Table.tsx +++ b/public/app/features/explore/Table.tsx @@ -1,84 +1,64 @@ +import _ from 'lodash'; import React, { PureComponent } from 'react'; +import ReactTable from 'react-table'; + import TableModel from 'app/core/table_model'; const EMPTY_TABLE = new TableModel(); +// Identify columns that contain values +const VALUE_REGEX = /^[Vv]alue #\d+/; interface TableProps { - className?: string; data: TableModel; loading: boolean; onClickCell?: (columnKey: string, rowValue: string) => void; } -interface SFCCellProps { - columnIndex: number; - onClickCell?: (columnKey: string, rowValue: string, columnIndex: number, rowIndex: number, table: TableModel) => void; - rowIndex: number; - table: TableModel; - value: string; +function prepareRows(rows, columnNames) { + return rows.map(cells => _.zipObject(columnNames, cells)); } -function Cell(props: SFCCellProps) { - const { columnIndex, rowIndex, table, value, onClickCell } = props; - const column = table.columns[columnIndex]; - if (column && column.filterable && onClickCell) { - const onClick = event => { - event.preventDefault(); - onClickCell(column.text, value, columnIndex, rowIndex, table); +export default class Table extends PureComponent { + getCellProps = (state, rowInfo, column) => { + return { + onClick: (e: React.SyntheticEvent) => { + // Only handle click on link, not the cell + if (e.target) { + const link = e.target as HTMLElement; + if (link.className === 'link') { + const columnKey = column.Header; + const rowValue = rowInfo.row[columnKey]; + this.props.onClickCell(columnKey, rowValue); + } + } + }, }; - return ( -
    - ); - } - return ; -} + }; -export default class Table extends PureComponent { render() { - const { className = '', data, loading, onClickCell } = this.props; + const { data, loading } = this.props; const tableModel = data || EMPTY_TABLE; - if (!loading && data && data.rows.length === 0) { - return ( -
    - - {value} - - {value}
    - - - - - - - - - - -
    Table
    The queries returned no data for a table.
    - ); - } + const columnNames = tableModel.columns.map(({ text }) => text); + const columns = tableModel.columns.map(({ filterable, text }) => ({ + Header: text, + accessor: text, + className: VALUE_REGEX.test(text) ? 'text-right' : '', + show: text !== 'Time', + Cell: row => {row.value}, + })); + const noDataText = data ? 'The queries returned no data for a table.' : ''; + return ( - - - {tableModel.columns.map(col => )} - - - {tableModel.rows.map((row, i) => ( - - {row.map((value, j) => ( - - ))} - - ))} - -
    {col.text}
    + prepareRows(data, columnNames)} + showPagination={Boolean(data)} + /> ); } } diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx index f9c740073d0..8955fb4aa9b 100644 --- a/public/app/features/explore/TimePicker.tsx +++ b/public/app/features/explore/TimePicker.tsx @@ -3,6 +3,7 @@ import moment from 'moment'; import * as dateMath from 'app/core/utils/datemath'; import * as rangeUtil from 'app/core/utils/rangeutil'; +import { RawTimeRange } from 'app/types/series'; const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; export const DEFAULT_RANGE = { @@ -10,77 +11,104 @@ export const DEFAULT_RANGE = { to: 'now', }; -export function parseTime(value, isUtc = false, asString = false) { +/** + * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT). + * @param value Epoch or relative time + */ +export function parseTime(value: string, isUtc = false): string { if (value.indexOf('now') !== -1) { return value; } - if (!isNaN(value)) { - const epoch = parseInt(value, 10); - const m = isUtc ? moment.utc(epoch) : moment(epoch); - return asString ? m.format(DATE_FORMAT) : m; + let time: any = value; + // Possible epoch + if (!isNaN(time)) { + time = parseInt(time, 10); } - return undefined; + time = isUtc ? moment.utc(time) : moment(time); + return time.format(DATE_FORMAT); } -export default class TimePicker extends PureComponent { +interface TimePickerProps { + isOpen?: boolean; + isUtc?: boolean; + range?: RawTimeRange; + onChangeTime?: (Range) => void; +} + +interface TimePickerState { + isOpen: boolean; + isUtc: boolean; + rangeString: string; + refreshInterval: string; + + // Input-controlled text, keep these in a shape that is human-editable + fromRaw: string; + toRaw: string; +} + +export default class TimePicker extends PureComponent { dropdownEl: any; + constructor(props) { super(props); - const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from; - const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to; + const from = props.range ? props.range.from : DEFAULT_RANGE.from; + const to = props.range ? props.range.to : DEFAULT_RANGE.to; + + // Ensure internal format + const fromRaw = parseTime(from, props.isUtc); + const toRaw = parseTime(to, props.isUtc); const range = { - from: parseTime(fromRaw), - to: parseTime(toRaw), + from: fromRaw, + to: toRaw, }; + this.state = { - fromRaw: parseTime(fromRaw, props.isUtc, true), + fromRaw, + toRaw, isOpen: props.isOpen, isUtc: props.isUtc, rangeString: rangeUtil.describeTimeRange(range), refreshInterval: '', - toRaw: parseTime(toRaw, props.isUtc, true), }; } - move(direction) { + move(direction: number) { const { onChangeTime } = this.props; const { fromRaw, toRaw } = this.state; - const range = { - from: dateMath.parse(fromRaw, false), - to: dateMath.parse(toRaw, true), - }; + const from = dateMath.parse(fromRaw, false); + const to = dateMath.parse(toRaw, true); + const timespan = (to.valueOf() - from.valueOf()) / 2; - const timespan = (range.to.valueOf() - range.from.valueOf()) / 2; - let to, from; + let nextTo, nextFrom; if (direction === -1) { - to = range.to.valueOf() - timespan; - from = range.from.valueOf() - timespan; + nextTo = to.valueOf() - timespan; + nextFrom = from.valueOf() - timespan; } else if (direction === 1) { - to = range.to.valueOf() + timespan; - from = range.from.valueOf() + timespan; - if (to > Date.now() && range.to < Date.now()) { - to = Date.now(); - from = range.from.valueOf(); + nextTo = to.valueOf() + timespan; + nextFrom = from.valueOf() + timespan; + if (nextTo > Date.now() && to < Date.now()) { + nextTo = Date.now(); + nextFrom = from.valueOf(); } } else { - to = range.to.valueOf(); - from = range.from.valueOf(); + nextTo = to.valueOf(); + nextFrom = from.valueOf(); } - const rangeString = rangeUtil.describeTimeRange(range); - // No need to convert to UTC again - to = moment(to); - from = moment(from); + const nextRange = { + from: moment(nextFrom), + to: moment(nextTo), + }; this.setState( { - rangeString, - fromRaw: from.format(DATE_FORMAT), - toRaw: to.format(DATE_FORMAT), + rangeString: rangeUtil.describeTimeRange(nextRange), + fromRaw: nextRange.from.format(DATE_FORMAT), + toRaw: nextRange.to.format(DATE_FORMAT), }, () => { - onChangeTime({ to, from }); + onChangeTime(nextRange); } ); } @@ -99,16 +127,19 @@ export default class TimePicker extends PureComponent { handleClickApply = () => { const { onChangeTime } = this.props; - const { toRaw, fromRaw } = this.state; - const range = { - from: dateMath.parse(fromRaw, false), - to: dateMath.parse(toRaw, true), - }; - const rangeString = rangeUtil.describeTimeRange(range); + let range; this.setState( - { - isOpen: false, - rangeString, + state => { + const { toRaw, fromRaw } = this.state; + range = { + from: dateMath.parse(fromRaw, false), + to: dateMath.parse(toRaw, true), + }; + const rangeString = rangeUtil.describeTimeRange(range); + return { + isOpen: false, + rangeString, + }; }, () => { if (onChangeTime) { diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index 0c01cbe01ba..13882e030f6 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/public/app/features/explore/Typeahead.tsx @@ -1,7 +1,7 @@ import React from 'react'; import Highlighter from 'react-highlight-words'; -import { Suggestion, SuggestionGroup } from './QueryField'; +import { CompletionItem, CompletionItemGroup } from 'app/types/explore'; function scrollIntoView(el: HTMLElement) { if (!el || !el.offsetParent) { @@ -15,12 +15,12 @@ function scrollIntoView(el: HTMLElement) { interface TypeaheadItemProps { isSelected: boolean; - item: Suggestion; + item: CompletionItem; onClickItem: (Suggestion) => void; prefix?: string; } -class TypeaheadItem extends React.PureComponent { +class TypeaheadItem extends React.PureComponent { el: HTMLElement; componentDidUpdate(prevProps) { @@ -53,14 +53,14 @@ class TypeaheadItem extends React.PureComponent { } interface TypeaheadGroupProps { - items: Suggestion[]; + items: CompletionItem[]; label: string; - onClickItem: (Suggestion) => void; - selected: Suggestion; + onClickItem: (CompletionItem) => void; + selected: CompletionItem; prefix?: string; } -class TypeaheadGroup extends React.PureComponent { +class TypeaheadGroup extends React.PureComponent { render() { const { items, label, selected, onClickItem, prefix } = this.props; return ( @@ -85,13 +85,13 @@ class TypeaheadGroup extends React.PureComponent { } interface TypeaheadProps { - groupedItems: SuggestionGroup[]; + groupedItems: CompletionItemGroup[]; menuRef: any; - selectedItem: Suggestion | null; + selectedItem: CompletionItem | null; onClickItem: (Suggestion) => void; prefix?: string; } -class Typeahead extends React.PureComponent { +class Typeahead extends React.PureComponent { render() { const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props; return ( diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index 7e07aafbf6d..de1eee4c662 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -7,6 +7,7 @@ import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore' import { StoreState } from 'app/types'; import { ExploreState } from 'app/types/explore'; +import ErrorBoundary from './ErrorBoundary'; import Explore from './Explore'; interface WrapperProps { @@ -61,28 +62,33 @@ export class Wrapper extends Component { const { split, splitState } = this.state; const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]); const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]); + return (
    - - {split && ( + + + {split && ( + + + )}
    ); diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap index d6760dff59c..fd2010a76d3 100644 --- a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap @@ -1,468 +1,468 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should render component 1`] = ` -
    +
    -
    + - -
    + "values": Array [ + Array [ + 1537858100, + "16", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/label/:name/values", + "instance": "localhost:9090", + "job": "prometheus", + "le": "3", + }, + "values": Array [ + Array [ + 1537858100, + "16", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/label/:name/values", + "instance": "localhost:9090", + "job": "prometheus", + "le": "60", + }, + "values": Array [ + Array [ + 1537858100, + "16", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/label/:name/values", + "instance": "localhost:9090", + "job": "prometheus", + "le": "8", + }, + "values": Array [ + Array [ + 1537858100, + "16", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "+Inf", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "0.1", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "0.4", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "1", + }, + "values": Array [ + Array [ + 1537847900, + "953", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "120", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "20", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "3", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "60", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "8", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + ] + } + />
    `; exports[`Render should render component with disclaimer 1`] = ` -
    +
    @@ -478,484 +478,480 @@ exports[`Render should render component with disclaimer 1`] = `
    -
    + - -
    + "values": Array [ + Array [ + 1537858100, + "16", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/label/:name/values", + "instance": "localhost:9090", + "job": "prometheus", + "le": "60", + }, + "values": Array [ + Array [ + 1537858100, + "16", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/label/:name/values", + "instance": "localhost:9090", + "job": "prometheus", + "le": "8", + }, + "values": Array [ + Array [ + 1537858100, + "16", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "+Inf", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "0.1", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "0.4", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "1", + }, + "values": Array [ + Array [ + 1537847900, + "953", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "120", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "20", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "3", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "60", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/metrics", + "instance": "localhost:9090", + "job": "prometheus", + "le": "8", + }, + "values": Array [ + Array [ + 1537858060, + "1195", + ], + Array [ + 1537858080, + "1195", + ], + Array [ + 1537858100, + "1195", + ], + ], + }, + Object { + "metric": Object { + "__name__": "prometheus_http_request_duration_seconds_bucket", + "handler": "/query", + "instance": "localhost:9090", + "job": "prometheus", + "le": "+Inf", + }, + "values": Array [ + Array [ + 1537858100, + "55", + ], + Array [ + 1537861960, + "1", + ], + Array [ + 1537861980, + "1", + ], + ], + }, + ] + } + />
    `; @@ -964,9 +960,16 @@ exports[`Render should show query return no time series 1`] = ` className="panel-container" >
    - The queries returned no time series to graph. -
    + className="explore-graph" + id="graph" + style={ + Object { + "height": "100px", + } + } + /> +
    `; diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index b42b06f1238..e517c48bb59 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -7,13 +7,11 @@ import { PanelCtrl } from 'app/features/panel/panel_ctrl'; import * as rangeUtil from 'app/core/utils/rangeutil'; import * as dateMath from 'app/core/utils/datemath'; import { getExploreUrl } from 'app/core/utils/explore'; - import { metricsTabDirective } from './metrics_tab'; class MetricsPanelCtrl extends PanelCtrl { scope: any; datasource: any; - datasourceName: any; $q: any; $timeout: any; contextSrv: any; @@ -45,10 +43,6 @@ class MetricsPanelCtrl extends PanelCtrl { this.scope = $scope; this.panel.datasource = this.panel.datasource || null; - if (!this.panel.targets) { - this.panel.targets = [{}]; - } - this.events.on('refresh', this.onMetricsPanelRefresh.bind(this)); this.events.on('init-edit-mode', this.onInitMetricsPanelEditMode.bind(this)); this.events.on('panel-teardown', this.onPanelTearDown.bind(this)); @@ -62,7 +56,7 @@ class MetricsPanelCtrl extends PanelCtrl { } private onInitMetricsPanelEditMode() { - this.addEditorTab('Metrics', metricsTabDirective); + this.addEditorTab('Metrics', metricsTabDirective, 1, 'fa fa-database'); this.addEditorTab('Time range', 'public/app/features/panel/partials/panelTime.html'); } @@ -291,27 +285,6 @@ class MetricsPanelCtrl extends PanelCtrl { }); } - setDatasource(datasource) { - // switching to mixed - if (datasource.meta.mixed) { - _.each(this.panel.targets, target => { - target.datasource = this.panel.datasource; - if (!target.datasource) { - target.datasource = config.defaultDatasource; - } - }); - } else if (this.datasource && this.datasource.meta.mixed) { - _.each(this.panel.targets, target => { - delete target.datasource; - }); - } - - this.panel.datasource = datasource.value; - this.datasourceName = datasource.name; - this.datasource = null; - this.refresh(); - } - getAdditionalMenuItems() { const items = []; if ( diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts index 3a1d0abe1c2..f520b5eefc0 100644 --- a/public/app/features/panel/metrics_tab.ts +++ b/public/app/features/panel/metrics_tab.ts @@ -1,6 +1,14 @@ -import { DashboardModel } from '../dashboard/dashboard_model'; +// Libraries +import _ from 'lodash'; import Remarkable from 'remarkable'; +// Services & utils +import coreModule from 'app/core/core_module'; +import config from 'app/core/config'; + +// Types +import { DashboardModel } from '../dashboard/dashboard_model'; + export class MetricsTabCtrl { dsName: string; panel: any; @@ -24,6 +32,9 @@ export class MetricsTabCtrl { $scope.ctrl = this; this.panel = this.panelCtrl.panel; + this.panel.datasource = this.panel.datasource || null; + this.panel.targets = this.panel.targets || [{}]; + this.dashboard = this.panelCtrl.dashboard; this.datasources = datasourceSrv.getMetricSources(); this.panelDsValue = this.panelCtrl.panel.datasource; @@ -66,10 +77,29 @@ export class MetricsTabCtrl { } this.datasourceInstance = option.datasource; - this.panelCtrl.setDatasource(option.datasource); + this.setDatasource(option.datasource); this.updateDatasourceOptions(); } + setDatasource(datasource) { + // switching to mixed + if (datasource.meta.mixed) { + _.each(this.panel.targets, target => { + target.datasource = this.panel.datasource; + if (!target.datasource) { + target.datasource = config.defaultDatasource; + } + }); + } else if (this.datasourceInstance && this.datasourceInstance.meta.mixed) { + _.each(this.panel.targets, target => { + delete target.datasource; + }); + } + + this.panel.datasource = datasource.value; + this.panel.refresh(); + } + addMixedQuery(option) { if (!option) { return; @@ -120,3 +150,5 @@ export function metricsTabDirective() { controller: MetricsTabCtrl, }; } + +coreModule.directive('metricsTab', metricsTabDirective); diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index e2ae5cc78a9..5e216f6b34d 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -24,10 +24,8 @@ export class PanelCtrl { $injector: any; $location: any; $timeout: any; - fullscreen: boolean; inspector: any; editModeInitiated: boolean; - editMode: any; height: any; containerHeight: any; events: Emitter; @@ -49,7 +47,6 @@ export class PanelCtrl { this.pluginName = plugin.name; } - $scope.$on('refresh', () => this.refresh()); $scope.$on('component-did-mount', () => this.panelDidMount()); $scope.$on('$destroy', () => { @@ -58,13 +55,9 @@ export class PanelCtrl { }); } - init() { - this.events.emit('panel-initialized'); - this.publishAppEvent('panel-initialized', { scope: this.$scope }); - } - panelDidMount() { this.events.emit('component-did-mount'); + this.dashboard.panelInitialized(this.panel); } renderingCompleted() { @@ -72,7 +65,7 @@ export class PanelCtrl { } refresh() { - this.events.emit('refresh', null); + this.panel.refresh(); } publishAppEvent(evtName, evt) { @@ -102,6 +95,7 @@ export class PanelCtrl { initEditMode() { this.editorTabs = []; this.addEditorTab('General', 'public/app/partials/panelgeneral.html'); + this.editModeInitiated = true; this.events.emit('init-edit-mode', null); @@ -122,14 +116,15 @@ export class PanelCtrl { route.updateParams(); } - addEditorTab(title, directiveFn, index?) { - const editorTab = { title, directiveFn }; + addEditorTab(title, directiveFn, index?, icon?) { + const editorTab = { title, directiveFn, icon }; if (_.isString(directiveFn)) { editorTab.directiveFn = () => { return { templateUrl: directiveFn }; }; } + if (index) { this.editorTabs.splice(index, 0, editorTab); } else { @@ -190,7 +185,7 @@ export class PanelCtrl { getExtendedMenu() { const menu = []; - if (!this.fullscreen && this.dashboard.meta.canEdit) { + if (!this.panel.fullscreen && this.dashboard.meta.canEdit) { menu.push({ text: 'Duplicate', click: 'ctrl.duplicate()', @@ -220,15 +215,15 @@ export class PanelCtrl { } otherPanelInFullscreenMode() { - return this.dashboard.meta.fullscreen && !this.fullscreen; + return this.dashboard.meta.fullscreen && !this.panel.fullscreen; } calculatePanelHeight() { - if (this.fullscreen) { - const docHeight = $(window).height(); - const editHeight = Math.floor(docHeight * 0.4); + if (this.panel.fullscreen) { + const docHeight = $('.react-grid-layout').height(); + const editHeight = Math.floor(docHeight * 0.35); const fullscreenHeight = Math.floor(docHeight * 0.8); - this.containerHeight = this.editMode ? editHeight : fullscreenHeight; + this.containerHeight = this.panel.isEditing ? editHeight : fullscreenHeight; } else { this.containerHeight = this.panel.gridPos.h * GRID_CELL_HEIGHT + (this.panel.gridPos.h - 1) * GRID_CELL_VMARGIN; } @@ -237,6 +232,11 @@ export class PanelCtrl { this.containerHeight = $(window).height(); } + // hacky solution + if (this.panel.isEditing && !this.editModeInitiated) { + this.initEditMode(); + } + this.height = this.containerHeight - (PANEL_BORDER + TITLE_HEIGHT); } @@ -247,9 +247,6 @@ export class PanelCtrl { duplicate() { this.dashboard.duplicatePanel(this.panel); - this.$timeout(() => { - this.$scope.$root.$broadcast('render'); - }); } removePanel() { diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index 8b742e17952..77ebf754b3a 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -6,48 +6,53 @@ import baron from 'baron'; const module = angular.module('grafana.directives'); const panelTemplate = ` -
    -
    - - - - +
    +
    +
    +
    + + + + - - - + + + - -
    + +
    -
    - -
    -
    - -
    -
    -
    -

    - {{ctrl.pluginName}} -

    - - - - +
    + +
    +
    -
    -
    - +
    +
    +
    +

    + {{ctrl.pluginName}} +

    + + + + +
    + +
    +
    + +
    @@ -85,10 +90,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { ctrl.dashboard.setPanelFocus(0); } - function panelHeightUpdated() { - panelContent.css({ height: ctrl.height + 'px' }); - } - function resizeScrollableContent() { if (panelScrollbar) { panelScrollbar.update(); @@ -133,7 +134,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { ctrl.events.on('panel-size-changed', () => { ctrl.calculatePanelHeight(); - panelHeightUpdated(); $timeout(() => { resizeScrollableContent(); ctrl.render(); @@ -142,7 +142,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => { // set initial height ctrl.calculatePanelHeight(); - panelHeightUpdated(); ctrl.events.on('render', () => { if (transparentLastState !== ctrl.panel.transparent) { diff --git a/public/app/features/panel/panel_editor_tab.ts b/public/app/features/panel/panel_editor_tab.ts index 13a9369856a..f7e1c48a323 100644 --- a/public/app/features/panel/panel_editor_tab.ts +++ b/public/app/features/panel/panel_editor_tab.ts @@ -1,6 +1,7 @@ import angular from 'angular'; const directiveModule = angular.module('grafana.directives'); +const directiveCache = {}; /** @ngInject */ function panelEditorTab(dynamicDirectiveSrv) { @@ -12,17 +13,24 @@ function panelEditorTab(dynamicDirectiveSrv) { }, directive: scope => { const pluginId = scope.ctrl.pluginId; - const tabIndex = scope.index; - // create a wrapper for directiveFn - // required for metrics tab directive - // that is the same for many panels but - // given different names in this function - const fn = () => scope.editorTab.directiveFn(); + const tabName = scope.editorTab.title.toLowerCase().replace(' ', '-'); - return Promise.resolve({ - name: `panel-editor-tab-${pluginId}${tabIndex}`, - fn: fn, - }); + if (directiveCache[pluginId]) { + if (directiveCache[pluginId][tabName]) { + return directiveCache[pluginId][tabName]; + } + } else { + directiveCache[pluginId] = []; + } + + const result = { + fn: () => scope.editorTab.directiveFn(), + name: `panel-editor-tab-${pluginId}${tabName}`, + }; + + directiveCache[pluginId][tabName] = result; + + return result; }, }); } diff --git a/public/app/features/panel/panel_header.ts b/public/app/features/panel/panel_header.ts index 5fa20c4714b..1d29d04ad98 100644 --- a/public/app/features/panel/panel_header.ts +++ b/public/app/features/panel/panel_header.ts @@ -8,21 +8,6 @@ const template = ` {{ctrl.timeInfo}} diff --git a/public/app/features/panel/partials/metrics_tab.html b/public/app/features/panel/partials/metrics_tab.html index 0ee1f81b0c3..815a99d6b74 100644 --- a/public/app/features/panel/partials/metrics_tab.html +++ b/public/app/features/panel/partials/metrics_tab.html @@ -1,11 +1,7 @@
    - - { + this.dashboard.changePanelType(this.panelCtrl.panel, plugin.id); + }; +} + +const template = ` +
    +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    `; + +/** @ngInject */ +export function vizTabDirective() { + 'use strict'; + return { + restrict: 'E', + template: template, + controller: VizTabCtrl, + }; +} + +react2AngularDirective('vizTypePicker', VizTypePicker, ['currentType', ['onTypeChanged', { watchDepth: 'reference' }]]); +coreModule.directive('vizTab', vizTabDirective); diff --git a/public/app/features/plugins/all.ts b/public/app/features/plugins/all.ts index d164a6d4255..c9fb250266c 100644 --- a/public/app/features/plugins/all.ts +++ b/public/app/features/plugins/all.ts @@ -2,6 +2,5 @@ import './plugin_edit_ctrl'; import './plugin_page_ctrl'; import './import_list/import_list'; import './ds_edit_ctrl'; -import './ds_dashboards_ctrl'; import './datasource_srv'; import './plugin_component'; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index e29e1709ccf..b9779190a8b 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -14,6 +14,8 @@ import * as testDataDSPlugin from 'app/plugins/datasource/testdata/module'; import * as stackdriverPlugin from 'app/plugins/datasource/stackdriver/module'; import * as textPanel from 'app/plugins/panel/text/module'; +import * as text2Panel from 'app/plugins/panel/text2/module'; +import * as graph2Panel from 'app/plugins/panel/graph2/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'; @@ -40,6 +42,8 @@ const builtInPlugins = { 'app/plugins/datasource/stackdriver/module': stackdriverPlugin, 'app/plugins/panel/text/module': textPanel, + 'app/plugins/panel/text2/module': text2Panel, + 'app/plugins/panel/graph2/module': graph2Panel, 'app/plugins/panel/graph/module': graphPanel, 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 7ef82519668..71a417a882f 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -1,8 +1,14 @@ +// Libraries import _ from 'lodash'; import coreModule from 'app/core/core_module'; + +// Utils import config from 'app/core/config'; import { importPluginModule } from './plugin_loader'; +// Types +import { DataSourceApi } from 'app/types/series'; + export class DatasourceSrv { datasources: any; @@ -15,7 +21,7 @@ export class DatasourceSrv { this.datasources = {}; } - get(name?) { + get(name?): Promise { if (!name) { return this.get(config.defaultDatasource); } @@ -162,5 +168,15 @@ export class DatasourceSrv { } } +let singleton: DatasourceSrv; + +export function setDatasourceSrv(srv: DatasourceSrv) { + singleton = srv; +} + +export function getDatasourceSrv(): DatasourceSrv { + return singleton; +} + coreModule.service('datasourceSrv', DatasourceSrv); export default DatasourceSrv; diff --git a/public/app/features/plugins/partials/ds_dashboards.html b/public/app/features/plugins/partials/ds_dashboards.html deleted file mode 100644 index a442e37d1f6..00000000000 --- a/public/app/features/plugins/partials/ds_dashboards.html +++ /dev/null @@ -1,7 +0,0 @@ - - -
    - - - -
    diff --git a/public/app/features/plugins/plugin_component.ts b/public/app/features/plugins/plugin_component.ts index 41d1b6f1deb..142eb942a30 100644 --- a/public/app/features/plugins/plugin_component.ts +++ b/public/app/features/plugins/plugin_component.ts @@ -8,7 +8,7 @@ import { importPluginModule } from './plugin_loader'; import { UnknownPanelCtrl } from 'app/plugins/panel/unknown/module'; /** @ngInject */ -function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $templateCache) { +function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $templateCache, $timeout) { function getTemplate(component) { if (component.template) { return $q.when(component.template); @@ -95,7 +95,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ PanelCtrl.templatePromise = getTemplate(PanelCtrl).then(template => { PanelCtrl.templateUrl = null; - PanelCtrl.template = `${template}`; + PanelCtrl.template = `${template}`; return componentInfo; }); @@ -207,10 +207,13 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ // let a binding digest cycle complete before adding to dom setTimeout(() => { - elem.append(child); scope.$applyAsync(() => { - scope.$broadcast('component-did-mount'); - scope.$broadcast('refresh'); + elem.append(child); + setTimeout(() => { + scope.$applyAsync(() => { + scope.$broadcast('component-did-mount'); + }); + }); }); }); } @@ -245,7 +248,6 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ registerPluginComponent(scope, elem, attrs, componentInfo); }) .catch(err => { - $rootScope.appEvent('alert-error', ['Plugin Error', err.message || err]); console.log('Plugin component error', err); }); }, diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index bc3c719917c..8e0958f6c1b 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -18,6 +18,7 @@ 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 { PluginExports } from 'app/types/plugins'; 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'; @@ -140,11 +141,12 @@ const flotDeps = [ 'jquery.flot.events', 'jquery.flot.gauge', ]; + for (const flotDep of flotDeps) { exposeToPlugin(flotDep, { fakeDep: 1 }); } -export function importPluginModule(path: string): Promise { +export function importPluginModule(path: string): Promise { const builtIn = builtInPlugins[path]; if (builtIn) { return Promise.resolve(builtIn); diff --git a/public/app/features/plugins/state/actions.ts b/public/app/features/plugins/state/actions.ts index dcfd510ffa0..9f53f3c1fd0 100644 --- a/public/app/features/plugins/state/actions.ts +++ b/public/app/features/plugins/state/actions.ts @@ -2,9 +2,11 @@ import { Plugin, StoreState } from 'app/types'; import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from '../../../core/services/backend_srv'; import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; +import { PluginDashboard } from '../../../types/plugins'; export enum ActionTypes { LoadPlugins = 'LOAD_PLUGINS', + LoadPluginDashboards = 'LOAD_PLUGIN_DASHBOARDS', SetPluginsSearchQuery = 'SET_PLUGIN_SEARCH_QUERY', SetLayoutMode = 'SET_LAYOUT_MODE', } @@ -14,6 +16,11 @@ export interface LoadPluginsAction { payload: Plugin[]; } +export interface LoadPluginDashboardsAction { + type: ActionTypes.LoadPluginDashboards; + payload: PluginDashboard[]; +} + export interface SetPluginsSearchQueryAction { type: ActionTypes.SetPluginsSearchQuery; payload: string; @@ -39,7 +46,12 @@ const pluginsLoaded = (plugins: Plugin[]): LoadPluginsAction => ({ payload: plugins, }); -export type Action = LoadPluginsAction | SetPluginsSearchQueryAction | SetLayoutModeAction; +const pluginDashboardsLoaded = (dashboards: PluginDashboard[]): LoadPluginDashboardsAction => ({ + type: ActionTypes.LoadPluginDashboards, + payload: dashboards, +}); + +export type Action = LoadPluginsAction | LoadPluginDashboardsAction | SetPluginsSearchQueryAction | SetLayoutModeAction; type ThunkResult = ThunkAction; @@ -49,3 +61,12 @@ export function loadPlugins(): ThunkResult { dispatch(pluginsLoaded(result)); }; } + +export function loadPluginDashboards(): ThunkResult { + return async (dispatch, getStore) => { + const dataSourceType = getStore().dataSources.dataSource.type; + + const response = await getBackendSrv().get(`api/plugins/${dataSourceType}/dashboards`); + dispatch(pluginDashboardsLoaded(response)); + }; +} diff --git a/public/app/features/plugins/state/navModel.ts b/public/app/features/plugins/state/navModel.ts index 852eb2806f9..f12967ebb7a 100644 --- a/public/app/features/plugins/state/navModel.ts +++ b/public/app/features/plugins/state/navModel.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import { DataSource, PluginMeta, NavModel } from 'app/types'; +import config from 'app/core/config'; export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: string): NavModel { let title = 'New'; @@ -38,6 +39,16 @@ export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: s }); } + if (config.buildInfo.isEnterprise) { + main.children.push({ + active: currentPage === 'datasource-permissions', + icon: 'fa fa-fw fa-lock', + id: 'datasource-permissions', + text: 'Permissions', + url: `datasources/edit/${ds.id}/permissions`, + }); + } + return { main: main, node: _.find(main.children, { active: true }), diff --git a/public/app/features/plugins/state/reducers.ts b/public/app/features/plugins/state/reducers.ts index bd99d2029f5..0d464ef1772 100644 --- a/public/app/features/plugins/state/reducers.ts +++ b/public/app/features/plugins/state/reducers.ts @@ -1,12 +1,14 @@ import { Action, ActionTypes } from './actions'; import { Plugin, PluginsState } from 'app/types'; import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector'; +import { PluginDashboard } from '../../../types/plugins'; export const initialState: PluginsState = { plugins: [] as Plugin[], searchQuery: '', layoutMode: LayoutModes.Grid, hasFetched: false, + dashboards: [] as PluginDashboard[], }; export const pluginsReducer = (state = initialState, action: Action): PluginsState => { @@ -19,6 +21,9 @@ export const pluginsReducer = (state = initialState, action: Action): PluginsSta case ActionTypes.SetLayoutMode: return { ...state, layoutMode: action.payload }; + + case ActionTypes.LoadPluginDashboards: + return { ...state, dashboards: action.payload }; } return state; }; diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index ef9a5ae0b70..45977de95bf 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Label } from 'app/core/components/Forms/Forms'; +import { Label } from 'app/core/components/Label/Label'; import { Team } from '../../types'; import { updateTeam } from './state/actions'; import { getRouteParamsId } from '../../core/selectors/location'; diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html index ac4450c20a2..c4463972177 100644 --- a/public/app/features/templating/partials/editor.html +++ b/public/app/features/templating/partials/editor.html @@ -115,7 +115,7 @@
    Values - +
    diff --git a/public/app/features/templating/specs/template_srv.test.ts b/public/app/features/templating/specs/template_srv.test.ts index 7f5ff959216..7805341d1a2 100644 --- a/public/app/features/templating/specs/template_srv.test.ts +++ b/public/app/features/templating/specs/template_srv.test.ts @@ -286,10 +286,40 @@ describe('templateSrv', () => { initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); - it('should return true if exists', () => { + it('should return true if $test exists', () => { const result = _templateSrv.variableExists('$test'); expect(result).toBe(true); }); + + it('should return true if $test exists in string', () => { + const result = _templateSrv.variableExists('something $test something'); + expect(result).toBe(true); + }); + + it('should return true if [[test]] exists in string', () => { + const result = _templateSrv.variableExists('something [[test]] something'); + expect(result).toBe(true); + }); + + it('should return true if [[test:csv]] exists in string', () => { + const result = _templateSrv.variableExists('something [[test:csv]] something'); + expect(result).toBe(true); + }); + + it('should return true if ${test} exists in string', () => { + const result = _templateSrv.variableExists('something ${test} something'); + expect(result).toBe(true); + }); + + it('should return true if ${test:raw} exists in string', () => { + const result = _templateSrv.variableExists('something ${test:raw} something'); + expect(result).toBe(true); + }); + + it('should return null if there are no variables in string', () => { + const result = _templateSrv.variableExists('string without variables'); + expect(result).toBe(null); + }); }); describe('can highlight variables in string', () => { @@ -429,6 +459,11 @@ describe('templateSrv', () => { name: 'period', current: { value: '$__auto_interval_interval', text: 'auto' }, }, + { + type: 'textbox', + name: 'empty_on_init', + current: { value: '', text: '' }, + }, ]); _templateSrv.setGrafanaVariable('$__auto_interval_interval', '13m'); _templateSrv.updateTemplateData(); @@ -438,6 +473,11 @@ describe('templateSrv', () => { const target = _templateSrv.replaceWithText('Server: $server, period: $period'); expect(target).toBe('Server: All, period: 13m'); }); + + it('should replace empty string-values with an empty string', () => { + const target = _templateSrv.replaceWithText('Hello $empty_on_init'); + expect(target).toBe('Hello '); + }); }); describe('built in interval variables', () => { diff --git a/public/app/features/templating/specs/variable.test.ts b/public/app/features/templating/specs/variable.test.ts index 6d5e88fa4bd..83f4af8bca9 100644 --- a/public/app/features/templating/specs/variable.test.ts +++ b/public/app/features/templating/specs/variable.test.ts @@ -22,6 +22,11 @@ describe('containsVariable', () => { expect(contains).toBe(true); }); + it('should find it with [[var:option]] syntax', () => { + const contains = containsVariable('this.[[test:csv]].filters', 'test'); + expect(contains).toBe(true); + }); + it('should find it when part of segment', () => { const contains = containsVariable('metrics.$env.$group-*', 'group'); expect(contains).toBe(true); @@ -36,6 +41,16 @@ describe('containsVariable', () => { const contains = containsVariable('asd', 'asd2.$env', 'env'); expect(contains).toBe(true); }); + + it('should find it with ${var} syntax', () => { + const contains = containsVariable('this.${test}.filters', 'test'); + expect(contains).toBe(true); + }); + + it('should find it with ${var:option} syntax', () => { + const contains = containsVariable('this.${test:csv}.filters', 'test'); + expect(contains).toBe(true); + }); }); }); diff --git a/public/app/features/templating/specs/variable_srv.test.ts b/public/app/features/templating/specs/variable_srv.test.ts index 359d5b79a38..3df6ccb8b5b 100644 --- a/public/app/features/templating/specs/variable_srv.test.ts +++ b/public/app/features/templating/specs/variable_srv.test.ts @@ -1,5 +1,6 @@ import '../all'; import { VariableSrv } from '../variable_srv'; +import { DashboardModel } from '../../dashboard/dashboard_model'; import moment from 'moment'; import $q from 'q'; @@ -56,10 +57,12 @@ describe('VariableSrv', function(this: any) { return getVarMockConstructor(ctr, model, ctx); }; - ctx.variableSrv.init({ - templating: { list: [] }, - updateSubmenuVisibility: () => {}, - }); + ctx.variableSrv.init( + new DashboardModel({ + templating: { list: [] }, + updateSubmenuVisibility: () => {}, + }) + ); scenario.variable = ctx.variableSrv.createVariableFromModel(scenario.variableModel); ctx.variableSrv.addVariable(scenario.variable); diff --git a/public/app/features/templating/specs/variable_srv_init.test.ts b/public/app/features/templating/specs/variable_srv_init.test.ts index b5d00a5289e..bda5b6aa577 100644 --- a/public/app/features/templating/specs/variable_srv_init.test.ts +++ b/public/app/features/templating/specs/variable_srv_init.test.ts @@ -2,6 +2,7 @@ import '../all'; import _ from 'lodash'; import { VariableSrv } from '../variable_srv'; +import { DashboardModel } from '../../dashboard/dashboard_model'; import $q from 'q'; describe('VariableSrv init', function(this: any) { @@ -56,9 +57,9 @@ describe('VariableSrv init', function(this: any) { ctx.variableSrv.datasourceSrv = ctx.datasourceSrv; ctx.variableSrv.$location.search = () => scenario.urlParams; - ctx.variableSrv.dashboard = { + ctx.variableSrv.dashboard = new DashboardModel({ templating: { list: scenario.variables }, - }; + }); await ctx.variableSrv.init(ctx.variableSrv.dashboard); diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 70fd287402f..0db7b8e77e0 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -1,5 +1,6 @@ import kbn from 'app/core/utils/kbn'; import _ from 'lodash'; +import { variableRegex } from 'app/features/templating/variable'; function luceneEscape(value) { return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1'); @@ -8,13 +9,7 @@ function luceneEscape(value) { export class TemplateSrv { variables: any[]; - /* - * This regex matches 3 types of variable reference with an optional format specifier - * \$(\w+) $var1 - * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] - * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} - */ - private regex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g; + private regex = variableRegex; private index = {}; private grafanaVariables = {}; private builtIns = {}; @@ -30,17 +25,14 @@ export class TemplateSrv { } updateTemplateData() { - this.index = {}; + const existsOrEmpty = value => value || value === ''; - for (let i = 0; i < this.variables.length; i++) { - const variable = this.variables[i]; - - if (!variable.current || (!variable.current.isNone && !variable.current.value)) { - continue; + this.index = this.variables.reduce((acc, currentValue) => { + if (currentValue.current && !currentValue.current.isNone && existsOrEmpty(currentValue.current.value)) { + acc[currentValue.name] = currentValue; } - - this.index[variable.name] = variable; - } + return acc; + }, {}); } variableInitialized(variable) { @@ -144,7 +136,8 @@ export class TemplateSrv { if (!match) { return null; } - return match[1] || match[2]; + const variableName = match.slice(1).find(match => match !== undefined); + return variableName; } variableExists(expression) { diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index 412426fb294..1994e86eff0 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -1,6 +1,19 @@ -import kbn from 'app/core/utils/kbn'; import { assignModelProperties } from 'app/core/utils/model_utils'; +/* + * This regex matches 3 types of variable reference with an optional format specifier + * \$(\w+) $var1 + * \[\[([\s\S]+?)(?::(\w+))?\]\] [[var2]] or [[var2:fmt2]] + * \${(\w+)(?::(\w+))?} ${var3} or ${var3:fmt3} + */ +export const variableRegex = /\$(\w+)|\[\[([\s\S]+?)(?::(\w+))?\]\]|\${(\w+)(?::(\w+))?}/g; + +// Helper function since lastIndex is not reset +export const variableRegexExec = (variableString: string) => { + variableRegex.lastIndex = 0; + return variableRegex.exec(variableString); +}; + export interface Variable { setValue(option); updateOptions(); @@ -14,15 +27,16 @@ export let variableTypes = {}; export { assignModelProperties }; export function containsVariable(...args: any[]) { - let variableName = args[args.length - 1]; - let str = args[0] || ''; + const variableName = args[args.length - 1]; + const variableString = args.slice(0, -1).join(' '); + const matches = variableString.match(variableRegex); + const isMatchingVariable = + matches !== null + ? matches.find(match => { + const varMatch = variableRegexExec(match); + return varMatch !== null && varMatch.indexOf(variableName) > -1; + }) + : false; - for (let i = 1; i < args.length - 1; i++) { - str += ' ' + args[i] || ''; - } - - variableName = kbn.regexEscape(variableName); - const findVarRegex = new RegExp('\\$(' + variableName + ')(?:\\W|$)|\\[\\[(' + variableName + ')\\]\\]', 'g'); - const match = findVarRegex.exec(str); - return match !== null; + return !!isMatchingVariable; } diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts index 75e2ca35ec7..a676f9c2848 100644 --- a/public/app/features/templating/variable_srv.ts +++ b/public/app/features/templating/variable_srv.ts @@ -1,5 +1,8 @@ +// Libaries import angular from 'angular'; import _ from 'lodash'; + +// Utils & Services import coreModule from 'app/core/core_module'; import { variableTypes } from './variable'; import { Graph } from 'app/core/utils/dag'; @@ -10,13 +13,12 @@ export class VariableSrv { /** @ngInject */ constructor(private $rootScope, private $q, private $location, private $injector, private templateSrv) { - // update time variant variables - $rootScope.$on('refresh', this.onDashboardRefresh.bind(this), $rootScope); $rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope); } init(dashboard) { this.dashboard = dashboard; + this.dashboard.events.on('time-range-updated', this.onTimeRangeUpdated.bind(this)); // create working class models representing variables this.variables = dashboard.templating.list = dashboard.templating.list.map(this.createVariableFromModel.bind(this)); @@ -39,11 +41,7 @@ export class VariableSrv { }); } - onDashboardRefresh(evt, payload) { - if (payload && payload.fromVariableValueUpdated) { - return Promise.resolve({}); - } - + onTimeRangeUpdated() { const promises = this.variables.filter(variable => variable.refresh === 2).map(variable => { const previousOptions = variable.options.slice(); @@ -54,7 +52,9 @@ export class VariableSrv { }); }); - return this.$q.all(promises); + return this.$q.all(promises).then(() => { + this.dashboard.startRefresh(); + }); } processVariable(variable, queryParams) { @@ -133,7 +133,7 @@ export class VariableSrv { return this.$q.all(promises).then(() => { if (emitChangeEvents) { this.$rootScope.$emit('template-variable-value-updated'); - this.$rootScope.$broadcast('refresh', { fromVariableValueUpdated: true }); + this.dashboard.startRefresh(); } }); } diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html index 9506587c515..32acdc435f2 100644 --- a/public/app/partials/dashboard.html +++ b/public/app/partials/dashboard.html @@ -7,12 +7,11 @@ class="dashboard-settings"> -
    +
    - - +
    diff --git a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts index ff0d39944ca..6fe48cb1715 100644 --- a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts +++ b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts @@ -1,17 +1,21 @@ +import _ from 'lodash'; export class CloudWatchConfigCtrl { static templateUrl = 'partials/config.html'; current: any; + datasourceSrv: any; accessKeyExist = false; secretKeyExist = false; /** @ngInject */ - constructor($scope) { + constructor($scope, datasourceSrv) { this.current.jsonData.timeField = this.current.jsonData.timeField || '@timestamp'; this.current.jsonData.authType = this.current.jsonData.authType || 'credentials'; this.accessKeyExist = this.current.secureJsonFields.accessKey; this.secretKeyExist = this.current.secureJsonFields.secretKey; + this.datasourceSrv = datasourceSrv; + this.getRegions(); } resetAccessKey() { @@ -36,4 +40,47 @@ export class CloudWatchConfigCtrl { { name: 'Monthly', value: 'Monthly', example: '[logstash-]YYYY.MM' }, { name: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' }, ]; + + regions = [ + 'ap-northeast-1', + 'ap-northeast-2', + 'ap-northeast-3', + 'ap-south-1', + 'ap-southeast-1', + 'ap-southeast-2', + 'ca-central-1', + 'cn-north-1', + 'cn-northwest-1', + 'eu-central-1', + 'eu-north-1', + 'eu-west-1', + 'eu-west-2', + 'eu-west-3', + 'me-south-1', + 'sa-east-1', + 'us-east-1', + 'us-east-2', + 'us-gov-east-1', + 'us-gov-west-1', + 'us-iso-east-1', + 'us-isob-east-1', + 'us-west-1', + 'us-west-2', + ]; + + getRegions() { + this.datasourceSrv + .loadDatasource(this.current.name) + .then(ds => { + return ds.getRegions(); + }) + .then( + regions => { + this.regions = _.map(regions, 'value'); + }, + err => { + console.error('failed to get latest regions'); + } + ); + } } diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 7821c5dcb2c..3eb8eff2d09 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -37,6 +37,9 @@ export default class CloudWatchDatasource { item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars); item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars); item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars); + item.statistics = item.statistics.map(s => { + return this.templateSrv.replace(s, options.scopedVars); + }); item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting item.id = this.templateSrv.replace(item.id, options.scopedVars); item.expression = this.templateSrv.replace(item.expression, options.scopedVars); @@ -137,7 +140,11 @@ export default class CloudWatchDatasource { if (res.results) { _.forEach(res.results, queryRes => { _.forEach(queryRes.series, series => { - data.push({ target: series.name, datapoints: series.points, unit: queryRes.meta.unit || 'none' }); + const s = { target: series.name, datapoints: series.points } as any; + if (queryRes.meta.unit) { + s.unit = queryRes.meta.unit; + } + data.push(s); }); }); } diff --git a/public/app/plugins/datasource/cloudwatch/partials/config.html b/public/app/plugins/datasource/cloudwatch/partials/config.html index e5ab0910cba..40249d32b7e 100644 --- a/public/app/plugins/datasource/cloudwatch/partials/config.html +++ b/public/app/plugins/datasource/cloudwatch/partials/config.html @@ -39,7 +39,7 @@
    - + Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region. diff --git a/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts index 4f4b2961761..ba5a39688b3 100644 --- a/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts +++ b/public/app/plugins/datasource/cloudwatch/query_parameter_ctrl.ts @@ -1,4 +1,5 @@ import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; export class CloudWatchQueryParameter { @@ -239,5 +240,5 @@ export class CloudWatchQueryParameterCtrl { } } -angular.module('grafana.controllers').directive('cloudwatchQueryParameter', CloudWatchQueryParameter); -angular.module('grafana.controllers').controller('CloudWatchQueryParameterCtrl', CloudWatchQueryParameterCtrl); +coreModule.directive('cloudwatchQueryParameter', CloudWatchQueryParameter); +coreModule.controller('CloudWatchQueryParameterCtrl', CloudWatchQueryParameterCtrl); diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts b/public/app/plugins/datasource/elasticsearch/bucket_agg.ts index 8963f2c3f4b..cacf86201fe 100644 --- a/public/app/plugins/datasource/elasticsearch/bucket_agg.ts +++ b/public/app/plugins/datasource/elasticsearch/bucket_agg.ts @@ -1,4 +1,4 @@ -import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; import * as queryDef from './query_def'; @@ -226,6 +226,5 @@ export class ElasticBucketAggCtrl { } } -const module = angular.module('grafana.directives'); -module.directive('elasticBucketAgg', elasticBucketAgg); -module.controller('ElasticBucketAggCtrl', ElasticBucketAggCtrl); +coreModule.directive('elasticBucketAgg', elasticBucketAgg); +coreModule.controller('ElasticBucketAggCtrl', ElasticBucketAggCtrl); diff --git a/public/app/plugins/datasource/elasticsearch/metric_agg.ts b/public/app/plugins/datasource/elasticsearch/metric_agg.ts index 623eed68914..1dd0d892360 100644 --- a/public/app/plugins/datasource/elasticsearch/metric_agg.ts +++ b/public/app/plugins/datasource/elasticsearch/metric_agg.ts @@ -1,4 +1,4 @@ -import angular from 'angular'; +import coreModule from 'app/core/core_module'; import _ from 'lodash'; import * as queryDef from './query_def'; @@ -203,6 +203,5 @@ export class ElasticMetricAggCtrl { } } -const module = angular.module('grafana.directives'); -module.directive('elasticMetricAgg', elasticMetricAgg); -module.controller('ElasticMetricAggCtrl', ElasticMetricAggCtrl); +coreModule.directive('elasticMetricAgg', elasticMetricAgg); +coreModule.controller('ElasticMetricAggCtrl', ElasticMetricAggCtrl); diff --git a/public/app/plugins/datasource/graphite/add_graphite_func.ts b/public/app/plugins/datasource/graphite/add_graphite_func.ts index a5c1dc49959..ea3dfe8ff5e 100644 --- a/public/app/plugins/datasource/graphite/add_graphite_func.ts +++ b/public/app/plugins/datasource/graphite/add_graphite_func.ts @@ -1,8 +1,8 @@ -import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; import rst2html from 'rst2html'; import Drop from 'tether-drop'; +import coreModule from 'app/core/core_module'; /** @ngInject */ export function graphiteAddFunc($compile) { @@ -130,7 +130,7 @@ export function graphiteAddFunc($compile) { }; } -angular.module('grafana.directives').directive('graphiteAddFunc', graphiteAddFunc); +coreModule.directive('graphiteAddFunc', graphiteAddFunc); function createFunctionDropDownMenu(funcDefs) { const categories = {}; diff --git a/public/app/plugins/datasource/graphite/func_editor.ts b/public/app/plugins/datasource/graphite/func_editor.ts index 68cc6f1452e..9e19083a9c3 100644 --- a/public/app/plugins/datasource/graphite/func_editor.ts +++ b/public/app/plugins/datasource/graphite/func_editor.ts @@ -1,7 +1,7 @@ -import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; import rst2html from 'rst2html'; +import coreModule from 'app/core/core_module'; /** @ngInject */ export function graphiteFuncEditor($compile, templateSrv, popoverSrv) { @@ -315,4 +315,4 @@ export function graphiteFuncEditor($compile, templateSrv, popoverSrv) { }; } -angular.module('grafana.directives').directive('graphiteFuncEditor', graphiteFuncEditor); +coreModule.directive('graphiteFuncEditor', graphiteFuncEditor); diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts index f1db05cabe8..13948c5d793 100644 --- a/public/app/plugins/datasource/postgres/datasource.ts +++ b/public/app/plugins/datasource/postgres/datasource.ts @@ -20,7 +20,7 @@ export class PostgresDatasource { this.interval = (instanceSettings.jsonData || {}).timeInterval; } - interpolateVariable(value, variable) { + interpolateVariable = (value, variable) => { if (typeof value === 'string') { if (variable.multi || variable.includeAll) { return this.queryModel.quoteLiteral(value); @@ -37,7 +37,7 @@ export class PostgresDatasource { return this.queryModel.quoteLiteral(v); }); return quotedValues.join(','); - } + }; query(options) { const queries = _.filter(options.targets, target => { diff --git a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json index aa9384a907d..47975d83d9c 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json @@ -753,7 +753,7 @@ "steppedLine": false, "targets": [ { - "expr": "increase(grafana_alerting_result_total[1m])", + "expr": "increase(grafana_alerting_active_alerts[1m])", "format": "time_series", "intervalFactor": 3, "legendFormat": "{{state}}", @@ -764,7 +764,7 @@ "thresholds": [], "timeFrom": null, "timeShift": null, - "title": "Grafana alert results", + "title": "Grafana active alerts", "tooltip": { "shared": true, "sort": 0, @@ -1049,4 +1049,4 @@ ] }, "timezone": "" - } \ No newline at end of file + } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 856ab035ea0..8514b6ca7d4 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -5,6 +5,7 @@ import kbn from 'app/core/utils/kbn'; import * as dateMath from 'app/core/utils/datemath'; import PrometheusMetricFindQuery from './metric_find_query'; import { ResultTransformer } from './result_transformer'; +import PrometheusLanguageProvider from './language_provider'; import { BackendSrv } from 'app/core/services/backend_srv'; import addLabelToQuery from './add_label_to_query'; @@ -60,6 +61,7 @@ export class PrometheusDatasource { interval: string; queryTimeout: string; httpMethod: string; + languageProvider: PrometheusLanguageProvider; resultTransformer: ResultTransformer; /** @ngInject */ @@ -76,6 +78,7 @@ export class PrometheusDatasource { this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET'; this.resultTransformer = new ResultTransformer(templateSrv); this.ruleMappings = {}; + this.languageProvider = new PrometheusLanguageProvider(this); } init() { @@ -176,7 +179,6 @@ export class PrometheusDatasource { return this.$q.all(allQueryPromise).then(responseList => { let result = []; - let hints = []; _.each(responseList, (response, index) => { if (response.status === 'error') { @@ -196,19 +198,14 @@ export class PrometheusDatasource { end: queries[index].end, query: queries[index].expr, responseListLength: responseList.length, - responseIndex: index, refId: activeTargets[index].refId, + valueWithRefId: activeTargets[index].valueWithRefId, }; const series = this.resultTransformer.transform(response, transformerOptions); result = [...result, ...series]; - - if (queries[index].hinting) { - const queryHints = getQueryHints(series, this); - hints = [...hints, ...queryHints]; - } }); - return { data: result, hints }; + return { data: result }; }); } @@ -437,6 +434,10 @@ export class PrometheusDatasource { return state; } + getQueryHints(query: string, result: any[]) { + return getQueryHints(query, result, this); + } + loadRules() { this.metadataRequest('/api/v1/rules') .then(res => res.data || res.json()) @@ -463,6 +464,9 @@ export class PrometheusDatasource { case 'ADD_RATE': { return `rate(${query}[5m])`; } + case 'ADD_SUM': { + return `sum(${query.trim()}) by ($1)`; + } case 'EXPAND_RULES': { const mapping = action.mapping; if (mapping) { diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts new file mode 100644 index 00000000000..3dd15eb713e --- /dev/null +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -0,0 +1,347 @@ +import _ from 'lodash'; +import moment from 'moment'; + +import { + CompletionItem, + CompletionItemGroup, + LanguageProvider, + TypeaheadInput, + TypeaheadOutput, +} from 'app/types/explore'; + +import { parseSelector, processLabels, RATE_RANGES } from './language_utils'; +import PromqlSyntax, { FUNCTIONS } from './promql'; + +const DEFAULT_KEYS = ['job', 'instance']; +const EMPTY_SELECTOR = '{}'; +const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms +const HISTORY_ITEM_COUNT = 5; +const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h + +const wrapLabel = (label: string) => ({ label }); + +const setFunctionMove = (suggestion: CompletionItem): CompletionItem => { + suggestion.move = -1; + return suggestion; +}; + +export function addHistoryMetadata(item: CompletionItem, history: any[]): CompletionItem { + const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF; + const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label); + const count = historyForItem.length; + const recent = historyForItem[0]; + let hint = `Queried ${count} times in the last 24h.`; + if (recent) { + const lastQueried = moment(recent.ts).fromNow(); + hint = `${hint} Last queried ${lastQueried}.`; + } + return { + ...item, + documentation: hint, + }; +} + +export default class PromQlLanguageProvider extends LanguageProvider { + histogramMetrics?: string[]; + labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] + labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] + metrics?: string[]; + logLabelOptions: any[]; + supportsLogs?: boolean; + started: boolean; + + constructor(datasource: any, initialValues?: any) { + super(); + + this.datasource = datasource; + this.histogramMetrics = []; + this.labelKeys = {}; + this.labelValues = {}; + this.metrics = []; + this.supportsLogs = false; + this.started = false; + + Object.assign(this, initialValues); + } + // Strip syntax chars + cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); + + getSyntax() { + return PromqlSyntax; + } + + request = url => { + return this.datasource.metadataRequest(url); + }; + + start = () => { + if (!this.started) { + this.started = true; + return Promise.all([this.fetchMetricNames(), this.fetchHistogramMetrics()]); + } + return Promise.resolve([]); + }; + + // Keep this DOM-free for testing + provideCompletionItems({ prefix, wrapperClasses, text }: TypeaheadInput, context?: any): TypeaheadOutput { + // Syntax spans have 3 classes by default. More indicate a recognized token + const tokenRecognized = wrapperClasses.length > 3; + // Determine candidates by CSS context + if (_.includes(wrapperClasses, 'context-range')) { + // Suggestions for metric[|] + return this.getRangeCompletionItems(); + } else if (_.includes(wrapperClasses, 'context-labels')) { + // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|} + return this.getLabelCompletionItems.apply(this, arguments); + } else if (_.includes(wrapperClasses, 'context-aggregation')) { + return this.getAggregationCompletionItems.apply(this, arguments); + } else if ( + // Show default suggestions in a couple of scenarios + (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token + (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace + text.match(/[+\-*/^%]/) // Anything after binary operator + ) { + return this.getEmptyCompletionItems(context || {}); + } + + return { + suggestions: [], + }; + } + + getEmptyCompletionItems(context: any): TypeaheadOutput { + const { history } = context; + const { metrics } = this; + const suggestions: CompletionItemGroup[] = []; + + if (history && history.length > 0) { + const historyItems = _.chain(history) + .uniqBy('query') + .take(HISTORY_ITEM_COUNT) + .map(h => h.query) + .map(wrapLabel) + .map(item => addHistoryMetadata(item, history)) + .value(); + + suggestions.push({ + prefixMatch: true, + skipSort: true, + label: 'History', + items: historyItems, + }); + } + + suggestions.push({ + prefixMatch: true, + label: 'Functions', + items: FUNCTIONS.map(setFunctionMove), + }); + + if (metrics) { + suggestions.push({ + label: 'Metrics', + items: metrics.map(wrapLabel), + }); + } + return { suggestions }; + } + + getRangeCompletionItems(): TypeaheadOutput { + return { + context: 'context-range', + suggestions: [ + { + label: 'Range vector', + items: [...RATE_RANGES].map(wrapLabel), + }, + ], + }; + } + + getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput { + let refresher: Promise = null; + const suggestions: CompletionItemGroup[] = []; + + // Stitch all query lines together to support multi-line queries + let queryOffset; + const queryText = value.document.getBlocks().reduce((text, block) => { + const blockText = block.getText(); + if (value.anchorBlock.key === block.key) { + // Newline characters are not accounted for but this is irrelevant + // for the purpose of extracting the selector string + queryOffset = value.anchorOffset + text.length; + } + text += blockText; + return text; + }, ''); + + const leftSide = queryText.slice(0, queryOffset); + const openParensAggregationIndex = leftSide.lastIndexOf('('); + const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('('); + const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex; + + let selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex); + + // Range vector syntax not accounted for by subsequent parse so discard it if present + selectorString = selectorString.replace(/\[[^\]]+\]$/, ''); + + const selector = parseSelector(selectorString, selectorString.length - 2).selector; + + const labelKeys = this.labelKeys[selector]; + if (labelKeys) { + suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) }); + } else { + refresher = this.fetchSeriesLabels(selector); + } + + return { + refresher, + suggestions, + context: 'context-aggregation', + }; + } + + getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput { + let context: string; + let refresher: Promise = null; + const suggestions: CompletionItemGroup[] = []; + const line = value.anchorBlock.getText(); + const cursorOffset: number = value.anchorOffset; + + // Get normalized selector + let selector; + let parsedSelector; + try { + parsedSelector = parseSelector(line, cursorOffset); + selector = parsedSelector.selector; + } catch { + selector = EMPTY_SELECTOR; + } + const containsMetric = selector.indexOf('__name__=') > -1; + const existingKeys = parsedSelector ? parsedSelector.labelKeys : []; + + if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) { + // Label values + if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) { + const labelValues = this.labelValues[selector][labelKey]; + context = 'context-label-values'; + suggestions.push({ + label: `Label values for "${labelKey}"`, + items: labelValues.map(wrapLabel), + }); + } + } else { + // Label keys + const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS); + if (labelKeys) { + const possibleKeys = _.difference(labelKeys, existingKeys); + if (possibleKeys.length > 0) { + context = 'context-labels'; + suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) }); + } + } + } + + // Query labels for selector + // Temporarily add skip for logging + if (selector && !this.labelValues[selector] && !this.supportsLogs) { + if (selector === EMPTY_SELECTOR) { + // Query label values for default labels + refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key))); + } else { + refresher = this.fetchSeriesLabels(selector, !containsMetric); + } + } + + return { context, refresher, suggestions }; + } + + async fetchMetricNames() { + const url = '/api/v1/label/__name__/values'; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + this.metrics = body.data; + } catch (error) { + console.error(error); + } + } + + async fetchHistogramMetrics() { + await this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true); + const histogramSeries = this.labelValues[HISTOGRAM_SELECTOR]; + if (histogramSeries && histogramSeries['__name__']) { + this.histogramMetrics = histogramSeries['__name__'].slice().sort(); + } + } + + // Temporarily here while reusing this field for logging + async fetchLogLabels() { + const url = '/api/prom/label'; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + const labelKeys = body.data.slice().sort(); + const labelKeysBySelector = { + ...this.labelKeys, + [EMPTY_SELECTOR]: labelKeys, + }; + const labelValuesByKey = {}; + this.logLabelOptions = []; + for (const key of labelKeys) { + const valuesUrl = `/api/prom/label/${key}/values`; + const res = await this.request(valuesUrl); + const body = await (res.data || res.json()); + const values = body.data.slice().sort(); + labelValuesByKey[key] = values; + this.logLabelOptions.push({ + label: key, + value: key, + children: values.map(value => ({ label: value, value })), + }); + } + this.labelValues = { [EMPTY_SELECTOR]: labelValuesByKey }; + this.labelKeys = labelKeysBySelector; + } catch (e) { + console.error(e); + } + } + + async fetchLabelValues(key: string) { + const url = `/api/v1/label/${key}/values`; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + const exisingValues = this.labelValues[EMPTY_SELECTOR]; + const values = { + ...exisingValues, + [key]: body.data, + }; + this.labelValues = { + ...this.labelValues, + [EMPTY_SELECTOR]: values, + }; + } catch (e) { + console.error(e); + } + } + + async fetchSeriesLabels(name: string, withName?: boolean) { + const url = `/api/v1/series?match[]=${name}`; + try { + const res = await this.request(url); + const body = await (res.data || res.json()); + const { keys, values } = processLabels(body.data, withName); + this.labelKeys = { + ...this.labelKeys, + [name]: keys, + }; + this.labelValues = { + ...this.labelValues, + [name]: values, + }; + } catch (e) { + console.error(e); + } + } +} diff --git a/public/app/features/explore/utils/prometheus.ts b/public/app/plugins/datasource/prometheus/language_utils.ts similarity index 84% rename from public/app/features/explore/utils/prometheus.ts rename to public/app/plugins/datasource/prometheus/language_utils.ts index 8c41b94d684..5995c427cd1 100644 --- a/public/app/features/explore/utils/prometheus.ts +++ b/public/app/plugins/datasource/prometheus/language_utils.ts @@ -23,12 +23,9 @@ export function processLabels(labels, withName = false) { return { values, keys: Object.keys(values) }; } -// Strip syntax chars -export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); - // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/; const selectorRegexp = /\{[^}]*?\}/; -const labelRegexp = /\b\w+="[^"\n]*?"/g; +const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g; export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } { if (!query.match(selectorRegexp)) { // Special matcher for metrics @@ -66,11 +63,8 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any // Extract clean labels to form clean selector, incomplete labels are dropped const selector = query.slice(prefixOpen, suffixClose); const labels = {}; - selector.replace(labelRegexp, match => { - const delimiterIndex = match.indexOf('='); - const key = match.slice(0, delimiterIndex); - const value = match.slice(delimiterIndex + 1, match.length); - labels[key] = value; + selector.replace(labelRegexp, (_, key, operator, value) => { + labels[key] = { value, operator }; return ''; }); @@ -78,12 +72,12 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any const metricPrefix = query.slice(0, prefixOpen); const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/); if (metricMatch) { - labels['__name__'] = `"${metricMatch[0]}"`; + labels['__name__'] = { value: `"${metricMatch[0]}"`, operator: '=' }; } // Build sorted selector const labelKeys = Object.keys(labels).sort(); - const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(','); + const cleanSelector = labelKeys.map(key => `${key}${labels[key].operator}${labels[key].value}`).join(','); const selectorString = ['{', cleanSelector, '}'].join(''); diff --git a/public/app/features/explore/slate-plugins/prism/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts similarity index 100% rename from public/app/features/explore/slate-plugins/prism/promql.ts rename to public/app/plugins/datasource/prometheus/promql.ts diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts index 5efaadb77be..ea505832468 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.ts +++ b/public/app/plugins/datasource/prometheus/query_hints.ts @@ -1,100 +1,118 @@ import _ from 'lodash'; -export function getQueryHints(series: any[], datasource?: any): any[] { - const hints = series.map((s, i) => { - const query: string = s.query; - const index: number = s.responseIndex; - if (query === undefined || index === undefined) { - return null; - } +import { QueryHint } from 'app/types/explore'; - // ..._bucket metric needs a histogram_quantile() - const histogramMetric = query.trim().match(/^\w+_bucket$/); - if (histogramMetric) { - const label = 'Time series has buckets, you probably wanted a histogram.'; - return { - index, +/** + * Number of time series results needed before starting to suggest sum aggregation hints + */ +export const SUM_HINT_THRESHOLD_COUNT = 20; + +export function getQueryHints(query: string, series?: any[], datasource?: any): QueryHint[] { + const hints = []; + + // ..._bucket metric needs a histogram_quantile() + const histogramMetric = query.trim().match(/^\w+_bucket$/); + if (histogramMetric) { + const label = 'Time series has buckets, you probably wanted a histogram.'; + hints.push({ + type: 'HISTOGRAM_QUANTILE', + label, + fix: { + label: 'Fix by adding histogram_quantile().', + action: { + type: 'ADD_HISTOGRAM_QUANTILE', + query, + }, + }, + }); + } + + // Check for monotony on series (table results are being ignored here) + if (series && series.length > 0) { + series.forEach(s => { + const datapoints: number[][] = s.datapoints; + if (query.indexOf('rate(') === -1 && datapoints.length > 1) { + let increasing = false; + const nonNullData = datapoints.filter(dp => dp[0] !== null); + const monotonic = nonNullData.every((dp, index) => { + if (index === 0) { + return true; + } + increasing = increasing || dp[0] > nonNullData[index - 1][0]; + // monotonic? + return dp[0] >= nonNullData[index - 1][0]; + }); + if (increasing && monotonic) { + const simpleMetric = query.trim().match(/^\w+$/); + let label = 'Time series is monotonously increasing.'; + let fix; + if (simpleMetric) { + fix = { + label: 'Fix by adding rate().', + action: { + type: 'ADD_RATE', + query, + }, + }; + } else { + label = `${label} Try applying a rate() function.`; + } + hints.push({ + type: 'APPLY_RATE', + label, + fix, + }); + } + } + }); + } + + // Check for recording rules expansion + if (datasource && datasource.ruleMappings) { + const mapping = datasource.ruleMappings; + const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { + if (query.search(ruleName) > -1) { + return { + ...acc, + [ruleName]: mapping[ruleName], + }; + } + return acc; + }, {}); + if (_.size(mappingForQuery) > 0) { + const label = 'Query contains recording rules.'; + hints.push({ + type: 'EXPAND_RULES', label, fix: { - label: 'Fix by adding histogram_quantile().', + label: 'Expand rules', action: { - type: 'ADD_HISTOGRAM_QUANTILE', + type: 'EXPAND_RULES', query, - index, + mapping: mappingForQuery, }, }, - }; - } - - // Check for monotony - const datapoints: number[][] = s.datapoints; - if (query.indexOf('rate(') === -1 && datapoints.length > 1) { - let increasing = false; - const nonNullData = datapoints.filter(dp => dp[0] !== null); - const monotonic = nonNullData.every((dp, index) => { - if (index === 0) { - return true; - } - increasing = increasing || dp[0] > nonNullData[index - 1][0]; - // monotonic? - return dp[0] >= nonNullData[index - 1][0]; }); - if (increasing && monotonic) { - const simpleMetric = query.trim().match(/^\w+$/); - let label = 'Time series is monotonously increasing.'; - let fix; - if (simpleMetric) { - fix = { - label: 'Fix by adding rate().', - action: { - type: 'ADD_RATE', - query, - index, - }, - }; - } else { - label = `${label} Try applying a rate() function.`; - } - return { - label, - index, - fix, - }; - } } + } - // Check for recording rules expansion - if (datasource && datasource.ruleMappings) { - const mapping = datasource.ruleMappings; - const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => { - if (query.search(ruleName) > -1) { - return { - ...acc, - [ruleName]: mapping[ruleName], - }; - } - return acc; - }, {}); - if (_.size(mappingForQuery) > 0) { - const label = 'Query contains recording rules.'; - return { - label, - index, - fix: { - label: 'Expand rules', - action: { - type: 'EXPAND_RULES', - query, - index, - mapping: mappingForQuery, - }, + if (series.length >= SUM_HINT_THRESHOLD_COUNT) { + const simpleMetric = query.trim().match(/^\w+$/); + if (simpleMetric) { + hints.push({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: query, + preventSubmit: true, }, - }; - } + }, + }); } + } - // No hint found - return null; - }); - return hints; + return hints.length > 0 ? hints : null; } diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index bf916bebf04..c9693eaf657 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -8,7 +8,14 @@ export class ResultTransformer { const prometheusResult = response.data.data.result; if (options.format === 'table') { - return [this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.refId)]; + return [ + this.transformMetricDataToTable( + prometheusResult, + options.responseListLength, + options.refId, + options.valueWithRefId + ), + ]; } else if (options.format === 'heatmap') { let seriesList = []; prometheusResult.sort(sortSeriesByLabel); @@ -66,12 +73,11 @@ export class ResultTransformer { return { datapoints: dps, query: options.query, - responseIndex: options.responseIndex, target: metricLabel, }; } - transformMetricDataToTable(md, resultCount: number, refId: string) { + transformMetricDataToTable(md, resultCount: number, refId: string, valueWithRefId?: boolean) { const table = new TableModel(); let i, j; const metricLabels = {}; @@ -96,7 +102,7 @@ export class ResultTransformer { metricLabels[label] = labelIndex + 1; table.columns.push({ text: label, filterable: !label.startsWith('__') }); }); - const valueText = resultCount > 1 ? `Value #${refId}` : 'Value'; + const valueText = resultCount > 1 || valueWithRefId ? `Value #${refId}` : 'Value'; table.columns.push({ text: valueText }); // Populate rows, set value to empty string when label not present. diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts new file mode 100644 index 00000000000..20e148efd57 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts @@ -0,0 +1,273 @@ +import Plain from 'slate-plain-serializer'; + +import LanguageProvider from '../language_provider'; + +describe('Language completion provider', () => { + const datasource = { + metadataRequest: () => ({ data: { data: [] } }), + }; + + it('returns default suggestions on emtpty context', () => { + const instance = new LanguageProvider(datasource); + const result = instance.provideCompletionItems({ text: '', prefix: '', wrapperClasses: [] }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(2); + }); + + describe('range suggestions', () => { + it('returns range suggestions in range context', () => { + const instance = new LanguageProvider(datasource); + const result = instance.provideCompletionItems({ text: '1', prefix: '1', wrapperClasses: ['context-range'] }); + expect(result.context).toBe('context-range'); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions).toEqual([ + { + items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }], + label: 'Range vector', + }, + ]); + }); + }); + + describe('metric suggestions', () => { + it('returns metrics suggestions by default', () => { + const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); + const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', wrapperClasses: [] }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(2); + }); + + it('returns default suggestions after a binary operator', () => { + const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] }); + const result = instance.provideCompletionItems({ text: '*', prefix: '', wrapperClasses: [] }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeUndefined(); + expect(result.suggestions.length).toEqual(2); + }); + }); + + describe('label suggestions', () => { + it('returns default label suggestions on label context and no metric', () => { + const instance = new LanguageProvider(datasource); + const value = Plain.deserialize('{}'); + const range = value.selection.merge({ + anchorOffset: 1, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); + }); + + it('returns label suggestions on label context and metric', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); + const value = Plain.deserialize('metric{}'); + const range = value.selection.merge({ + anchorOffset: 7, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label suggestions on label context but leaves out labels that already exist', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }, + }); + const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}'); + const range = value.selection.merge({ + anchorOffset: 36, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-labels'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label value suggestions inside a label value context after a negated matching operator', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{}': ['label'] }, + labelValues: { '{}': { label: ['a', 'b', 'c'] } }, + }); + const value = Plain.deserialize('{label!=}'); + const range = value.selection.merge({ anchorOffset: 8 }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '!=', + prefix: '', + wrapperClasses: ['context-labels'], + labelKey: 'label', + value: valueWithSelection, + }); + expect(result.context).toBe('context-label-values'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }], + label: 'Label values for "label"', + }, + ]); + }); + + it('returns a refresher on label context and unavailable metric', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } }); + const value = Plain.deserialize('metric{}'); + const range = value.selection.merge({ + anchorOffset: 7, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-labels'], + value: valueWithSelection, + }); + expect(result.context).toBeUndefined(); + expect(result.refresher).toBeInstanceOf(Promise); + expect(result.suggestions).toEqual([]); + }); + + it('returns label values on label context when given a metric and a label key', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric"}': ['bar'] }, + labelValues: { '{__name__="metric"}': { bar: ['baz'] } }, + }); + const value = Plain.deserialize('metric{bar=ba}'); + const range = value.selection.merge({ + anchorOffset: 13, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '=ba', + prefix: 'ba', + wrapperClasses: ['context-labels'], + labelKey: 'bar', + value: valueWithSelection, + }); + expect(result.context).toBe('context-label-values'); + expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); + }); + + it('returns label suggestions on aggregation context and metric w/ selector', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } }); + const value = Plain.deserialize('sum(metric{foo="xx"}) by ()'); + const range = value.selection.merge({ + anchorOffset: 26, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label suggestions on aggregation context and metric w/o selector', () => { + const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } }); + const value = Plain.deserialize('sum(metric) by ()'); + const range = value.selection.merge({ + anchorOffset: 16, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + }); + + it('returns label suggestions inside a multi-line aggregation context', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, + }); + const value = Plain.deserialize('sum(\nmetric\n)\nby ()'); + const aggregationTextBlock = value.document.getBlocksAsArray()[3]; + const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }], + label: 'Labels', + }, + ]); + }); + + it('returns label suggestions inside an aggregation context with a range vector', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] }, + }); + const value = Plain.deserialize('sum(rate(metric[1h])) by ()'); + const range = value.selection.merge({ + anchorOffset: 26, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }], + label: 'Labels', + }, + ]); + }); + + it('returns label suggestions inside an aggregation context with a range vector and label', () => { + const instance = new LanguageProvider(datasource, { + labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] }, + }); + const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()'); + const range = value.selection.merge({ + anchorOffset: 42, + }); + const valueWithSelection = value.change().select(range).value; + const result = instance.provideCompletionItems({ + text: '', + prefix: '', + wrapperClasses: ['context-aggregation'], + value: valueWithSelection, + }); + expect(result.context).toBe('context-aggregation'); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'label1' }, { label: 'label2' }, { label: 'label3' }], + label: 'Labels', + }, + ]); + }); + }); +}); diff --git a/public/app/features/explore/utils/prometheus.test.ts b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts similarity index 97% rename from public/app/features/explore/utils/prometheus.test.ts rename to public/app/plugins/datasource/prometheus/specs/language_utils.test.ts index 4e84deaa7e8..748217e21b7 100644 --- a/public/app/features/explore/utils/prometheus.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/language_utils.test.ts @@ -1,4 +1,4 @@ -import { parseSelector } from './prometheus'; +import { parseSelector } from '../language_utils'; describe('parseSelector()', () => { let parsed; diff --git a/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts index 2c72203137d..3b782d3dd09 100644 --- a/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/query_hints.test.ts @@ -1,35 +1,32 @@ -import { getQueryHints } from '../query_hints'; +import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from '../query_hints'; describe('getQueryHints()', () => { it('returns no hints for no series', () => { - expect(getQueryHints([])).toEqual([]); + expect(getQueryHints('', [])).toEqual(null); }); it('returns no hints for empty series', () => { - expect(getQueryHints([{ datapoints: [], query: '' }])).toEqual([null]); + expect(getQueryHints('', [{ datapoints: [] }])).toEqual(null); }); it('returns no hint for a monotonously decreasing series', () => { - const series = [{ datapoints: [[23, 1000], [22, 1001]], query: 'metric', responseIndex: 0 }]; - const hints = getQueryHints(series); - expect(hints).toEqual([null]); + const series = [{ datapoints: [[23, 1000], [22, 1001]] }]; + const hints = getQueryHints('metric', series); + expect(hints).toEqual(null); }); it('returns no hint for a flat series', () => { - const series = [ - { datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]], query: 'metric', responseIndex: 0 }, - ]; - const hints = getQueryHints(series); - expect(hints).toEqual([null]); + const series = [{ datapoints: [[null, 1000], [23, 1001], [null, 1002], [23, 1003]] }]; + const hints = getQueryHints('metric', series); + expect(hints).toEqual(null); }); it('returns a rate hint for a monotonously increasing series', () => { - const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'metric', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000], [24, 1001]] }]; + const hints = getQueryHints('metric', series); expect(hints.length).toBe(1); expect(hints[0]).toMatchObject({ label: 'Time series is monotonously increasing.', - index: 0, fix: { action: { type: 'ADD_RATE', @@ -40,26 +37,25 @@ describe('getQueryHints()', () => { }); it('returns no rate hint for a monotonously increasing series that already has a rate', () => { - const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'rate(metric[1m])', responseIndex: 0 }]; - const hints = getQueryHints(series); - expect(hints).toEqual([null]); + const series = [{ datapoints: [[23, 1000], [24, 1001]] }]; + const hints = getQueryHints('rate(metric[1m])', series); + expect(hints).toEqual(null); }); it('returns a rate hint w/o action for a complex monotonously increasing series', () => { - const series = [{ datapoints: [[23, 1000], [24, 1001]], query: 'sum(metric)', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000], [24, 1001]] }]; + const hints = getQueryHints('sum(metric)', series); expect(hints.length).toBe(1); expect(hints[0].label).toContain('rate()'); expect(hints[0].fix).toBeUndefined(); }); it('returns a rate hint for a monotonously increasing series with missing data', () => { - const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]], query: 'metric', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000], [null, 1001], [24, 1002]] }]; + const hints = getQueryHints('metric', series); expect(hints.length).toBe(1); expect(hints[0]).toMatchObject({ label: 'Time series is monotonously increasing.', - index: 0, fix: { action: { type: 'ADD_RATE', @@ -70,12 +66,11 @@ describe('getQueryHints()', () => { }); it('returns a histogram hint for a bucket series', () => { - const series = [{ datapoints: [[23, 1000]], query: 'metric_bucket', responseIndex: 0 }]; - const hints = getQueryHints(series); + const series = [{ datapoints: [[23, 1000]] }]; + const hints = getQueryHints('metric_bucket', series); expect(hints.length).toBe(1); expect(hints[0]).toMatchObject({ label: 'Time series has buckets, you probably wanted a histogram.', - index: 0, fix: { action: { type: 'ADD_HISTOGRAM_QUANTILE', @@ -84,4 +79,25 @@ describe('getQueryHints()', () => { }, }); }); + + it('returns a sum hint when many time series results are returned for a simple metric', () => { + const seriesCount = SUM_HINT_THRESHOLD_COUNT; + const series = Array.from({ length: seriesCount }, _ => ({ + datapoints: [[0, 0], [0, 0]], + })); + const hints = getQueryHints('metric', series); + expect(hints.length).toBe(1); + expect(hints[0]).toMatchObject({ + type: 'ADD_SUM', + label: 'Many time series results returned.', + fix: { + label: 'Consider aggregating with sum().', + action: { + type: 'ADD_SUM', + query: 'metric', + preventSubmit: true, + }, + }, + }); + }); }); diff --git a/public/app/plugins/datasource/stackdriver/config_ctrl.ts b/public/app/plugins/datasource/stackdriver/config_ctrl.ts index d3847562156..e74659536c8 100644 --- a/public/app/plugins/datasource/stackdriver/config_ctrl.ts +++ b/public/app/plugins/datasource/stackdriver/config_ctrl.ts @@ -5,13 +5,23 @@ export class StackdriverConfigCtrl { jsonText: string; validationErrors: string[] = []; inputDataValid: boolean; + authenticationTypes: any[]; + defaultAuthenticationType: string; /** @ngInject */ constructor(datasourceSrv) { + this.defaultAuthenticationType = 'jwt'; this.datasourceSrv = datasourceSrv; this.current.jsonData = this.current.jsonData || {}; + this.current.jsonData.authenticationType = this.current.jsonData.authenticationType + ? this.current.jsonData.authenticationType + : this.defaultAuthenticationType; this.current.secureJsonData = this.current.secureJsonData || {}; this.current.secureJsonFields = this.current.secureJsonFields || {}; + this.authenticationTypes = [ + { key: this.defaultAuthenticationType, value: 'Google JWT File' }, + { key: 'gce', value: 'GCE Default Service Account' }, + ]; } save(jwt) { @@ -35,6 +45,10 @@ export class StackdriverConfigCtrl { this.validationErrors.push('Client Email field missing in JWT file.'); } + if (!jwt.project_id || jwt.project_id.length === 0) { + this.validationErrors.push('Project Id field missing in JWT file.'); + } + if (this.validationErrors.length === 0) { this.inputDataValid = true; return true; @@ -67,7 +81,7 @@ export class StackdriverConfigCtrl { this.inputDataValid = false; this.jsonText = ''; - this.current.jsonData = {}; + this.current.jsonData = Object.assign({}, { authenticationType: this.current.jsonData.authenticationType }); this.current.secureJsonData = {}; this.current.secureJsonFields = {}; } diff --git a/public/app/plugins/datasource/stackdriver/datasource.ts b/public/app/plugins/datasource/stackdriver/datasource.ts index 7ea748e1082..034333cbb86 100644 --- a/public/app/plugins/datasource/stackdriver/datasource.ts +++ b/public/app/plugins/datasource/stackdriver/datasource.ts @@ -1,11 +1,14 @@ import { stackdriverUnitMappings } from './constants'; import appEvents from 'app/core/app_events'; +import _ from 'lodash'; export default class StackdriverDatasource { id: number; url: string; baseUrl: string; projectName: string; + authenticationType: string; + queryPromise: Promise; /** @ngInject */ constructor(instanceSettings, private backendSrv, private templateSrv, private timeSrv) { @@ -14,6 +17,7 @@ export default class StackdriverDatasource { this.doRequest = this.doRequest; this.id = instanceSettings.id; this.projectName = instanceSettings.jsonData.defaultProject || ''; + this.authenticationType = instanceSettings.jsonData.authenticationType || 'jwt'; } async getTimeSeries(options) { @@ -46,16 +50,20 @@ export default class StackdriverDatasource { }; }); - const { data } = await this.backendSrv.datasourceRequest({ - url: '/api/tsdb/query', - method: 'POST', - data: { - from: options.range.from.valueOf().toString(), - to: options.range.to.valueOf().toString(), - queries, - }, - }); - return data; + if (queries.length > 0) { + const { data } = await this.backendSrv.datasourceRequest({ + url: '/api/tsdb/query', + method: 'POST', + data: { + from: options.range.from.valueOf().toString(), + to: options.range.to.valueOf().toString(), + queries, + }, + }); + return data; + } else { + return { results: [] }; + } } async getLabels(metricType, refId) { @@ -89,7 +97,7 @@ export default class StackdriverDatasource { } resolvePanelUnitFromTargets(targets: any[]) { - let unit = 'none'; + let unit; if (targets.length > 0 && targets.every(t => t.unit === targets[0].unit)) { if (stackdriverUnitMappings.hasOwnProperty(targets[0].unit)) { unit = stackdriverUnitMappings[targets[0].unit]; @@ -106,21 +114,24 @@ export default class StackdriverDatasource { if (!queryRes.series) { return; } - const unit = this.resolvePanelUnitFromTargets(options.targets); queryRes.series.forEach(series => { - result.push({ + let timeSerie: any = { target: series.name, datapoints: series.points, refId: queryRes.refId, meta: queryRes.meta, - unit, - }); + }; + if (unit) { + timeSerie = { ...timeSerie, unit }; + } + result.push(timeSerie); }); }); + return { data: result }; + } else { + return { data: [] }; } - - return { data: result }; } async annotationQuery(options) { @@ -170,76 +181,84 @@ export default class StackdriverDatasource { throw new Error('Template variables support is not yet imlemented'); } - testDatasource() { - const path = `v3/projects/${this.projectName}/metricDescriptors`; - return this.doRequest(`${this.baseUrl}${path}`) - .then(response => { - if (response.status === 200) { - return { - status: 'success', - message: 'Successfully queried the Stackdriver API.', - title: 'Success', - }; - } - - return { - status: 'error', - message: 'Returned http status code ' + response.status, - }; - }) - .catch(error => { - let message = 'Stackdriver: '; - message += error.statusText ? error.statusText + ': ' : ''; - + async testDatasource() { + let status, message; + const defaultErrorMessage = 'Cannot connect to Stackdriver API'; + try { + const projectName = await this.getDefaultProject(); + const path = `v3/projects/${projectName}/metricDescriptors`; + const response = await this.doRequest(`${this.baseUrl}${path}`); + if (response.status === 200) { + status = 'success'; + message = 'Successfully queried the Stackdriver API.'; + } else { + status = 'error'; + message = response.statusText ? response.statusText : defaultErrorMessage; + } + } catch (error) { + status = 'error'; + if (_.isString(error)) { + message = error; + } else { + message = 'Stackdriver: '; + message += error.statusText ? error.statusText : defaultErrorMessage; if (error.data && error.data.error && error.data.error.code) { - // 400, 401 - message += error.data.error.code + '. ' + error.data.error.message; - } else { - message += 'Cannot connect to Stackdriver API'; + message += ': ' + error.data.error.code + '. ' + error.data.error.message; } - return { - status: 'error', - message: message, - }; - }); + } + } finally { + return { + status, + message, + }; + } } - async getProjects() { - const response = await this.doRequest(`/cloudresourcemanager/v1/projects`); - return response.data.projects.map(p => ({ id: p.projectId, name: p.name })); + formatStackdriverError(error) { + let message = 'Stackdriver: '; + message += error.statusText ? error.statusText + ': ' : ''; + if (error.data && error.data.error) { + try { + const res = JSON.parse(error.data.error); + message += res.error.code + '. ' + res.error.message; + } catch (err) { + message += error.data.error; + } + } else { + message += 'Cannot connect to Stackdriver API'; + } + return message; } async getDefaultProject() { try { - const projects = await this.getProjects(); - if (projects && projects.length > 0) { - const test = projects.filter(p => p.id === this.projectName)[0]; - return test; + if (this.authenticationType === 'gce' || !this.projectName) { + const { data } = await this.backendSrv.datasourceRequest({ + url: '/api/tsdb/query', + method: 'POST', + data: { + queries: [ + { + refId: 'ensureDefaultProjectQuery', + type: 'ensureDefaultProjectQuery', + datasourceId: this.id, + }, + ], + }, + }); + this.projectName = data.results.ensureDefaultProjectQuery.meta.defaultProject; + return this.projectName; } else { - throw new Error('No projects found'); + return this.projectName; } } catch (error) { - let message = 'Projects cannot be fetched: '; - message += error.statusText ? error.statusText + ': ' : ''; - if (error && error.data && error.data.error && error.data.error.message) { - if (error.data.error.code === 403) { - message += ` - A list of projects could not be fetched from the Google Cloud Resource Manager API. - You might need to enable it first: - https://console.developers.google.com/apis/library/cloudresourcemanager.googleapis.com`; - } else { - message += error.data.error.code + '. ' + error.data.error.message; - } - } else { - message += 'Cannot connect to Stackdriver API'; - } - appEvents.emit('ds-request-error', message); + throw this.formatStackdriverError(error); } } - async getMetricTypes(projectId: string) { + async getMetricTypes(projectName: string) { try { - const metricsApiPath = `v3/projects/${projectId}/metricDescriptors`; + const metricsApiPath = `v3/projects/${projectName}/metricDescriptors`; const { data } = await this.doRequest(`${this.baseUrl}${metricsApiPath}`); const metrics = data.metricDescriptors.map(m => { @@ -253,7 +272,8 @@ export default class StackdriverDatasource { return metrics; } catch (error) { - console.log(error); + appEvents.emit('ds-request-error', this.formatStackdriverError(error)); + return []; } } diff --git a/public/app/plugins/datasource/stackdriver/filter_segments.ts b/public/app/plugins/datasource/stackdriver/filter_segments.ts index 9eb27f31975..5adb56e2fcf 100644 --- a/public/app/plugins/datasource/stackdriver/filter_segments.ts +++ b/public/app/plugins/datasource/stackdriver/filter_segments.ts @@ -44,7 +44,7 @@ export class FilterSegments { this.removeSegment.value = DefaultRemoveFilterValue; return Promise.resolve([this.removeSegment]); } else { - return this.getFilterKeysFunc(); + return this.getFilterKeysFunc(segment, DefaultRemoveFilterValue); } } diff --git a/public/app/plugins/datasource/stackdriver/partials/config.html b/public/app/plugins/datasource/stackdriver/partials/config.html index 46b79d8bb0d..9be7eef0e68 100644 --- a/public/app/plugins/datasource/stackdriver/partials/config.html +++ b/public/app/plugins/datasource/stackdriver/partials/config.html @@ -1,37 +1,54 @@
    -
    GCP Service Account
    +

    Stackdriver Authentication

    +

    There are two ways to authenticate the Stackdriver plugin - either by uploading a Service Account key file, or by + automatically retrieving credentials from the Google metadata server. The latter option is only available + when running Grafana on a GCE virtual machine.

    + +
    Uploading a Service Account Key File

    - To authenticate with the Stackdriver API, you need to create a Google Cloud Platform (GCP) Service Account for + First you need to create a Google Cloud Platform (GCP) Service Account for the Project you want to show data for. A Grafana datasource integrates with one GCP Project. If you want to visualize data from multiple GCP Projects then you need to create one datasource per GCP Project.

    - The Monitoring Viewer role provides all the permissions that Grafana needs. + The Monitoring Viewer role provides all the permissions that Grafana needs. The following API + needs to be enabled on GCP for the datasource to work: Monitoring + API

    + +
    GCE Default Service Account

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

    + If Grafana is running on a Google Compute Engine (GCE) virtual machine, it is possible for Grafana to + automatically retrieve the default project id and authentication token from the metadata server. In order for this to + work, you need to make sure that you have a service account that is setup as the default account for the virtual + machine and that the service account has been given read access to the Stackdriver Monitoring API.

    +

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

    + the documentation. +

    -

    Service Account Authentication

    +

    Authentication

    Upload your Service Account key file or paste in the contents of the file. The file contents will be encrypted and saved in the Grafana database.
    -
    +
    +
    + Authentication Type +
    + +
    +
    +
    + +
    @@ -52,23 +69,23 @@
    -
    +
    Uploaded Key Details
    - Project + Project
    - Client Email - -
    + Client Email + +
    - Token URI + Token URI
    - Private Key + Private Key
    @@ -81,6 +98,8 @@
    -
    - Do not forget to save your changes after uploading a file. -
    +

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

    + +

    + Verify GCE default service account by clicking Save & Test

    diff --git a/public/app/plugins/datasource/stackdriver/partials/query.editor.html b/public/app/plugins/datasource/stackdriver/partials/query.editor.html index 7cc93e9d1a8..98c8fcc83e8 100755 --- a/public/app/plugins/datasource/stackdriver/partials/query.editor.html +++ b/public/app/plugins/datasource/stackdriver/partials/query.editor.html @@ -15,8 +15,7 @@
    Project - +